From c9ae7af07eb628c9c7839796410ad84a8e9b3135 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 19:11:09 +0900 Subject: [PATCH 1/6] Add export and import commands (#1649, #1726) --- DEVELOPER_GUIDE.md | 9 + USER_GUIDE.md | 52 +++ changelog.d/unreleased/1649.added.md | 18 + changelog.d/unreleased/1726.added.md | 19 + src/CodeIndex/Cli/ConsoleUi.cs | 10 + .../Cli/ExportImportCommandRunner.cs | 376 ++++++++++++++++++ src/CodeIndex/Cli/JsonOutputContracts.cs | 3 + src/CodeIndex/Cli/ProgramRunner.cs | 2 + tests/CodeIndex.Tests/ProgramCliTests.cs | 107 +++++ 9 files changed, 596 insertions(+) create mode 100644 changelog.d/unreleased/1649.added.md create mode 100644 changelog.d/unreleased/1726.added.md create mode 100644 src/CodeIndex/Cli/ExportImportCommandRunner.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3c3d3a7117..dd41d97a50 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -54,6 +54,15 @@ cdidx search AuthService --db /artifacts/codeindex.db --immutable Mutating commands such as `index`, `backfill-fold`, `optimize`, and `vacuum` require writable storage and reject read-only database opens. +For CI jobs that want to publish a reusable index artifact, run +`cdidx export codeindex.cdidx.zip` after indexing and upload that archive. A +consumer can run `cdidx import codeindex.cdidx.zip --db ` before query +commands. Use `--prune-paths` on import when the archive comes from another +checkout and the restored DB should advertise the current workspace root. The +archive contains `manifest.json` plus `codeindex.db`; import validates the +embedded SQLite file as a CodeIndex database before replacing the destination +DB. + ## Filesystem Permissions On POSIX filesystems, cdidx creates `.cdidx/` with mode `0700` and applies mode diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0adc6b1ab0..0fd751e5a9 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -261,6 +261,8 @@ sections below show examples and option details for the most common workflows. | Diagnostics | `db --integrity-check` | Run SQLite `PRAGMA integrity_check` against the DB | -- | | Diagnostics | `report --output ` | Build a redacted bug-report bundle | -- | | Feedback | `suggestions` | List, inspect, and export local suggestion history | -- | +| Portability | `export ctags` | Write a native `tags` file for Vim, Emacs, Sublime, and other ctags consumers | -- | +| Portability | `export` / `import` | Share a built CodeIndex database as a portable archive | -- | | MCP | `mcp` | Start the MCP server for AI tools | server transport | | Legal | `license` | Show the license and commercial-use summary | -- | @@ -282,6 +284,31 @@ cdidx search authenticate --json # ndjson stream, one result per line cdidx search authenticate --json=array # single JSON array ``` +## Editor and index portability + +Use `cdidx export ctags` when an editor wants the traditional ctags file format +instead of querying `cdidx` directly: + +```bash +cdidx export ctags --output tags +cdidx export ctags --db .cdidx/codeindex.db --output .tags +``` + +Use `cdidx export ` to package the current `codeindex.db` with a +manifest, and `cdidx import ` to restore it on another checkout or CI +job: + +```bash +cdidx export codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip --db /tmp/codeindex.db --prune-paths +``` + +The archive path is intended for trusted CodeIndex databases. Import validates +that the embedded SQLite file is a CodeIndex DB before replacing the destination +database. `--prune-paths` rewrites the imported `indexed_project_root` metadata +to the current checkout. + ## Flag compatibility and migrations `--exact` remains accepted for compatibility, but new usage should prefer the @@ -2267,6 +2294,8 @@ cdidx index . --quiet | Diagnostics | `db --integrity-check` | DB に対して SQLite `PRAGMA integrity_check` を実行 | -- | | Diagnostics | `report --output ` | redact 済み bug-report bundle を作成 | -- | | Feedback | `suggestions` | local suggestion history を list / inspect / export | -- | +| Portability | `export ctags` | Vim、Emacs、Sublime など ctags consumer 向けに `tags` file を出力 | -- | +| Portability | `export` / `import` | build 済み CodeIndex database を portable archive として共有 | -- | | MCP | `mcp` | AI tools 向け MCP server を起動 | server transport | | Legal | `license` | license と commercial-use summary を表示 | -- | @@ -2288,6 +2317,29 @@ cdidx search authenticate --json # ndjson stream、1 行 1 result cdidx search authenticate --json=array # 単一 JSON array ``` +## Editor / index portability + +Editor が `cdidx` を直接 query するのではなく従来の ctags file を読む場合は、 +`cdidx export ctags` を使います。 + +```bash +cdidx export ctags --output tags +cdidx export ctags --db .cdidx/codeindex.db --output .tags +``` + +`cdidx export ` は現在の `codeindex.db` と manifest を archive 化します。 +別 checkout や CI job では `cdidx import ` で復元できます。 + +```bash +cdidx export codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip +cdidx import codeindex.cdidx.zip --db /tmp/codeindex.db --prune-paths +``` + +archive は信頼できる CodeIndex database の共有向けです。Import は埋め込まれた +SQLite file が CodeIndex DB であることを検証してから destination database を置き換えます。 +`--prune-paths` は import した `indexed_project_root` metadata を現在の checkout に書き換えます。 + ## フラグ互換性と移行 `--exact` は互換性のため引き続き受け付けますが、新しい使い方ではコマンド系統に diff --git a/changelog.d/unreleased/1649.added.md b/changelog.d/unreleased/1649.added.md new file mode 100644 index 0000000000..a457344487 --- /dev/null +++ b/changelog.d/unreleased/1649.added.md @@ -0,0 +1,18 @@ +--- +category: added +issues: + - 1649 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - USER_GUIDE.md +--- + +## English + +- **Added ctags export (#1649)** — `cdidx export ctags` now writes editor-native `tags` files from indexed symbols, with `--output` and `--db` options. + +## 日本語 + +- **ctags export を追加しました (#1649)** — `cdidx export ctags` が indexed symbols から editor native の `tags` file を出力し、`--output` と `--db` options に対応しました。 diff --git a/changelog.d/unreleased/1726.added.md b/changelog.d/unreleased/1726.added.md new file mode 100644 index 0000000000..509272628d --- /dev/null +++ b/changelog.d/unreleased/1726.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 1726 +affected: + - src/CodeIndex/Cli/ExportImportCommandRunner.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Added portable index archive export/import (#1726)** — `cdidx export ` and `cdidx import ` now package and restore CodeIndex databases with a manifest for CI or cross-machine reuse, including `--prune-paths` on import. + +## 日本語 + +- **portable index archive の export/import を追加しました (#1726)** — `cdidx export ` / `cdidx import ` で manifest 付きの CodeIndex database を CI や別 machine で再利用でき、import 時の `--prune-paths` に対応しました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 29b33d64c9..ed151fc0ef 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -94,6 +94,9 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ]"), + ("export", "cdidx export [--db ] [--json]"), + ("export", "cdidx export ctags [--output ] [--db ]"), + ("import", "cdidx import [--db ] [--prune-paths] [--json]"), ("languages", "cdidx languages [--json]"), ("batch", "cdidx batch [--db ] # reads JSON string arrays from stdin, one query command per line"), ("mcp", "cdidx mcp [--db ]"), @@ -653,6 +656,8 @@ public static void PrintUsageBrief(bool showBanner = true) Console.WriteLine(" deps Show file-level dependency edges from the reference graph"); Console.WriteLine(" unused Find symbols defined but never referenced (dead code)"); Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); + Console.WriteLine(" export Export ctags or a portable CodeIndex archive"); + Console.WriteLine(" import Import a portable CodeIndex archive"); Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); Console.WriteLine(" completions Generate shell completions for bash, zsh, fish, or PowerShell"); @@ -759,6 +764,8 @@ private static void PrintCommandSummary() Console.WriteLine(" unused Find symbols defined but never referenced (dead code)"); Console.WriteLine(" hotspots Find high-impact symbols; duplicate-name families may fall back conservatively"); Console.WriteLine(" suggestions List, inspect, and export local suggestion history"); + Console.WriteLine(" export Export ctags or a portable CodeIndex archive"); + Console.WriteLine(" import Import a portable CodeIndex archive"); Console.WriteLine(" languages List supported languages and their capabilities"); Console.WriteLine(" batch Run newline-delimited JSON query commands with one DB connection"); Console.WriteLine(" mcp Start MCP server (for AI tools: Claude, Cursor, etc.)"); @@ -871,6 +878,9 @@ private static void PrintExamples() Console.WriteLine(" Update DB from files changed between two refs"); Console.WriteLine(" cdidx index ./myproject --files src/app.cs Update specific files"); Console.WriteLine(" cdidx index ./myproject --watch Run an initial scan, then keep the index live as files change (Ctrl+C to stop)"); + Console.WriteLine(" cdidx export ctags --output tags Export editor tags for Vim, Emacs, and Sublime"); + Console.WriteLine(" cdidx export codeindex.cdidx.zip Export a portable CodeIndex archive"); + Console.WriteLine(" cdidx import codeindex.cdidx.zip Import a portable CodeIndex archive"); Console.WriteLine(" cdidx search \"authenticate\" Full-text search"); Console.WriteLine(" cdidx search \"auth*\" Prefix shorthand in literal-safe mode"); Console.WriteLine(" cdidx search --query --path --path README.md Search for a literal option token"); diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs new file mode 100644 index 0000000000..08a281fea4 --- /dev/null +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -0,0 +1,376 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using CodeIndex.Database; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Cli; + +internal static class ExportImportCommandRunner +{ + private const string ManifestEntryName = "manifest.json"; + private const string DatabaseEntryName = "codeindex.db"; + private static readonly DateTimeOffset DeterministicZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public static int RunExport(string[] args, JsonSerializerOptions jsonOptions, string appVersion) + { + if (args.Length > 0 && args[0] == "ctags") + return RunExportCtags(args[1..]); + + return RunExportArchive(args, jsonOptions, appVersion); + } + + public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) + { + string? archivePath = null; + string? dbPath = null; + var wantsJson = false; + var prunePaths = false; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + if (arg == "--prune-paths") + { + prunePaths = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteError(dbError, "use `cdidx import --db `.", "cdidx import [--db ] [--json]"); + dbPath = dbValue; + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return WriteError($"unknown import option `{arg}`.", "use `cdidx import [--db ]`.", "cdidx import [--db ] [--prune-paths] [--json]"); + + if (archivePath != null) + return WriteError($"import accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", "cdidx import [--db ] [--json]"); + archivePath = arg; + } + + if (string.IsNullOrWhiteSpace(archivePath)) + return WriteError("import requires an archive path.", "pass an archive produced by `cdidx export `.", "cdidx import [--db ] [--prune-paths] [--json]"); + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var fullDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(dbPath)); + var dbDirectory = Path.GetDirectoryName(fullDbPath); + if (string.IsNullOrWhiteSpace(dbDirectory)) + return WriteError($"could not resolve destination DB directory for `{dbPath}`.", "pass an explicit `--db `.", "cdidx import [--db ] [--json]"); + + var tempPath = Path.Combine(dbDirectory, $".codeindex-import-{Guid.NewGuid():N}.db"); + try + { + Directory.CreateDirectory(dbDirectory); + using (var archive = ZipFile.OpenRead(archivePath)) + { + if (archive.GetEntry(ManifestEntryName) == null) + return WriteError("archive is missing manifest.json.", "use an archive produced by `cdidx export `.", "cdidx import [--db ] [--json]"); + + var dbEntry = archive.GetEntry(DatabaseEntryName); + if (dbEntry == null) + return WriteError("archive is missing codeindex.db.", "use an archive produced by `cdidx export `.", "cdidx import [--db ] [--json]"); + + dbEntry.ExtractToFile(tempPath, overwrite: true); + } + + if (!DbContext.TryValidateExistingCodeIndexDb(tempPath, out var validationMessage, out _)) + return WriteError($"archive database is invalid: {validationMessage}.", "re-export from a compatible CodeIndex database.", "cdidx import [--db ] [--prune-paths] [--json]"); + + if (prunePaths) + RewriteImportedProjectRoot(tempPath, Environment.CurrentDirectory); + + DeleteSqliteSidecars(fullDbPath); + File.Move(tempPath, fullDbPath, overwrite: true); + DeleteSqliteSidecars(fullDbPath); + if (wantsJson) + { + Console.WriteLine(JsonSerializer.Serialize(new ImportResult("1", fullDbPath, prunePaths), jsonOptions)); + } + else + { + Console.WriteLine($"Imported CodeIndex database to {fullDbPath}"); + } + return CommandExitCodes.Success; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or SqliteException) + { + return WriteError($"import failed: {ex.Message}", "check the archive path and destination database permissions.", "cdidx import [--db ] [--prune-paths] [--json]"); + } + finally + { + try { if (File.Exists(tempPath)) File.Delete(tempPath); } catch { } + } + } + + private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOptions, string appVersion) + { + string? outputPath = null; + string? dbPath = null; + var wantsJson = false; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (arg == "--json") + { + wantsJson = true; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteError(dbError, "use `cdidx export --db `.", "cdidx export [--db ] [--json]"); + dbPath = dbValue; + continue; + } + + if (arg.StartsWith("-", StringComparison.Ordinal)) + return WriteError($"unknown export option `{arg}`.", "use `cdidx export [--db ]` or `cdidx export ctags`.", "cdidx export [--db ] [--json]"); + + if (outputPath != null) + return WriteError($"export accepts exactly one archive path, got extra `{arg}`.", "remove the extra argument.", "cdidx export [--db ] [--json]"); + outputPath = arg; + } + + if (string.IsNullOrWhiteSpace(outputPath)) + return WriteError("export requires an output archive path.", "pass a destination such as `codeindex.cdidx.zip`, or use `cdidx export ctags`.", "cdidx export [--db ] [--json]"); + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); + if (!DbContext.TryValidateExistingCodeIndexDb(normalizedDbPath, out var validationMessage, out _)) + return WriteError(validationMessage, "run `cdidx index ` first or pass `--db `.", "cdidx export [--db ] [--json]"); + + var fullSourceDbPath = Path.GetFullPath(normalizedDbPath); + var fullOutputPath = Path.GetFullPath(outputPath); + if (IsSamePath(fullOutputPath, fullSourceDbPath) + || IsSamePath(fullOutputPath, fullSourceDbPath + "-wal") + || IsSamePath(fullOutputPath, fullSourceDbPath + "-shm")) + { + return WriteError("export archive path must not be the source database or a SQLite sidecar.", "choose a separate archive path, for example `codeindex.cdidx.zip`.", "cdidx export [--db ] [--json]"); + } + + var snapshotPath = Path.Combine(Path.GetTempPath(), $"codeindex-export-{Guid.NewGuid():N}.db"); + try + { + var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(outputPath)); + if (!string.IsNullOrWhiteSpace(outputDirectory)) + Directory.CreateDirectory(outputDirectory); + + CreateDatabaseSnapshot(normalizedDbPath, snapshotPath); + using var snapshotConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString); + snapshotConnection.Open(); + var manifest = BuildManifest(snapshotConnection, snapshotPath, appVersion); + 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); + } + + if (wantsJson) + Console.WriteLine(JsonSerializer.Serialize(new ExportArchiveResult("1", Path.GetFullPath(outputPath), fullSourceDbPath), jsonOptions)); + else + Console.WriteLine($"Exported CodeIndex archive to {outputPath}"); + return CommandExitCodes.Success; + } + catch (Exception ex) + { + return WriteError($"export failed: {ex.Message}", "check the database and output archive paths.", "cdidx export [--db ] [--json]"); + } + finally + { + try { if (File.Exists(snapshotPath)) File.Delete(snapshotPath); } catch { } + try { DeleteSqliteSidecars(snapshotPath); } catch { } + } + } + + private static int RunExportCtags(string[] args) + { + var outputPath = "tags"; + string? dbPath = null; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (TryReadValueOption(args, ref i, "--output", arg, out var outputValue, out var outputError)) + { + if (outputError != null) + return WriteError(outputError, "use `cdidx export ctags --output tags`.", "cdidx export ctags [--output ] [--db ]"); + outputPath = outputValue!; + continue; + } + + if (TryReadValueOption(args, ref i, "--db", arg, out var dbValue, out var dbError)) + { + if (dbError != null) + return WriteError(dbError, "use `cdidx export ctags --db `.", "cdidx export ctags [--output ] [--db ]"); + dbPath = dbValue; + continue; + } + + return WriteError($"unknown ctags export option `{arg}`.", "use `--output ` or `--db `.", "cdidx export ctags [--output ] [--db ]"); + } + + dbPath ??= DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, explicitDbPath: null, explicitDataDir: null).DbPath; + var normalizedDbPath = DbPathResolver.NormalizeDbPath(dbPath); + if (!DbContext.TryValidateExistingCodeIndexDb(normalizedDbPath, out var validationMessage, out _)) + return WriteError(validationMessage, "run `cdidx index ` first or pass `--db `.", "cdidx export ctags [--output ] [--db ]"); + + try + { + using var db = new DbContext(normalizedDbPath); + db.TryMigrateForRead(); + var outputDirectory = Path.GetDirectoryName(Path.GetFullPath(outputPath)); + 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()) + { + 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; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SqliteException) + { + return WriteError($"ctags export failed: {ex.Message}", "check the database and output paths.", "cdidx export ctags [--output ] [--db ]"); + } + } + + private static ExportManifest BuildManifest(SqliteConnection connection, string dbPath, string appVersion) + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA user_version"; + var userVersion = Convert.ToInt32(cmd.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = 'indexed_project_root' LIMIT 1"; + var projectRoot = cmd.ExecuteScalar() as string; + cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = 'indexed_head_sha' LIMIT 1"; + var indexedHead = cmd.ExecuteScalar() as string; + return new ExportManifest("1", appVersion, userVersion, projectRoot, indexedHead, ComputeSha256(dbPath)); + } + + private static void AddTextEntry(ZipArchive archive, string name, string content) + { + var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize); + entry.LastWriteTime = DeterministicZipTimestamp; + using var writer = new StreamWriter(entry.Open(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + writer.Write(content); + } + + private static string ComputeSha256(string path) + { + using var stream = File.OpenRead(path); + return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant(); + } + + private static void RewriteImportedProjectRoot(string dbPath, string projectRoot) + { + using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + INSERT INTO codeindex_meta(key, value) + VALUES ('indexed_project_root', @projectRoot) + ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + cmd.Parameters.AddWithValue("@projectRoot", Path.GetFullPath(projectRoot)); + cmd.ExecuteNonQuery(); + } + + private static void CreateDatabaseSnapshot(string sourceDbPath, string snapshotPath) + { + using var source = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = sourceDbPath }.ConnectionString); + using var destination = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString); + source.Open(); + destination.Open(); + source.BackupDatabase(destination); + } + + private static void DeleteSqliteSidecars(string dbPath) + { + TryDeleteFile(dbPath + "-wal"); + TryDeleteFile(dbPath + "-shm"); + } + + private static void TryDeleteFile(string path) + { + if (File.Exists(path)) + File.Delete(path); + } + + private static bool IsSamePath(string left, string right) + => string.Equals( + Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + private static string SanitizeCtagsField(string value) + => value.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' '); + + private static bool TryReadValueOption(string[] args, ref int index, string optionName, string arg, out string? value, out string? error) + { + value = null; + error = null; + if (arg == optionName) + { + if (index + 1 >= args.Length || string.IsNullOrWhiteSpace(args[index + 1])) + { + error = $"{optionName} requires a non-empty value."; + return true; + } + value = args[++index]; + return true; + } + + var prefix = optionName + "="; + if (arg.StartsWith(prefix, StringComparison.Ordinal)) + { + value = arg[prefix.Length..]; + if (string.IsNullOrWhiteSpace(value)) + error = $"{optionName} requires a non-empty value."; + return true; + } + + return false; + } + + private static int WriteError(string message, string hint, string usage) + => CommandErrorWriter.Write(message, CommandExitCodes.UsageError, hint, usage); + + internal sealed record ExportManifest(string FormatVersion, string CdidxVersion, int UserVersion, string? ProjectRoot, string? IndexedHeadSha, string DatabaseSha256); + internal sealed record ExportArchiveResult(string ApiVersion, string ArchivePath, string DbPath); + internal sealed record ImportResult(string ApiVersion, string DbPath, bool PrunedPaths); +} diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 635aae96d5..1fdec202db 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -310,6 +310,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(DiffSummaryOnlyJsonResult))] [JsonSerializable(typeof(DiffSummaryJsonResult))] [JsonSerializable(typeof(ExactZeroHintResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.ExportArchiveResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.ExportManifest))] [JsonSerializable(typeof(ExcerptSemanticToken))] [JsonSerializable(typeof(FileDependencyResult))] [JsonSerializable(typeof(FileExcerptResult))] @@ -331,6 +333,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(IndexUpdateJsonResult))] [JsonSerializable(typeof(IndexUpdateSummaryJsonResult))] [JsonSerializable(typeof(IndexWatchEventJsonResult))] +[JsonSerializable(typeof(ExportImportCommandRunner.ImportResult))] [JsonSerializable(typeof(HookCommandJsonResult))] [JsonSerializable(typeof(JsonStreamDoneResult))] [JsonSerializable(typeof(LanguageEntryJsonResult))] diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 450576e4c3..e90a190610 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -247,6 +247,8 @@ internal static int Run( { "upgrade" => RunUpgrade(subArgs, jsonOptions, appVersion), "index" => IndexCommandRunner.Run(subArgs, jsonOptions), + "export" => ExportImportCommandRunner.RunExport(subArgs, jsonOptions, appVersion), + "import" => ExportImportCommandRunner.RunImport(subArgs, jsonOptions), "diff" => DiffCommandRunner.Run(subArgs, jsonOptions), "hooks" => HookCommandRunner.Run(subArgs, jsonOptions), "backfill-fold" => IndexCommandRunner.RunBackfillFold(subArgs, jsonOptions), diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 59467da9db..c870c70db9 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using CodeIndex.Database; using CodeIndex.Models; using Microsoft.Data.Sqlite; using System.Text.Json; @@ -290,6 +291,8 @@ public void Completions_OptionLikeShellTokenReturnsUsageError() [InlineData("deps", "cdidx deps")] [InlineData("map", "cdidx map")] [InlineData("status", "cdidx status")] + [InlineData("export", "cdidx export ")] + [InlineData("import", "cdidx import ")] [InlineData("completions", "cdidx completions ")] [InlineData("license", "cdidx license")] public void SubcommandHelp_PrintsCommandSpecificUsage(string command, string expectedUsage) @@ -306,6 +309,110 @@ public void SubcommandHelp_PrintsCommandSpecificUsage(string command, string exp Assert.DoesNotContain("██████╗", stdout); } + [Fact] + public void ExportCtags_WritesTagsFileFromIndexedSymbols() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_ctags"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + var tagsPath = Path.Combine(projectRoot, "tags"); + + var (exitCode, stdout, stderr) = RunCliInSubprocess(["export", "ctags", "--db", dbPath, "--output", tagsPath]); + + Assert.Equal(0, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Contains("Exported ctags", stdout); + var tags = File.ReadAllText(tagsPath); + Assert.Contains("!_TAG_FILE_FORMAT\t2", tags); + Assert.Contains("App\tsrc/app.cs\t1;\"", tags); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ExportImportArchive_RestoresCodeIndexDatabase() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_archive"); + try + { + var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + var importedDbPath = Path.Combine(projectRoot, "imported", "codeindex.db"); + + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + var (importExit, importStdout, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", importedDbPath]); + + Assert.True(exportExit == 0, exportStderr); + Assert.Equal(string.Empty, exportStderr); + Assert.True(importExit == 0, importStderr); + Assert.Equal(string.Empty, importStderr); + Assert.Contains("Imported CodeIndex database", importStdout); + Assert.True(File.Exists(importedDbPath)); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(importedDbPath, out _, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ExportArchive_RejectsSourceDatabaseAsOutput() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_export_same_db"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + + var (exitCode, _, stderr) = RunCliInSubprocess(["export", dbPath, "--db", dbPath]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("must not be the source database", stderr); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(dbPath, out _, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void ImportArchive_RemovesStaleDestinationSidecars() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_import_sidecars"); + try + { + var sourceDbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(sourceDbPath, "src/app.cs", "csharp", "class App { void Run() {} }\n"); + var archivePath = Path.Combine(projectRoot, "codeindex.cdidx.zip"); + var destinationDbPath = Path.Combine(projectRoot, "destination", "codeindex.db"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationDbPath)!); + File.WriteAllText(destinationDbPath, "old"); + File.WriteAllText(destinationDbPath + "-wal", "old wal"); + File.WriteAllText(destinationDbPath + "-shm", "old shm"); + + var (exportExit, _, exportStderr) = RunCliInSubprocess(["export", archivePath, "--db", sourceDbPath]); + var (importExit, _, importStderr) = RunCliInSubprocess(["import", archivePath, "--db", destinationDbPath]); + + Assert.True(exportExit == 0, exportStderr); + Assert.True(importExit == 0, importStderr); + Assert.False(File.Exists(destinationDbPath + "-wal")); + Assert.False(File.Exists(destinationDbPath + "-shm")); + Assert.True(DbContext.TryValidateExistingCodeIndexDb(destinationDbPath, out _, out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void TopLevelHelp_DefaultIsBriefAndExtendedHelpKeepsFullReference() { From fbe2a4858f29f48699e2c0a952efb7e542d57475 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 20:28:23 +0900 Subject: [PATCH 2/6] Close export snapshot before archive read (#1726) --- src/CodeIndex/Cli/ExportImportCommandRunner.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 08a281fea4..c125e4f882 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -168,9 +168,12 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt Directory.CreateDirectory(outputDirectory); CreateDatabaseSnapshot(normalizedDbPath, snapshotPath); - using var snapshotConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString); - snapshotConnection.Open(); - var manifest = BuildManifest(snapshotConnection, snapshotPath, appVersion); + ExportManifest manifest; + using (var snapshotConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString)) + { + snapshotConnection.Open(); + manifest = BuildManifest(snapshotConnection, snapshotPath, appVersion); + } if (File.Exists(outputPath)) File.Delete(outputPath); From 2e3e57e00689daa863295bad3946a18e57f868c0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 22:47:52 +0900 Subject: [PATCH 3/6] Compute export snapshot hash after closing DB (#1726) --- src/CodeIndex/Cli/ExportImportCommandRunner.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index c125e4f882..4f6fc3645d 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -172,8 +172,9 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt using (var snapshotConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString)) { snapshotConnection.Open(); - manifest = BuildManifest(snapshotConnection, snapshotPath, appVersion); + manifest = BuildManifest(snapshotConnection, appVersion); } + manifest = manifest with { DatabaseSha256 = ComputeSha256(snapshotPath) }; if (File.Exists(outputPath)) File.Delete(outputPath); @@ -274,7 +275,7 @@ FROM symbols s } } - private static ExportManifest BuildManifest(SqliteConnection connection, string dbPath, string appVersion) + private static ExportManifest BuildManifest(SqliteConnection connection, string appVersion) { using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA user_version"; @@ -283,7 +284,7 @@ private static ExportManifest BuildManifest(SqliteConnection connection, string var projectRoot = cmd.ExecuteScalar() as string; cmd.CommandText = "SELECT value FROM codeindex_meta WHERE key = 'indexed_head_sha' LIMIT 1"; var indexedHead = cmd.ExecuteScalar() as string; - return new ExportManifest("1", appVersion, userVersion, projectRoot, indexedHead, ComputeSha256(dbPath)); + return new ExportManifest("1", appVersion, userVersion, projectRoot, indexedHead, string.Empty); } private static void AddTextEntry(ZipArchive archive, string name, string content) From c36cd415ed6ef9e3843c3160cd4ce2b4b8739ccf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 00:21:02 +0900 Subject: [PATCH 4/6] Disable pooling for export snapshot connections (#1726) --- src/CodeIndex/Cli/ExportImportCommandRunner.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 4f6fc3645d..572b16b62a 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -169,11 +169,12 @@ private static int RunExportArchive(string[] args, JsonSerializerOptions jsonOpt CreateDatabaseSnapshot(normalizedDbPath, snapshotPath); ExportManifest manifest; - using (var snapshotConnection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString)) + using (var snapshotConnection = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath))) { snapshotConnection.Open(); manifest = BuildManifest(snapshotConnection, appVersion); } + SqliteConnection.ClearAllPools(); manifest = manifest with { DatabaseSha256 = ComputeSha256(snapshotPath) }; if (File.Exists(outputPath)) File.Delete(outputPath); @@ -316,13 +317,16 @@ INSERT INTO codeindex_meta(key, value) private static void CreateDatabaseSnapshot(string sourceDbPath, string snapshotPath) { - using var source = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = sourceDbPath }.ConnectionString); - using var destination = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = snapshotPath }.ConnectionString); + using var source = new SqliteConnection(CreateUnpooledConnectionString(sourceDbPath)); + using var destination = new SqliteConnection(CreateUnpooledConnectionString(snapshotPath)); source.Open(); destination.Open(); source.BackupDatabase(destination); } + private static string CreateUnpooledConnectionString(string dbPath) + => new SqliteConnectionStringBuilder { DataSource = dbPath, Pooling = false }.ConnectionString; + private static void DeleteSqliteSidecars(string dbPath) { TryDeleteFile(dbPath + "-wal"); From 9259fda2b03e48feb8145caee18633c55eca11c5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 01:21:19 +0900 Subject: [PATCH 5/6] Release import temp DB handles before move (#1726) --- src/CodeIndex/Cli/ExportImportCommandRunner.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/ExportImportCommandRunner.cs b/src/CodeIndex/Cli/ExportImportCommandRunner.cs index 572b16b62a..05b5150906 100644 --- a/src/CodeIndex/Cli/ExportImportCommandRunner.cs +++ b/src/CodeIndex/Cli/ExportImportCommandRunner.cs @@ -85,9 +85,13 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions) if (!DbContext.TryValidateExistingCodeIndexDb(tempPath, out var validationMessage, out _)) return WriteError($"archive database is invalid: {validationMessage}.", "re-export from a compatible CodeIndex database.", "cdidx import [--db ] [--prune-paths] [--json]"); + SqliteConnection.ClearAllPools(); if (prunePaths) + { RewriteImportedProjectRoot(tempPath, Environment.CurrentDirectory); + SqliteConnection.ClearAllPools(); + } DeleteSqliteSidecars(fullDbPath); File.Move(tempPath, fullDbPath, overwrite: true); @@ -304,7 +308,7 @@ private static string ComputeSha256(string path) private static void RewriteImportedProjectRoot(string dbPath, string projectRoot) { - using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ConnectionString); + using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath)); connection.Open(); using var cmd = connection.CreateCommand(); cmd.CommandText = @" From 0041e56c95d9d0c6eb64912d12d45993202fa808 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 1 Jun 2026 01:44:58 +0900 Subject: [PATCH 6/6] Scope trim analyzers to publish builds --- src/CodeIndex/CodeIndex.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/CodeIndex.csproj b/src/CodeIndex/CodeIndex.csproj index e7eab15775..2e41fa6074 100644 --- a/src/CodeIndex/CodeIndex.csproj +++ b/src/CodeIndex/CodeIndex.csproj @@ -7,9 +7,9 @@ CodeIndex enable enable - true - true - true + true + true + true false true true