From 65c04301e1128bf3d78495d0a671c065bd17c959 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 00:21:55 +0900 Subject: [PATCH 1/4] Fix atomic cdidx export archives (#2839) --- changelog.d/unreleased/2839.fixed.md | 16 +++++++++ .../Cli/ExportImportCommandRunner.cs | 29 ++++++++------- .../ExportImportCommandRunnerTests.cs | 35 +++++++++++++++++++ 3 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/2839.fixed.md diff --git a/changelog.d/unreleased/2839.fixed.md b/changelog.d/unreleased/2839.fixed.md new file mode 100644 index 0000000000..ca3000d9b7 --- /dev/null +++ b/changelog.d/unreleased/2839.fixed.md @@ -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 を削除しなくなりました。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index b7929b6dd4..2231e03253 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -193,18 +193,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)); @@ -313,6 +302,22 @@ 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); + }); + } + private static string ComputeSha256(string path) { using var stream = File.OpenRead(path); diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index e99de47670..e14f9a3c63 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -1,9 +1,44 @@ +using System.Text.Json; using CodeIndex.Cli; namespace CodeIndex.Tests; public class ExportImportCommandRunnerTests { + [Fact] + public void WriteExportArchiveFile_FailurePreservesExistingArchive() + { + var workDir = Path.Combine(Path.GetTempPath(), $"cdidx_export_{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + try + { + var outputPath = Path.Combine(workDir, "codeindex.cdidx.zip"); + File.WriteAllText(outputPath, "existing archive"); + var missingSnapshotPath = Path.Combine(workDir, "missing.db"); + var manifest = new ExportImportCommandRunner.ExportManifest( + "1", + "test", + 0, + null, + null, + new string('0', 64)); + + Assert.Throws(() => + ExportImportCommandRunner.WriteExportArchiveFile( + outputPath, + missingSnapshotPath, + manifest, + new JsonSerializerOptions())); + + Assert.Equal("existing archive", File.ReadAllText(outputPath)); + Assert.Single(Directory.GetFiles(workDir)); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + [Fact] public void TryValidateDatabaseEntrySize_RejectsOversizedUncompressedLength() { From c78394960010a269b454497dfb187eb56c76a81f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 00:25:41 +0900 Subject: [PATCH 2/4] Fix atomic ctags export output (#2886) --- changelog.d/unreleased/2886.fixed.md | 16 ++++++ .../Cli/ExportImportCommandRunner.cs | 57 ++++++++++++------- .../ExportImportCommandRunnerTests.cs | 28 +++++++++ 3 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 changelog.d/unreleased/2886.fixed.md diff --git a/changelog.d/unreleased/2886.fixed.md b/changelog.d/unreleased/2886.fixed.md new file mode 100644 index 0000000000..70adf48c2c --- /dev/null +++ b/changelog.d/unreleased/2886.fixed.md @@ -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 が保持されます。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 2231e03253..93ad28e2a8 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -252,26 +252,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; @@ -318,6 +320,23 @@ internal static void WriteExportArchiveFile(string outputPath, string snapshotPa }); } + internal static void WriteCtagsFile(string outputPath, Action 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); diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index e14f9a3c63..bd9982f385 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -39,6 +39,34 @@ public void WriteExportArchiveFile_FailurePreservesExistingArchive() } } + [Fact] + public void WriteCtagsFile_FailurePreservesExistingTagfile() + { + var workDir = Path.Combine(Path.GetTempPath(), $"cdidx_ctags_{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + try + { + var outputPath = Path.Combine(workDir, "tags"); + File.WriteAllText(outputPath, "existing tags"); + + Assert.Throws(() => + ExportImportCommandRunner.WriteCtagsFile( + outputPath, + writer => + { + writer.WriteLine("partial"); + throw new IOException("simulated ctags failure"); + })); + + Assert.Equal("existing tags", File.ReadAllText(outputPath)); + Assert.Single(Directory.GetFiles(workDir)); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + [Fact] public void TryValidateDatabaseEntrySize_RejectsOversizedUncompressedLength() { From deb90806a7a0de79a3a0b9e94ddcc6205229c165 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 00:33:46 +0900 Subject: [PATCH 3/4] Fix atomic report bundle output (#2842) --- changelog.d/unreleased/2842.fixed.md | 16 ++++++ src/CodeIndex/Cli/ReportCommandRunner.cs | 53 +++++++++---------- .../ReportCommandRunnerTests.cs | 26 +++++++++ 3 files changed, 67 insertions(+), 28 deletions(-) create mode 100644 changelog.d/unreleased/2842.fixed.md diff --git a/changelog.d/unreleased/2842.fixed.md b/changelog.d/unreleased/2842.fixed.md new file mode 100644 index 0000000000..ea9ccd08ec --- /dev/null +++ b/changelog.d/unreleased/2842.fixed.md @@ -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 が保持されます。 diff --git a/src/CodeIndex/Cli/ReportCommandRunner.cs b/src/CodeIndex/Cli/ReportCommandRunner.cs index ee1a7dfb70..ed6e728e1e 100644 --- a/src/CodeIndex/Cli/ReportCommandRunner.cs +++ b/src/CodeIndex/Cli/ReportCommandRunner.cs @@ -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) diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index 6580ba0eaa..bc4f1d8a75 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -195,6 +195,32 @@ public void Run_OutputArchiveAndEntriesUseOwnerOnlyPermissions() } } + [Fact] + public void WriteBundle_FailurePreservesExistingBundle() + { + var workDir = CreateWorkDir(); + try + { + var output = Path.Combine(workDir, "bundle.tgz"); + File.WriteAllText(output, "existing bundle"); + var bundle = new ReportBundle(); + bundle.AddText("metadata.txt", "partial"); + + Assert.Throws(() => + ReportCommandRunner.WriteBundle( + output, + bundle, + beforeWriteEntries: () => throw new IOException("simulated report failure"))); + + Assert.Equal("existing bundle", File.ReadAllText(output)); + Assert.Single(Directory.GetFiles(workDir)); + } + finally + { + TryDeleteDirectory(workDir); + } + } + [Fact] public void Run_WithRealDb_SchemaTxtListsTablesAndRowCounts() { From b8a24845402930292a116bf9b6e9f2a4c5b60e9b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 00:36:51 +0900 Subject: [PATCH 4/4] Fix import sidecar replacement ordering (#2850) --- changelog.d/unreleased/2850.fixed.md | 16 ++++++ .../Cli/ExportImportCommandRunner.cs | 11 ++-- .../ExportImportCommandRunnerTests.cs | 53 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/2850.fixed.md diff --git a/changelog.d/unreleased/2850.fixed.md b/changelog.d/unreleased/2850.fixed.md new file mode 100644 index 0000000000..9c02d03fcf --- /dev/null +++ b/changelog.d/unreleased/2850.fixed.md @@ -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 が消えなくなりました。 diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 93ad28e2a8..70660d9532 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -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)); @@ -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 { } } } @@ -501,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"); diff --git a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs index bd9982f385..0d90d49fe1 100644 --- a/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs @@ -67,6 +67,59 @@ public void WriteCtagsFile_FailurePreservesExistingTagfile() } } + [Fact] + public void ReplaceImportedDatabase_MoveFailurePreservesExistingSidecars() + { + var workDir = Path.Combine(Path.GetTempPath(), $"cdidx_import_{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + try + { + var dbPath = Path.Combine(workDir, "codeindex.db"); + File.WriteAllText(dbPath, "existing db"); + File.WriteAllText(dbPath + "-wal", "existing wal"); + File.WriteAllText(dbPath + "-shm", "existing shm"); + var missingTempPath = Path.Combine(workDir, "missing.db"); + + Assert.ThrowsAny(() => + ExportImportCommandRunner.ReplaceImportedDatabase(missingTempPath, dbPath)); + + Assert.Equal("existing db", File.ReadAllText(dbPath)); + Assert.Equal("existing wal", File.ReadAllText(dbPath + "-wal")); + Assert.Equal("existing shm", File.ReadAllText(dbPath + "-shm")); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + + [Fact] + public void ReplaceImportedDatabase_SuccessDeletesDestinationSidecarsAfterMove() + { + var workDir = Path.Combine(Path.GetTempPath(), $"cdidx_import_{Guid.NewGuid():N}"); + Directory.CreateDirectory(workDir); + try + { + var dbPath = Path.Combine(workDir, "codeindex.db"); + var tempPath = Path.Combine(workDir, "staged.db"); + File.WriteAllText(dbPath, "existing db"); + File.WriteAllText(dbPath + "-wal", "existing wal"); + File.WriteAllText(dbPath + "-shm", "existing shm"); + File.WriteAllText(tempPath, "imported db"); + + ExportImportCommandRunner.ReplaceImportedDatabase(tempPath, dbPath); + + Assert.Equal("imported db", File.ReadAllText(dbPath)); + Assert.False(File.Exists(dbPath + "-wal")); + Assert.False(File.Exists(dbPath + "-shm")); + Assert.False(File.Exists(tempPath)); + } + finally + { + Directory.Delete(workDir, recursive: true); + } + } + [Fact] public void TryValidateDatabaseEntrySize_RejectsOversizedUncompressedLength() {