Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2839.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2839
affected:
- src/CodeIndex/Cli/ExportImportCommandRunner.cs
- tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs
---

## English

- **`cdidx export` now replaces archive outputs atomically (#2839)** — archive exports are written to a same-directory temporary file and moved into place only after the zip is complete, so a failed export no longer deletes the previous archive.

## 日本語

- **`cdidx export` が archive 出力を atomic に置換するようになりました (#2839)** — archive export は同一ディレクトリの一時ファイルへ書き終えてから移動するため、失敗した export が既存 archive を削除しなくなりました。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2842.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2842
affected:
- src/CodeIndex/Cli/ReportCommandRunner.cs
- tests/CodeIndex.Tests/ReportCommandRunnerTests.cs
---

## English

- **`cdidx report` now replaces bundle outputs atomically (#2842)** — report tarballs are written to a same-directory temporary file and moved into place only after the gzip/tar payload is complete, preserving the previous bundle on failure.

## 日本語

- **`cdidx report` が bundle 出力を atomic に置換するようになりました (#2842)** — report tarball は gzip/tar payload の完了後に同一ディレクトリの一時ファイルから移動されるため、失敗時も既存 bundle が保持されます。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2850.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2850
affected:
- src/CodeIndex/Cli/ExportImportCommandRunner.cs
- tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs
---

## English

- **`cdidx import` now preserves live SQLite sidecars until replacement succeeds (#2850)** — import moves the staged database into place before deleting destination WAL/SHM files, so a failed move no longer removes sidecars from the existing database.

## 日本語

- **`cdidx import` が置換成功まで live SQLite sidecar を保持するようになりました (#2850)** — import は staged database の移動が成功してから destination の WAL/SHM を削除するため、move 失敗時に既存 database の sidecar が消えなくなりました。
16 changes: 16 additions & 0 deletions changelog.d/unreleased/2886.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
category: fixed
issues:
- 2886
affected:
- src/CodeIndex/Cli/ExportImportCommandRunner.cs
- tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs
---

## English

- **`cdidx export ctags` now replaces tagfiles atomically (#2886)** — ctags output is staged in a same-directory temporary file and moved into place only after every tag row is written, preserving the previous tagfile if export fails.

## 日本語

- **`cdidx export ctags` が tagfile を atomic に置換するようになりました (#2886)** — ctags 出力は全 tag row の書き込み完了後に同一ディレクトリの一時ファイルから移動されるため、export 失敗時も既存 tagfile が保持されます。
97 changes: 63 additions & 34 deletions src/CodeIndex/Cli/ExportImportCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions)
SqliteConnection.ClearAllPools();
}

DeleteSqliteSidecars(fullDbPath);
File.Move(tempPath, fullDbPath, overwrite: true);
DeleteSqliteSidecars(fullDbPath);
ReplaceImportedDatabase(tempPath, fullDbPath);
if (wantsJson)
{
Console.WriteLine(JsonSerializer.Serialize(new ImportResult("1", fullDbPath, prunePaths), jsonOptions));
Expand All @@ -126,6 +124,7 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions)
finally
{
try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { }
try { DeleteSqliteSidecars(tempPath); } catch { }
}
}

Expand Down Expand Up @@ -193,18 +192,7 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt
}
SqliteConnection.ClearAllPools();
manifest = manifest with { DatabaseSha256 = ComputeSha256(snapshotPath) };
if (File.Exists(outputPath))
File.Delete(outputPath);

using (var archive = ZipFile.Open(outputPath, ZipArchiveMode.Create))
{
AddTextEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, jsonOptions));
var dbEntry = archive.CreateEntry(DatabaseEntryName, CompressionLevel.SmallestSize);
dbEntry.LastWriteTime = DeterministicZipTimestamp;
using var source = File.OpenRead(snapshotPath);
using var target = dbEntry.Open();
source.CopyTo(target);
}
WriteExportArchiveFile(outputPath, snapshotPath, manifest, jsonOptions);

if (wantsJson)
Console.WriteLine(JsonSerializer.Serialize(new ExportArchiveResult("1", Path.GetFullPath(outputPath), fullSourceDbPath), jsonOptions));
Expand Down Expand Up @@ -263,26 +251,28 @@ private static int RunExportCtags(string[] args)
if (!string.IsNullOrWhiteSpace(outputDirectory))
Directory.CreateDirectory(outputDirectory);

using var writer = new StreamWriter(outputPath, append: false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
writer.WriteLine("!_TAG_FILE_FORMAT\t2\t/extended format/");
writer.WriteLine("!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/");

using var cmd = db.Connection.CreateCommand();
cmd.CommandText = @"
SELECT s.name, f.path, COALESCE(s.start_line, s.line, 1), s.kind
FROM symbols s
JOIN files f ON s.file_id = f.id
WHERE s.name IS NOT NULL AND s.name != ''
ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)";
using var reader = cmd.ExecuteReader();
while (reader.Read())
WriteCtagsFile(outputPath, writer =>
{
var name = SanitizeCtagsField(reader.GetString(0));
var path = SanitizeCtagsField(reader.GetString(1));
var line = Math.Max(1, reader.GetInt32(2));
var kind = SanitizeCtagsField(reader.GetString(3));
writer.WriteLine($"{name}\t{path}\t{line};\"\tkind:{kind}\tline:{line}");
}
writer.WriteLine("!_TAG_FILE_FORMAT\t2\t/extended format/");
writer.WriteLine("!_TAG_FILE_SORTED\t1\t/0=unsorted, 1=sorted, 2=foldcase/");

using var cmd = db.Connection.CreateCommand();
cmd.CommandText = @"
SELECT s.name, f.path, COALESCE(s.start_line, s.line, 1), s.kind
FROM symbols s
JOIN files f ON s.file_id = f.id
WHERE s.name IS NOT NULL AND s.name != ''
ORDER BY s.name COLLATE NOCASE, f.path, COALESCE(s.start_line, s.line, 1)";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var name = SanitizeCtagsField(reader.GetString(0));
var path = SanitizeCtagsField(reader.GetString(1));
var line = Math.Max(1, reader.GetInt32(2));
var kind = SanitizeCtagsField(reader.GetString(3));
writer.WriteLine($"{name}\t{path}\t{line};\"\tkind:{kind}\tline:{line}");
}
});

Console.WriteLine($"Exported ctags to {outputPath}");
return CommandExitCodes.Success;
Expand Down Expand Up @@ -313,6 +303,39 @@ private static void AddTextEntry(ZipArchive archive, string name, string content
writer.Write(content);
}

internal static void WriteExportArchiveFile(string outputPath, string snapshotPath, ExportManifest manifest, JsonSerializerOptions jsonOptions)
{
AtomicFileWriter.Write(
outputPath,
stream =>
{
using var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true);
AddTextEntry(archive, ManifestEntryName, JsonSerializer.Serialize(manifest, jsonOptions));
var dbEntry = archive.CreateEntry(DatabaseEntryName, CompressionLevel.SmallestSize);
dbEntry.LastWriteTime = DeterministicZipTimestamp;
using var source = File.OpenRead(snapshotPath);
using var target = dbEntry.Open();
source.CopyTo(target);
});
}

internal static void WriteCtagsFile(string outputPath, Action<TextWriter> writeContents)
{
ArgumentNullException.ThrowIfNull(writeContents);

AtomicFileWriter.Write(
outputPath,
stream =>
{
using var writer = new StreamWriter(
stream,
new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
bufferSize: 1024,
leaveOpen: true);
writeContents(writer);
});
}

private static string ComputeSha256(string path)
{
using var stream = File.OpenRead(path);
Expand Down Expand Up @@ -477,6 +500,12 @@ private static void CreateDatabaseSnapshot(string sourceDbPath, string snapshotP
private static string CreateUnpooledConnectionString(string dbPath)
=> new SqliteConnectionStringBuilder { DataSource = dbPath, Pooling = false }.ConnectionString;

internal static void ReplaceImportedDatabase(string tempPath, string fullDbPath)
{
File.Move(tempPath, fullDbPath, overwrite: true);
DeleteSqliteSidecars(fullDbPath);
}

private static void DeleteSqliteSidecars(string dbPath)
{
TryDeleteFile(dbPath + "-wal");
Expand Down
53 changes: 25 additions & 28 deletions src/CodeIndex/Cli/ReportCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -309,41 +309,38 @@ private static string RedactKeyValue(string line, string key)
return line[..(idx + key.Length)] + RedactedPlaceholder;
}

private static void WriteBundle(string outputPath, ReportBundle bundle)
internal static void WriteBundle(string outputPath, ReportBundle bundle, Action? beforeWriteEntries = null)
{
var dir = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);

if (!OperatingSystem.IsWindows() && File.Exists(outputPath))
File.SetUnixFileMode(outputPath, BundleFileMode);

var streamOptions = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Share = FileShare.None,
};
if (!OperatingSystem.IsWindows())
streamOptions.UnixCreateMode = BundleFileMode;
AtomicFileWriter.Write(
outputPath,
stream =>
{
using var gz = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true);
using var tar = new TarWriter(gz, TarEntryFormat.Pax, leaveOpen: true);
beforeWriteEntries?.Invoke();

foreach (var (name, bytes) in bundle.Files)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
{
DataStream = new MemoryStream(bytes, writable: false),
Mode = BundleFileMode,
ModificationTime = DateTimeOffset.UtcNow,
};
tar.WriteEntry(entry);
}
},
ApplyBundleFileMode);
}

using var fileStream = new FileStream(outputPath, streamOptions);
private static void ApplyBundleFileMode(string path)
{
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(outputPath, BundleFileMode);

using var gz = new GZipStream(fileStream, CompressionLevel.Optimal);
using var tar = new TarWriter(gz, TarEntryFormat.Pax, leaveOpen: true);

foreach (var (name, bytes) in bundle.Files)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, name)
{
DataStream = new MemoryStream(bytes, writable: false),
Mode = BundleFileMode,
ModificationTime = DateTimeOffset.UtcNow,
};
tar.WriteEntry(entry);
}
File.SetUnixFileMode(path, BundleFileMode);
}

internal static ReportCommandOptions ParseArgs(string[] args)
Expand Down
Loading
Loading