From ff589564956962fe4ad243b82f8741e320011072 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:46:43 +0900 Subject: [PATCH 01/11] Add watch JSON batch summaries (#3568) --- changelog.d/unreleased/3568.fixed.md | 17 +++ src/CodeIndex/Cli/IndexWatchRunner.cs | 127 +++++++++++++++--- src/CodeIndex/Cli/JsonOutputContracts.cs | 18 +++ .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 60 ++++++++- 4 files changed, 198 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unreleased/3568.fixed.md diff --git a/changelog.d/unreleased/3568.fixed.md b/changelog.d/unreleased/3568.fixed.md new file mode 100644 index 0000000000..d58b56fd04 --- /dev/null +++ b/changelog.d/unreleased/3568.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3568 +affected: + - src/CodeIndex/Cli/IndexWatchRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +--- + +## English + +- **Watch JSON events now expose batch health summaries (#3568)** — `cdidx index --watch --json` event headers now include incremental phase metadata, bounded batch path samples, parsed sub-run update/remove/error counts, parse-gap diagnostics, and structured overflow recovery commands. + +## 日本語 + +- **watch JSON イベントがバッチ健全性サマリーを公開するようになりました (#3568)** — `cdidx index --watch --json` のイベントヘッダに incremental phase メタデータ、上限付きバッチ path sample、サブ実行の update/remove/error 件数、parse gap 診断、overflow 復旧コマンドを構造化して含めるようになりました。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 5bceb0063b..c6ad8c627f 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -18,6 +18,7 @@ internal static class IndexWatchRunner internal const int MaxDebounceMs = 60_000; internal const int MaxHumanSummarySubRunJsonChars = 64 * 1024; internal const int MaxHumanSummaryJsonDepth = 16; + internal const int BatchPathSampleLimit = 20; private const int InternalBufferSize = 64 * 1024; private const int PollIntervalMs = 50; @@ -175,7 +176,7 @@ private static int RunPartialUpdate( foreach (var path in changedPaths) args.Add(path); - return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", changedPaths.Count); + return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", changedPaths.Count, "incremental", changedPaths); } private static int RunFullRescan( @@ -186,7 +187,7 @@ private static int RunFullRescan( var args = BuildSubRunArgs(baseOptions); // No --files: this is a default incremental full scan. // --files を付けない: 通常のインクリメンタル全件スキャン。 - return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null); + return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null, "incremental", batchPaths: null); } private static void RecordSubRunExitCode(ref int watchExitCode, int subRunExitCode) @@ -234,7 +235,9 @@ private static int InvokeSubRunAndEmit( List args, Stopwatch stopwatch, string status, - int? batchSize) + int? batchSize, + string phase, + IReadOnlyList? batchPaths) { string capturedJson; int subRunExitCode; @@ -255,18 +258,29 @@ private static int InvokeSubRunAndEmit( var failureReason = subRunExitCode == CommandExitCodes.Success ? null : $"{status} sub-run exited with code {subRunExitCode.ToString(CultureInfo.InvariantCulture)}"; + var summary = ParseSubRunSummary(capturedJson); if (baseOptions.Json) { + var pathSamples = BuildBatchPathSamples(baseOptions.ProjectPath!, batchPaths, out var pathSamplesTruncated); // Pre-pend a watch-event header line so MCP clients can distinguish watch // batches from the initial scan. The underlying sub-run result follows. // watch バッチであることを示すヘッダ行を先頭に流し、その後にサブ実行 JSON を出す。 Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult { Status = eventStatus, + Phase = phase, BatchSize = batchSize, + BatchPathSamples = pathSamples.Count > 0 ? pathSamples : null, + BatchPathSampleLimit = batchPaths == null ? null : BatchPathSampleLimit, + BatchPathSamplesTruncated = batchPaths == null ? null : pathSamplesTruncated, ElapsedMs = stopwatch.ElapsedMilliseconds, ExitCode = subRunExitCode, + Updated = summary.Updated, + Removed = summary.Removed, + Errors = summary.Errors, + SubRunParseStatus = summary.ParseStatus, + SubRunParseReason = summary.ParseReason, Reason = failureReason, }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); @@ -302,33 +316,74 @@ private static string FormatHumanSummary(string status, int? batchSize, long ela { $"exit code {exitCode.ToString(CultureInfo.InvariantCulture)}", }; + var summary = ParseSubRunSummary(subRunJson); + if (summary.ParseStatus == "parsed") + { + details.Add($"updated {summary.Updated.GetValueOrDefault()}"); + details.Add($"removed {summary.Removed.GetValueOrDefault()}"); + details.Add($"errors {summary.Errors.GetValueOrDefault()}"); + } + + var detail = details.Count > 0 ? $" ({string.Join(", ", details)})" : string.Empty; + return $"{prefix}{batchLabel}{detail} in {elapsedMs.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} ms"; + } + + private static WatchSubRunSummary ParseSubRunSummary(string subRunJson) + { + var trimmedLength = TrimTrailingLineBreaks(subRunJson); + if (trimmedLength == 0) + return new WatchSubRunSummary(null, null, null, "missing", "sub-run emitted no JSON"); + + if (trimmedLength > MaxHumanSummarySubRunJsonChars) + return new WatchSubRunSummary(null, null, null, "too_large", $"sub-run JSON exceeded {MaxHumanSummarySubRunJsonChars.ToString(CultureInfo.InvariantCulture)} characters"); + try { - var trimmedLength = TrimTrailingLineBreaks(subRunJson); - if (trimmedLength > 0 && trimmedLength <= MaxHumanSummarySubRunJsonChars) + using var doc = JsonDocument.Parse( + subRunJson.AsMemory(0, trimmedLength), + new JsonDocumentOptions { MaxDepth = MaxHumanSummaryJsonDepth }); + var root = doc.RootElement; + if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("summary", out var summary) + || summary.ValueKind != JsonValueKind.Object) { - using var doc = JsonDocument.Parse( - subRunJson.AsMemory(0, trimmedLength), - new JsonDocumentOptions { MaxDepth = MaxHumanSummaryJsonDepth }); - var root = doc.RootElement; - if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("summary", out var summary) - && summary.ValueKind == JsonValueKind.Object) - { - int updated = summary.TryGetProperty("updated", out var u) && u.TryGetInt32(out var uv) ? uv : 0; - int removed = summary.TryGetProperty("removed", out var r) && r.TryGetInt32(out var rv) ? rv : 0; - int errors = summary.TryGetProperty("errors", out var er) && er.TryGetInt32(out var erv) ? erv : 0; - details.Add($"updated {updated}"); - details.Add($"removed {removed}"); - details.Add($"errors {errors}"); - } + return new WatchSubRunSummary(null, null, null, "missing_summary", "sub-run JSON did not contain an object summary"); } + + return new WatchSubRunSummary( + TryReadInt32(summary, "updated") ?? 0, + TryReadInt32(summary, "removed") ?? 0, + TryReadInt32(summary, "errors") ?? 0, + "parsed", + null); } - catch (JsonException) + catch (JsonException ex) { + return new WatchSubRunSummary(null, null, null, "invalid_json", ex.Message); } + } - var detail = details.Count > 0 ? $" ({string.Join(", ", details)})" : string.Empty; - return $"{prefix}{batchLabel}{detail} in {elapsedMs.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} ms"; + private static int? TryReadInt32(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.TryGetInt32(out var value) + ? value + : null; + + private static List BuildBatchPathSamples(string projectRoot, IReadOnlyList? batchPaths, out bool truncated) + { + truncated = false; + if (batchPaths == null || batchPaths.Count == 0) + return []; + + truncated = batchPaths.Count > BatchPathSampleLimit; + var samples = new List(Math.Min(batchPaths.Count, BatchPathSampleLimit)); + foreach (var path in batchPaths.Take(BatchPathSampleLimit)) + { + var sample = path; + if (Path.IsPathRooted(path)) + sample = Path.GetRelativePath(projectRoot, path); + samples.Add(FileIndexer.NormalizePathSeparators(sample)); + } + + return samples; } private static int TrimTrailingLineBreaks(string value) @@ -355,6 +410,7 @@ private static void EmitWatchStarted( Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult { Status = "watching", + Phase = "initial_scan", ProjectRoot = projectRoot, Db = resolvedDbPath, DebounceMs = (int)debounce.TotalMilliseconds, @@ -379,6 +435,9 @@ private static void EmitWatchOverflow(IndexCommandOptions baseOptions, string? r { Status = "overflow", Reason = reason, + Phase = "incremental", + OverflowReason = reason, + RecoveryCommand = BuildOverflowRecoveryCommand(baseOptions), }, CliJsonSerializerContextFactory.Create(jsonOpts).IndexWatchEventJsonResult)); } else @@ -406,6 +465,30 @@ private static void EmitWatchStopped(IndexCommandOptions baseOptions) Console.Error.WriteLine("[watch] Stopped."); } } + + private static IndexWatchRecoveryCommandJsonResult BuildOverflowRecoveryCommand(IndexCommandOptions baseOptions) + { + var args = new List { "index", baseOptions.ProjectPath! }; + if (!string.IsNullOrEmpty(baseOptions.DbPath)) + { + args.Add("--db"); + args.Add(baseOptions.DbPath!); + } + + args.Add("--json"); + return new IndexWatchRecoveryCommandJsonResult + { + Command = "cdidx", + Args = args, + }; + } + + private readonly record struct WatchSubRunSummary( + int? Updated, + int? Removed, + int? Errors, + string ParseStatus, + string? ParseReason); } /// diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index bb5066b1ee..204713cb0a 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -208,15 +208,32 @@ internal sealed class IndexDryRunJsonResult internal sealed class IndexWatchEventJsonResult { public string Status { get; init; } = string.Empty; + public string? Phase { get; init; } public string? ProjectRoot { get; init; } public string? Db { get; init; } public int? DebounceMs { get; init; } public int? BatchSize { get; init; } + public List? BatchPathSamples { get; init; } + public int? BatchPathSampleLimit { get; init; } + public bool? BatchPathSamplesTruncated { get; init; } public long? ElapsedMs { get; init; } public int? ExitCode { get; init; } + public int? Updated { get; init; } + public int? Removed { get; init; } + public int? Errors { get; init; } + public string? SubRunParseStatus { get; init; } + public string? SubRunParseReason { get; init; } + public string? OverflowReason { get; init; } + public IndexWatchRecoveryCommandJsonResult? RecoveryCommand { get; init; } public string? Reason { get; init; } } +internal sealed class IndexWatchRecoveryCommandJsonResult +{ + public string Command { get; init; } = string.Empty; + public List Args { get; init; } = []; +} + internal sealed class IndexUpdateSummaryJsonResult { public long FilesTotal { get; init; } @@ -425,6 +442,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(IndexUpdateJsonResult))] [JsonSerializable(typeof(IndexUpdateSummaryJsonResult))] [JsonSerializable(typeof(IndexWatchEventJsonResult))] +[JsonSerializable(typeof(IndexWatchRecoveryCommandJsonResult))] [JsonSerializable(typeof(ExportImportCommandRunner.ImportResult))] [JsonSerializable(typeof(HookCommandJsonResult))] [JsonSerializable(typeof(JsonStreamDoneResult))] diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index ee7476b130..453c9cf5dd 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -231,7 +231,16 @@ public void InvokeSubRunAndEmit_JsonSubRunFailure_EmitsFailedStatusAndExitCode() { exitCode = Assert.IsType(method.Invoke( null, - [options, _jsonOptions, args, Stopwatch.StartNew(), "updated", 3])); + [ + options, + _jsonOptions, + args, + Stopwatch.StartNew(), + "updated", + 3, + "incremental", + new[] { Path.Combine(projectRoot, "a.cs"), Path.Combine(projectRoot, "b.cs") }, + ])); } finally { @@ -245,8 +254,14 @@ public void InvokeSubRunAndEmit_JsonSubRunFailure_EmitsFailedStatusAndExitCode() var firstLine = Assert.Single(capturedOut.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Take(1)); using var doc = JsonDocument.Parse(firstLine); Assert.Equal("failed", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal("incremental", doc.RootElement.GetProperty("phase").GetString()); Assert.Equal(3, doc.RootElement.GetProperty("batch_size").GetInt32()); + Assert.Equal(IndexWatchRunner.BatchPathSampleLimit, doc.RootElement.GetProperty("batch_path_sample_limit").GetInt32()); + Assert.False(doc.RootElement.GetProperty("batch_path_samples_truncated").GetBoolean()); + Assert.Equal(2, doc.RootElement.GetProperty("batch_path_samples").GetArrayLength()); + Assert.Equal("a.cs", doc.RootElement.GetProperty("batch_path_samples")[0].GetString()); Assert.Equal(exitCode, doc.RootElement.GetProperty("exit_code").GetInt32()); + Assert.Equal("missing_summary", doc.RootElement.GetProperty("sub_run_parse_status").GetString()); var reason = doc.RootElement.GetProperty("reason").GetString(); Assert.NotNull(reason); Assert.Contains("updated sub-run exited with code", reason); @@ -288,7 +303,7 @@ public void InvokeSubRunAndEmit_HumanSubRunFailure_IncludesExitCode() { exitCode = Assert.IsType(method.Invoke( null, - [options, _jsonOptions, args, Stopwatch.StartNew(), "updated", 3])); + [options, _jsonOptions, args, Stopwatch.StartNew(), "updated", 3, "incremental", Array.Empty()])); } finally { @@ -308,6 +323,47 @@ public void InvokeSubRunAndEmit_HumanSubRunFailure_IncludesExitCode() } } + [Fact] + public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() + { + var options = new IndexCommandOptions + { + ProjectPath = "/repo", + DbPath = "/repo/.cdidx/codeindex.db", + Json = true, + Watch = true, + }; + var method = typeof(IndexWatchRunner).GetMethod("EmitWatchOverflow", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + string capturedOut; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + Console.SetOut(stdout); + try + { + method.Invoke(null, [options, "buffer full"]); + } + finally + { + Console.SetOut(originalOut); + } + capturedOut = stdout.ToString(); + } + + using var doc = JsonDocument.Parse(capturedOut); + Assert.Equal("overflow", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal("incremental", doc.RootElement.GetProperty("phase").GetString()); + Assert.Equal("buffer full", doc.RootElement.GetProperty("overflow_reason").GetString()); + var recovery = doc.RootElement.GetProperty("recovery_command"); + Assert.Equal("cdidx", recovery.GetProperty("command").GetString()); + Assert.Equal("index", recovery.GetProperty("args")[0].GetString()); + Assert.Equal("/repo", recovery.GetProperty("args")[1].GetString()); + Assert.Contains("--json", recovery.GetProperty("args").EnumerateArray().Select(static item => item.GetString())); + } + [Fact] public void FormatHumanSummary_BoundedSubRunJson_ExtractsCounts() { From 235ec5ee746189fc01d79455e5a4a0c8bb420fd0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:50:41 +0900 Subject: [PATCH 02/11] Add dry-run mutation estimates (#3569) --- changelog.d/unreleased/3569.fixed.md | 17 ++ .../Cli/IndexCommandRunner.DryRun.cs | 251 +++++++++++++++++- src/CodeIndex/Cli/JsonOutputContracts.cs | 7 + .../IndexCommandRunnerTests.cs | 80 ++++++ 4 files changed, 348 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/3569.fixed.md diff --git a/changelog.d/unreleased/3569.fixed.md b/changelog.d/unreleased/3569.fixed.md new file mode 100644 index 0000000000..ec93bd6e4b --- /dev/null +++ b/changelog.d/unreleased/3569.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3569 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Index dry-run now reports projected cleanup work (#3569)** — `cdidx index --dry-run --json` now marks mutation estimates explicitly and reports projected file updates, deletes, purges, unsupported/unknown counts, and estimated table mutation counts without writing to the database. + +## 日本語 + +- **index dry-run が予測される cleanup 作業を報告するようになりました (#3569)** — `cdidx index --dry-run --json` は mutation estimate であることを明示し、DB に書き込まずに予測される file update/delete/purge、unsupported/unknown 件数、table 別 mutation 推定件数を報告するようになりました。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 6c719c7745..f7412ca360 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CodeIndex.Database; using CodeIndex.Indexer; +using Microsoft.Data.Sqlite; namespace CodeIndex.Cli; @@ -21,9 +22,19 @@ private static int RunDryRun( var projectPath = options.ProjectPath!; var dryIndexer = new FileIndexer(projectPath, ignoreCase, ignoreRuleRoot, options.MaxFileSizeBytes, directoryIgnoreCaseProbe: null, symlinkPolicy: options.SymlinkPolicy); IReadOnlyList dryCandidates; + IReadOnlyList dryDeleteCandidates; + bool authoritativeFullScan; var errorSamples = new List(); var errorCount = 0; var dryScanErrorKeys = new HashSet(StringComparer.Ordinal); + var resolvedDbPath = DbPathResolver.NormalizeDbPath(DbPathResolver.ResolveForIndex(projectPath, options.DbPath, options.DataDir).DbPath); + var dbSnapshot = ReadDryRunDbSnapshot(resolvedDbPath); + var retainedRelativePaths = new HashSet(StringComparer.Ordinal); + var projectedDeletePaths = new HashSet(StringComparer.Ordinal); + var projectedPurgePaths = new HashSet(StringComparer.Ordinal); + var estimatedTableMutations = CreateEmptyEstimatedTableMutations(); + var unsupportedTotal = 0; + var unknownExtensionTotal = 0; void RecordDryRunError(string file, string message) { @@ -57,6 +68,8 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) cancellationToken, RecordDryRunScanErrors, out dryCandidates, + out dryDeleteCandidates, + out authoritativeFullScan, out var exitCode)) { return exitCode; @@ -67,34 +80,82 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) var langCounts = new Dictionary(); foreach (var f in dryCandidates) { + var relativePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, f)); var pathFilter = dryIndexer.EvaluatePathFilter(f); RecordDryRunScanErrors(pathFilter.Errors); if (pathFilter.ShouldSkip) + { + if (pathFilter.ShouldDeleteExisting && dbSnapshot.Files.ContainsKey(relativePath)) + projectedDeletePaths.Add(relativePath); continue; + } - if (!TryProbeDryRunFile(dryIndexer, f, out var lang, out var message)) + var probe = ProbeDryRunFile(dryIndexer, f); + if (!probe.Supported) { - if (message != null) + if (probe.UnknownExtension) + unknownExtensionTotal++; + else if (probe.Unsupported) + unsupportedTotal++; + + if (dbSnapshot.Files.ContainsKey(relativePath)) + projectedDeletePaths.Add(relativePath); + + if (probe.Error != null) { - var displayPath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, f)); - RecordDryRunError(displayPath, message); + RecordDryRunError(relativePath, probe.Error); if (!options.Json && !options.Quiet) - ConsoleUi.PrintWarning($"{displayPath}: {message}"); + ConsoleUi.PrintWarning($"{relativePath}: {probe.Error}"); } continue; } dryFileCount++; + retainedRelativePaths.Add(relativePath); + AddEstimatedUpdateMutation(estimatedTableMutations, dbSnapshot, relativePath); if (dryFileSamples.Count < DryRunFileSampleLimit) - dryFileSamples.Add(FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, f))); - langCounts[lang] = langCounts.GetValueOrDefault(lang) + 1; + dryFileSamples.Add(relativePath); + langCounts[probe.Language] = langCounts.GetValueOrDefault(probe.Language) + 1; + } + + foreach (var relativePath in dryDeleteCandidates) + { + if (dbSnapshot.Files.ContainsKey(relativePath)) + projectedDeletePaths.Add(relativePath); + } + + if (authoritativeFullScan && dbSnapshot.Files.Count > 0) + { + foreach (var relativePath in dbSnapshot.Files.Keys) + { + if (!retainedRelativePaths.Contains(relativePath)) + projectedPurgePaths.Add(relativePath); + } } + + projectedPurgePaths.ExceptWith(projectedDeletePaths); + + var projectedDeletes = projectedDeletePaths.Count; + var projectedPurges = projectedPurgePaths.Count; + + foreach (var relativePath in projectedDeletePaths) + AddEstimatedDeleteMutation(estimatedTableMutations, dbSnapshot, relativePath); + foreach (var relativePath in projectedPurgePaths) + AddEstimatedDeleteMutation(estimatedTableMutations, dbSnapshot, relativePath); + if (options.Json) { Console.WriteLine(JsonSerializer.Serialize(new IndexDryRunJsonResult { Status = "dry_run", FilesTotal = dryFileCount, + Estimates = true, + ProjectedFileUpdates = dryFileCount, + ProjectedFileDeletes = projectedDeletes, + ProjectedFilePurges = projectedPurges, + UnsupportedTotal = unsupportedTotal, + UnknownExtensionTotal = unknownExtensionTotal, + EstimatedTableMutations = estimatedTableMutations, FileSamples = dryFileSamples.Count > 0 ? dryFileSamples : null, FileSamplesTruncated = dryFileCount > dryFileSamples.Count, FileSampleLimit = DryRunFileSampleLimit, @@ -108,6 +169,8 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) else { Console.WriteLine($"Dry run: {dryFileCount} files would be indexed"); + Console.WriteLine($" projected deletes {projectedDeletes,6}"); + Console.WriteLine($" projected purges {projectedPurges,6}"); foreach (var (lang, count) in langCounts.OrderByDescending(kv => kv.Value)) Console.WriteLine($" {lang,-12} {count,6}"); } @@ -122,9 +185,13 @@ private static bool TryResolveDryRunCandidates( CancellationToken cancellationToken, Action> recordDryRunScanErrors, out IReadOnlyList dryCandidates, + out IReadOnlyList dryDeleteCandidates, + out bool authoritativeFullScan, out int exitCode) { dryCandidates = []; + dryDeleteCandidates = []; + authoritativeFullScan = false; exitCode = CommandExitCodes.Success; if (options.UpdateFiles.Count > 0) @@ -145,10 +212,14 @@ private static bool TryResolveDryRunCandidates( return false; } dryCandidates = scanResult.Files; + authoritativeFullScan = true; recordDryRunScanErrors(scanResult.Errors); } else { + dryDeleteCandidates = updatePaths + .Where(path => !File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(projectPath, path.Replace('/', Path.DirectorySeparatorChar))))) + .ToList(); dryCandidates = updatePaths .Select(path => Path.Combine(projectPath, path.Replace('/', Path.DirectorySeparatorChar))) .Where(p => File.Exists(LongPath.EnsureWindowsPrefix(p))) @@ -230,10 +301,14 @@ private static bool TryResolveDryRunCandidates( return false; } dryCandidates = scanResult.Files; + authoritativeFullScan = true; recordDryRunScanErrors(scanResult.Errors); } else { + dryDeleteCandidates = changedFiles + .Where(path => !File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(projectPath, path.Replace('/', Path.DirectorySeparatorChar))))) + .ToList(); dryCandidates = changedFiles .Select(path => Path.Combine(projectPath, path.Replace('/', Path.DirectorySeparatorChar))) .Where(p => File.Exists(LongPath.EnsureWindowsPrefix(p))) @@ -253,12 +328,150 @@ private static bool TryResolveDryRunCandidates( return false; } dryCandidates = scanResult.Files; + authoritativeFullScan = true; recordDryRunScanErrors(scanResult.Errors); } return true; } + private static DryRunFileProbe ProbeDryRunFile(FileIndexer indexer, string absolutePath) + { + var indexability = FileIndexer.GetFileIndexability(absolutePath); + if (indexability == FileIndexer.FileProbeStatus.ProbeFailed) + return DryRunFileProbe.FromError("Could not probe file for indexability/language."); + if (indexability != FileIndexer.FileProbeStatus.Supported) + return DryRunFileProbe.FromUnsupported(); + + var detection = FileIndexer.TryDetectLanguage(absolutePath); + if (detection.Status == FileIndexer.FileProbeStatus.ProbeFailed) + return DryRunFileProbe.FromError("Could not probe file for indexability/language."); + if (detection.Status != FileIndexer.FileProbeStatus.Supported) + return string.IsNullOrEmpty(Path.GetExtension(absolutePath)) + ? DryRunFileProbe.FromUnsupported() + : DryRunFileProbe.FromUnknownExtension(); + + try + { + var (record, _, _, warning) = indexer.BuildRecordWithRawBytes(absolutePath); + return new DryRunFileProbe(true, record.Lang ?? "unknown", warning, Unsupported: false, UnknownExtension: false); + } + catch (Exception ex) + { + return DryRunFileProbe.FromError(ex.Message); + } + } + + private static Dictionary CreateEmptyEstimatedTableMutations() + => new(StringComparer.Ordinal) + { + ["files"] = 0, + ["chunks"] = 0, + ["symbols"] = 0, + ["symbol_references"] = 0, + ["reference_lines"] = 0, + ["file_issues"] = 0, + }; + + private static void AddEstimatedUpdateMutation( + Dictionary mutations, + DryRunDbSnapshot snapshot, + string relativePath) + { + mutations["files"]++; + if (snapshot.Files.TryGetValue(relativePath, out var rows)) + AddExistingChildRows(mutations, rows); + } + + private static void AddEstimatedDeleteMutation( + Dictionary mutations, + DryRunDbSnapshot snapshot, + string relativePath) + { + if (!snapshot.Files.TryGetValue(relativePath, out var rows)) + return; + + mutations["files"]++; + AddExistingChildRows(mutations, rows); + } + + private static void AddExistingChildRows(Dictionary mutations, DryRunExistingFileRows rows) + { + mutations["chunks"] += rows.Chunks; + mutations["symbols"] += rows.Symbols; + mutations["symbol_references"] += rows.SymbolReferences; + mutations["reference_lines"] += rows.ReferenceLines; + mutations["file_issues"] += rows.FileIssues; + } + + private static DryRunDbSnapshot ReadDryRunDbSnapshot(string dbPath) + { + try + { + if (!dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase) + && !File.Exists(LongPath.EnsureWindowsPrefix(dbPath))) + { + return DryRunDbSnapshot.Empty; + } + + using var connection = new SqliteConnection(DbPathResolver.BuildSqliteConnectionString(dbPath, SqliteOpenMode.ReadOnly)); + connection.Open(); + if (!DryRunTableExists(connection, "files")) + return DryRunDbSnapshot.Empty; + + var hasChunks = DryRunTableExists(connection, "chunks"); + var hasSymbols = DryRunTableExists(connection, "symbols"); + var hasSymbolReferences = DryRunTableExists(connection, "symbol_references"); + var hasReferenceLines = DryRunTableExists(connection, "reference_lines"); + var hasFileIssues = DryRunTableExists(connection, "file_issues"); + + using var command = connection.CreateCommand(); + command.CommandText = $""" + SELECT f.path, + {(hasChunks ? "(SELECT COUNT(*) FROM chunks c WHERE c.file_id = f.id)" : "0")} AS chunks_count, + {(hasSymbols ? "(SELECT COUNT(*) FROM symbols s WHERE s.file_id = f.id)" : "0")} AS symbols_count, + {(hasSymbolReferences ? "(SELECT COUNT(*) FROM symbol_references r WHERE r.file_id = f.id)" : "0")} AS symbol_references_count, + {(hasReferenceLines ? "(SELECT COUNT(*) FROM reference_lines l WHERE l.file_id = f.id)" : "0")} AS reference_lines_count, + {(hasFileIssues ? "(SELECT COUNT(*) FROM file_issues i WHERE i.file_id = f.id)" : "0")} AS file_issues_count + FROM files f + """; + + var files = new Dictionary(StringComparer.Ordinal); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + files[reader.GetString(0)] = new DryRunExistingFileRows( + reader.GetInt64(1), + reader.GetInt64(2), + reader.GetInt64(3), + reader.GetInt64(4), + reader.GetInt64(5)); + } + + return new DryRunDbSnapshot(files); + } + catch (SqliteException) + { + return DryRunDbSnapshot.Empty; + } + catch (IOException) + { + return DryRunDbSnapshot.Empty; + } + catch (UnauthorizedAccessException) + { + return DryRunDbSnapshot.Empty; + } + } + + private static bool DryRunTableExists(SqliteConnection connection, string tableName) + { + using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = @name LIMIT 1"; + command.Parameters.AddWithValue("@name", tableName); + return command.ExecuteScalar() != null; + } + private static int WriteDryRunInterrupted(IndexCommandOptions options, JsonSerializerOptions jsonOptions) => WriteCommandError( options.Json, jsonOptions, @@ -266,4 +479,28 @@ private static int WriteDryRunInterrupted(IndexCommandOptions options, JsonSeria CommandExitCodes.Interrupted, "Rerun `cdidx index --dry-run` when you are ready to inspect the candidate files again.", CommandErrorCodes.Interrupted); + + private sealed record DryRunDbSnapshot(IReadOnlyDictionary Files) + { + public static DryRunDbSnapshot Empty { get; } = new(new Dictionary(StringComparer.Ordinal)); + } + + private readonly record struct DryRunExistingFileRows( + long Chunks, + long Symbols, + long SymbolReferences, + long ReferenceLines, + long FileIssues); + + private readonly record struct DryRunFileProbe( + bool Supported, + string Language, + string? Error, + bool Unsupported, + bool UnknownExtension) + { + public static DryRunFileProbe FromError(string message) => new(false, string.Empty, message, Unsupported: false, UnknownExtension: false); + public static DryRunFileProbe FromUnsupported() => new(false, string.Empty, null, Unsupported: true, UnknownExtension: false); + public static DryRunFileProbe FromUnknownExtension() => new(false, string.Empty, null, Unsupported: false, UnknownExtension: true); + } } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 204713cb0a..c3b56b88ec 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -195,6 +195,13 @@ internal sealed class IndexDryRunJsonResult { public string Status { get; init; } = string.Empty; public int FilesTotal { get; init; } + public bool Estimates { get; init; } + public int ProjectedFileUpdates { get; init; } + public int ProjectedFileDeletes { get; init; } + public int ProjectedFilePurges { get; init; } + public int UnsupportedTotal { get; init; } + public int UnknownExtensionTotal { get; init; } + public Dictionary EstimatedTableMutations { get; init; } = new(); public List? FileSamples { get; init; } public bool FileSamplesTruncated { get; init; } public int FileSampleLimit { get; init; } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 603bd6be72..213af67fbf 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -8304,6 +8304,86 @@ public void Run_DryRun_JsonCapsErrorSamples() } } + [Fact] + public void Run_DryRun_WithFiles_ReportsProjectedUpdatesDeletesAndUnknowns() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText(Path.Combine(projectRoot, "changed.cs"), "public class Changed { }\n"); + File.WriteAllText(Path.Combine(projectRoot, "deleted.cs"), "public class Deleted { }\n"); + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(2, CountRows(dbPath, "files")); + + File.AppendAllText(Path.Combine(projectRoot, "changed.cs"), "public class ChangedAgain { }\n"); + File.Delete(Path.Combine(projectRoot, "deleted.cs")); + File.WriteAllText(Path.Combine(projectRoot, "notes.unknownext"), "plain text\n"); + + var (exitCode, json) = RunAndCaptureJson([ + projectRoot, + "--files", + "changed.cs", + "deleted.cs", + "notes.unknownext", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.True(json.GetProperty("estimates").GetBoolean()); + Assert.Equal(1, json.GetProperty("files_total").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_purges").GetInt32()); + Assert.Equal(1, json.GetProperty("unknown_extension_total").GetInt32()); + Assert.Equal(0, json.GetProperty("unsupported_total").GetInt32()); + var mutations = json.GetProperty("estimated_table_mutations"); + Assert.True(mutations.GetProperty("files").GetInt64() >= 2); + Assert.True(mutations.GetProperty("chunks").GetInt64() > 0); + Assert.True(mutations.GetProperty("symbols").GetInt64() > 0); + Assert.True(mutations.TryGetProperty("file_issues", out _)); + Assert.Equal(2, CountRows(dbPath, "files")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_FullScan_ReportsProjectedPurgesWithoutWriting() + { + var projectRoot = CreateTempProject(); + try + { + File.WriteAllText(Path.Combine(projectRoot, "kept.cs"), "public class Kept { }\n"); + File.WriteAllText(Path.Combine(projectRoot, "removed.cs"), "public class Removed { }\n"); + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + File.Delete(Path.Combine(projectRoot, "removed.cs")); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("files_total").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); + Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("files").GetInt64() >= 2); + Assert.Equal(2, CountRows(dbPath, "files")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_FullScan_ReportsUnreadableDirectory() { From 95c6cc9baa0a26bd8eb875d50bc52a3a09330518 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:18:41 +0900 Subject: [PATCH 03/11] Fix dry-run full-scan estimates (#3569) --- .../Cli/IndexCommandRunner.DryRun.cs | 41 +++++++++++++++++++ .../IndexCommandRunnerTests.cs | 3 ++ 2 files changed, 44 insertions(+) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index f7412ca360..389c2d149d 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -27,6 +27,7 @@ private static int RunDryRun( var errorSamples = new List(); var errorCount = 0; var dryScanErrorKeys = new HashSet(StringComparer.Ordinal); + DryRunScanMetadata dryScanMetadata; var resolvedDbPath = DbPathResolver.NormalizeDbPath(DbPathResolver.ResolveForIndex(projectPath, options.DbPath, options.DataDir).DbPath); var dbSnapshot = ReadDryRunDbSnapshot(resolvedDbPath); var retainedRelativePaths = new HashSet(StringComparer.Ordinal); @@ -70,6 +71,7 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) out dryCandidates, out dryDeleteCandidates, out authoritativeFullScan, + out dryScanMetadata, out var exitCode)) { return exitCode; @@ -78,6 +80,12 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) var dryFileSamples = new List(); var dryFileCount = 0; var langCounts = new Dictionary(); + if (authoritativeFullScan) + { + unknownExtensionTotal = dryScanMetadata.UnknownExtensionFiles.Count; + unsupportedTotal = CountUnsupportedNonIndexablePaths(dryScanMetadata); + } + foreach (var f in dryCandidates) { var relativePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, f)); @@ -187,11 +195,13 @@ private static bool TryResolveDryRunCandidates( out IReadOnlyList dryCandidates, out IReadOnlyList dryDeleteCandidates, out bool authoritativeFullScan, + out DryRunScanMetadata scanMetadata, out int exitCode) { dryCandidates = []; dryDeleteCandidates = []; authoritativeFullScan = false; + scanMetadata = DryRunScanMetadata.Empty; exitCode = CommandExitCodes.Success; if (options.UpdateFiles.Count > 0) @@ -213,6 +223,7 @@ private static bool TryResolveDryRunCandidates( } dryCandidates = scanResult.Files; authoritativeFullScan = true; + scanMetadata = DryRunScanMetadata.FromScanResult(scanResult); recordDryRunScanErrors(scanResult.Errors); } else @@ -302,6 +313,7 @@ private static bool TryResolveDryRunCandidates( } dryCandidates = scanResult.Files; authoritativeFullScan = true; + scanMetadata = DryRunScanMetadata.FromScanResult(scanResult); recordDryRunScanErrors(scanResult.Errors); } else @@ -329,12 +341,31 @@ private static bool TryResolveDryRunCandidates( } dryCandidates = scanResult.Files; authoritativeFullScan = true; + scanMetadata = DryRunScanMetadata.FromScanResult(scanResult); recordDryRunScanErrors(scanResult.Errors); } return true; } + private static int CountUnsupportedNonIndexablePaths(DryRunScanMetadata scanMetadata) + { + if (scanMetadata.NonIndexablePaths.Count == 0) + return 0; + + var unknownPaths = scanMetadata.UnknownExtensionFiles.Count > 0 + ? new HashSet(scanMetadata.UnknownExtensionFiles, StringComparer.Ordinal) + : []; + var count = 0; + foreach (var path in scanMetadata.NonIndexablePaths) + { + if (!unknownPaths.Contains(path)) + count++; + } + + return count; + } + private static DryRunFileProbe ProbeDryRunFile(FileIndexer indexer, string absolutePath) { var indexability = FileIndexer.GetFileIndexability(absolutePath); @@ -492,6 +523,16 @@ private readonly record struct DryRunExistingFileRows( long ReferenceLines, long FileIssues); + private readonly record struct DryRunScanMetadata( + IReadOnlyList NonIndexablePaths, + IReadOnlyList UnknownExtensionFiles) + { + public static DryRunScanMetadata Empty { get; } = new([], []); + + public static DryRunScanMetadata FromScanResult(FileIndexer.ScanFilesResult scanResult) + => new(scanResult.NonIndexablePaths, scanResult.UnknownExtensionFiles); + } + private readonly record struct DryRunFileProbe( bool Supported, string Language, diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 213af67fbf..045dd65a58 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -8367,6 +8367,7 @@ public void Run_DryRun_FullScan_ReportsProjectedPurgesWithoutWriting() var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); File.Delete(Path.Combine(projectRoot, "removed.cs")); + File.WriteAllText(Path.Combine(projectRoot, "notes.unknownext"), "plain text\n"); var (exitCode, json) = RunAndCaptureJson([projectRoot, "--dry-run", "--json"]); @@ -8375,6 +8376,8 @@ public void Run_DryRun_FullScan_ReportsProjectedPurgesWithoutWriting() Assert.Equal(1, json.GetProperty("files_total").GetInt32()); Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); + Assert.Equal(1, json.GetProperty("unknown_extension_total").GetInt32()); + Assert.True(json.TryGetProperty("unsupported_total", out _)); Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("files").GetInt64() >= 2); Assert.Equal(2, CountRows(dbPath, "files")); } From e8c2ddd4551e79ba1cb21cd54f38a34484146aa8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:19:40 +0900 Subject: [PATCH 04/11] Fix watch overflow recovery command (#3568) --- src/CodeIndex/Cli/IndexWatchRunner.cs | 16 +++++----------- tests/CodeIndex.Tests/IndexWatchRunnerTests.cs | 7 +++++-- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index c6ad8c627f..3bad28bef0 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -141,7 +141,7 @@ void Enqueue(string fullPath) if (fullRescan) { - EmitWatchOverflow(baseOptions, overflowReason); + EmitWatchOverflow(baseOptions, overflowReason, resolvedDbPath); RecordSubRunExitCode(ref watchExitCode, RunFullRescan(baseOptions, jsonOptions)); continue; } @@ -423,7 +423,7 @@ private static void EmitWatchStarted( } } - private static void EmitWatchOverflow(IndexCommandOptions baseOptions, string? reason) + private static void EmitWatchOverflow(IndexCommandOptions baseOptions, string? reason, string resolvedDbPath) { if (baseOptions.Json) { @@ -437,7 +437,7 @@ private static void EmitWatchOverflow(IndexCommandOptions baseOptions, string? r Reason = reason, Phase = "incremental", OverflowReason = reason, - RecoveryCommand = BuildOverflowRecoveryCommand(baseOptions), + RecoveryCommand = BuildOverflowRecoveryCommand(baseOptions, resolvedDbPath), }, CliJsonSerializerContextFactory.Create(jsonOpts).IndexWatchEventJsonResult)); } else @@ -466,15 +466,9 @@ private static void EmitWatchStopped(IndexCommandOptions baseOptions) } } - private static IndexWatchRecoveryCommandJsonResult BuildOverflowRecoveryCommand(IndexCommandOptions baseOptions) + private static IndexWatchRecoveryCommandJsonResult BuildOverflowRecoveryCommand(IndexCommandOptions baseOptions, string resolvedDbPath) { - var args = new List { "index", baseOptions.ProjectPath! }; - if (!string.IsNullOrEmpty(baseOptions.DbPath)) - { - args.Add("--db"); - args.Add(baseOptions.DbPath!); - } - + var args = new List { "index", baseOptions.ProjectPath!, "--db", resolvedDbPath }; args.Add("--json"); return new IndexWatchRecoveryCommandJsonResult { diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 453c9cf5dd..f8874c4a31 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -329,12 +329,13 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() var options = new IndexCommandOptions { ProjectPath = "/repo", - DbPath = "/repo/.cdidx/codeindex.db", + DataDir = "/custom-data", Json = true, Watch = true, }; var method = typeof(IndexWatchRunner).GetMethod("EmitWatchOverflow", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); + const string resolvedDbPath = "/custom-data/codeindex.db"; string capturedOut; lock (TestConsoleLock.Gate) @@ -344,7 +345,7 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() Console.SetOut(stdout); try { - method.Invoke(null, [options, "buffer full"]); + method.Invoke(null, [options, "buffer full", resolvedDbPath]); } finally { @@ -361,6 +362,8 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() Assert.Equal("cdidx", recovery.GetProperty("command").GetString()); Assert.Equal("index", recovery.GetProperty("args")[0].GetString()); Assert.Equal("/repo", recovery.GetProperty("args")[1].GetString()); + Assert.Equal("--db", recovery.GetProperty("args")[2].GetString()); + Assert.Equal(resolvedDbPath, recovery.GetProperty("args")[3].GetString()); Assert.Contains("--json", recovery.GetProperty("args").EnumerateArray().Select(static item => item.GetString())); } From 09e1820c85579c324a34120464b394f3263bd1c9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:52:43 +0900 Subject: [PATCH 05/11] Align dry-run purge estimates with partial scans (#3569) --- .../Cli/IndexCommandRunner.DryRun.cs | 104 ++++++++++++++++-- .../IndexCommandRunnerTests.cs | 38 +++++++ 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 389c2d149d..e65bea656a 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -134,11 +134,11 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) if (authoritativeFullScan && dbSnapshot.Files.Count > 0) { - foreach (var relativePath in dbSnapshot.Files.Keys) - { - if (!retainedRelativePaths.Contains(relativePath)) - projectedPurgePaths.Add(relativePath); - } + AddProjectedFullScanPurges( + projectedPurgePaths, + dbSnapshot, + retainedRelativePaths, + dryScanMetadata); } projectedPurgePaths.ExceptWith(projectedDeletePaths); @@ -366,6 +366,82 @@ private static int CountUnsupportedNonIndexablePaths(DryRunScanMetadata scanMeta return count; } + private static void AddProjectedFullScanPurges( + HashSet projectedPurgePaths, + DryRunDbSnapshot dbSnapshot, + HashSet retainedRelativePaths, + DryRunScanMetadata scanMetadata) + { + if (!scanMetadata.HadErrors) + { + foreach (var relativePath in dbSnapshot.Files.Keys) + { + if (!retainedRelativePaths.Contains(relativePath)) + projectedPurgePaths.Add(relativePath); + } + + return; + } + + var retainedPaths = new HashSet(retainedRelativePaths, StringComparer.Ordinal); + foreach (var relativePath in scanMetadata.ProbeFailedFilePaths) + retainedPaths.Add(FileIndexer.NormalizeIndexPath(relativePath)); + + foreach (var relativePath in scanMetadata.NonIndexablePaths) + { + var dbPath = FileIndexer.NormalizeIndexPath(relativePath); + if (dbSnapshot.Files.ContainsKey(dbPath)) + projectedPurgePaths.Add(dbPath); + } + + var listedDirectories = scanMetadata.ListedDirectories + .Select(FileIndexer.NormalizeIndexPath) + .ToHashSet(StringComparer.Ordinal); + var attributePrunedDirectories = scanMetadata.AttributePrunedDirectories + .Select(FileIndexer.NormalizeIndexPath) + .ToHashSet(StringComparer.Ordinal); + attributePrunedDirectories.UnionWith(scanMetadata.NestedRepositories.Select(FileIndexer.NormalizeIndexPath)); + + foreach (var relativePath in dbSnapshot.Files.Keys) + { + if (retainedPaths.Contains(relativePath)) + continue; + + if (HasListedParentDirectory(relativePath, listedDirectories) + || IsUnderAttributePrunedDirectory(relativePath, attributePrunedDirectories)) + { + projectedPurgePaths.Add(relativePath); + } + } + } + + private static bool HasListedParentDirectory(string path, IReadOnlySet listedDirectories) + => listedDirectories.Contains(GetDirectoryPath(path)); + + private static bool IsUnderAttributePrunedDirectory(string path, IReadOnlySet attributePrunedDirectories) + { + if (attributePrunedDirectories.Count == 0) + return false; + + var directory = GetDirectoryPath(path); + while (directory.Length > 0) + { + if (attributePrunedDirectories.Contains(directory)) + return true; + + var separatorIndex = directory.LastIndexOf('/'); + directory = separatorIndex >= 0 ? directory[..separatorIndex] : string.Empty; + } + + return false; + } + + private static string GetDirectoryPath(string path) + { + var separatorIndex = path.LastIndexOf('/'); + return separatorIndex >= 0 ? path[..separatorIndex] : string.Empty; + } + private static DryRunFileProbe ProbeDryRunFile(FileIndexer indexer, string absolutePath) { var indexability = FileIndexer.GetFileIndexability(absolutePath); @@ -524,13 +600,25 @@ private readonly record struct DryRunExistingFileRows( long FileIssues); private readonly record struct DryRunScanMetadata( + bool HadErrors, IReadOnlyList NonIndexablePaths, - IReadOnlyList UnknownExtensionFiles) + IReadOnlyList UnknownExtensionFiles, + IReadOnlyList ProbeFailedFilePaths, + IReadOnlyList ListedDirectories, + IReadOnlyList AttributePrunedDirectories, + IReadOnlyList NestedRepositories) { - public static DryRunScanMetadata Empty { get; } = new([], []); + public static DryRunScanMetadata Empty { get; } = new(false, [], [], [], [], [], []); public static DryRunScanMetadata FromScanResult(FileIndexer.ScanFilesResult scanResult) - => new(scanResult.NonIndexablePaths, scanResult.UnknownExtensionFiles); + => new( + scanResult.HadErrors, + scanResult.NonIndexablePaths, + scanResult.UnknownExtensionFiles, + scanResult.ProbeFailedFilePaths, + scanResult.ListedDirectories, + scanResult.AttributePrunedDirectories, + scanResult.NestedRepositories); } private readonly record struct DryRunFileProbe( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 045dd65a58..e5ba7fbca0 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -8422,6 +8422,44 @@ public void Run_DryRun_FullScan_ReportsUnreadableDirectory() } } + [Fact] + public void Run_DryRun_FullScan_DoesNotProjectUnreadableSubtreePurge() + { + if (OperatingSystem.IsWindows()) + return; + + var projectRoot = CreateTempProject(); + var secretDir = Path.Combine(projectRoot, "secret"); + try + { + Directory.CreateDirectory(secretDir); + File.WriteAllText(Path.Combine(secretDir, "a.cs"), "public class A { }\n"); + File.WriteAllText(Path.Combine(projectRoot, "stale.cs"), "public class Stale { }\n"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(2, CountRows(dbPath, "files")); + + File.Delete(Path.Combine(projectRoot, "stale.cs")); + SetUnixPermissions(secretDir, UnixFileMode.None); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); + Assert.Equal(2, CountRows(dbPath, "files")); + } + finally + { + if (Directory.Exists(secretDir)) + SetUnixPermissions(secretDir, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_WithFiles_IgnoresUnixFifoKnownFilename() { From 121217b7b09cda2fb66101b799c70457a9fe8695 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:53:10 +0900 Subject: [PATCH 06/11] Preserve watch recovery indexing options (#3568) --- src/CodeIndex/Cli/IndexWatchRunner.cs | 25 ++++++----- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 42 +++++++++++++++---- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 3bad28bef0..60787254e4 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -142,14 +142,14 @@ void Enqueue(string fullPath) if (fullRescan) { EmitWatchOverflow(baseOptions, overflowReason, resolvedDbPath); - RecordSubRunExitCode(ref watchExitCode, RunFullRescan(baseOptions, jsonOptions)); + RecordSubRunExitCode(ref watchExitCode, RunFullRescan(baseOptions, jsonOptions, resolvedDbPath)); continue; } if (batch.Count == 0) continue; - RecordSubRunExitCode(ref watchExitCode, RunPartialUpdate(baseOptions, jsonOptions, batch)); + RecordSubRunExitCode(ref watchExitCode, RunPartialUpdate(baseOptions, jsonOptions, batch, resolvedDbPath)); } } finally @@ -168,10 +168,11 @@ void Enqueue(string fullPath) private static int RunPartialUpdate( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions, - IReadOnlyList changedPaths) + IReadOnlyList changedPaths, + string resolvedDbPath) { var stopwatch = Stopwatch.StartNew(); - var args = BuildSubRunArgs(baseOptions); + var args = BuildSubRunArgs(baseOptions, resolvedDbPath); args.Add("--files"); foreach (var path in changedPaths) args.Add(path); @@ -181,10 +182,11 @@ private static int RunPartialUpdate( private static int RunFullRescan( IndexCommandOptions baseOptions, - JsonSerializerOptions jsonOptions) + JsonSerializerOptions jsonOptions, + string resolvedDbPath) { var stopwatch = Stopwatch.StartNew(); - var args = BuildSubRunArgs(baseOptions); + var args = BuildSubRunArgs(baseOptions, resolvedDbPath); // No --files: this is a default incremental full scan. // --files を付けない: 通常のインクリメンタル全件スキャン。 return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null, "incremental", batchPaths: null); @@ -196,7 +198,7 @@ private static void RecordSubRunExitCode(ref int watchExitCode, int subRunExitCo watchExitCode = subRunExitCode; } - private static List BuildSubRunArgs(IndexCommandOptions baseOptions) + private static List BuildSubRunArgs(IndexCommandOptions baseOptions, string? resolvedDbPath = null) { // Always pass --json so sub-runs produce a single JSON-line summary on stdout. The // watch loop then either forwards that line (user --json) or extracts a one-line @@ -204,10 +206,11 @@ private static List BuildSubRunArgs(IndexCommandOptions baseOptions) // 常に --json を付けてサブ実行の stdout を1行 JSON に揃える。watch ループ側で // 透過 or 整形してから出力する。 var args = new List(8) { baseOptions.ProjectPath!, "--json", "--quiet" }; - if (!string.IsNullOrEmpty(baseOptions.DbPath)) + var dbPath = string.IsNullOrEmpty(resolvedDbPath) ? baseOptions.DbPath : resolvedDbPath; + if (!string.IsNullOrEmpty(dbPath)) { args.Add("--db"); - args.Add(baseOptions.DbPath!); + args.Add(dbPath!); } if (baseOptions.Verbose && baseOptions.Json) args.Add("--verbose"); @@ -468,8 +471,8 @@ private static void EmitWatchStopped(IndexCommandOptions baseOptions) private static IndexWatchRecoveryCommandJsonResult BuildOverflowRecoveryCommand(IndexCommandOptions baseOptions, string resolvedDbPath) { - var args = new List { "index", baseOptions.ProjectPath!, "--db", resolvedDbPath }; - args.Add("--json"); + var args = BuildSubRunArgs(baseOptions, resolvedDbPath); + args.Insert(0, "index"); return new IndexWatchRecoveryCommandJsonResult { Command = "cdidx", diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index f8874c4a31..e142dd90ad 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -3,6 +3,7 @@ using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Indexer; using Microsoft.Data.Sqlite; namespace CodeIndex.Tests; @@ -154,10 +155,11 @@ public void BuildSubRunArgs_JsonSubRun_IsQuiet() var method = typeof(IndexWatchRunner).GetMethod("BuildSubRunArgs", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); - var args = Assert.IsType>(method.Invoke(null, [options])); + var args = Assert.IsType>(method.Invoke(null, [options, "/repo/.cdidx/codeindex.db"])); Assert.Contains("--json", args); Assert.Contains("--quiet", args); + AssertOptionValue(args, "--db", "/repo/.cdidx/codeindex.db"); } [Fact] @@ -173,7 +175,7 @@ public void BuildSubRunArgs_MaxFileBytes_PreservesWatchOverride() var method = typeof(IndexWatchRunner).GetMethod("BuildSubRunArgs", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); - var args = Assert.IsType>(method.Invoke(null, [options])); + var args = Assert.IsType>(method.Invoke(null, [options, "/repo/.cdidx/codeindex.db"])); var flagIndex = args.IndexOf("--max-file-bytes"); Assert.True(flagIndex >= 0); @@ -193,7 +195,7 @@ public void BuildSubRunArgs_MaxSymbolsPerFile_PreservesWatchOverride() var method = typeof(IndexWatchRunner).GetMethod("BuildSubRunArgs", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); - var args = Assert.IsType>(method.Invoke(null, [options])); + var args = Assert.IsType>(method.Invoke(null, [options, "/repo/.cdidx/codeindex.db"])); var flagIndex = args.IndexOf("--max-symbols-per-file"); Assert.True(flagIndex >= 0); @@ -332,6 +334,9 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() DataDir = "/custom-data", Json = true, Watch = true, + MaxFileSizeBytes = 4096, + MaxSymbolsPerFile = 42, + SymlinkPolicy = FileIndexer.SymlinkPolicy.All, }; var method = typeof(IndexWatchRunner).GetMethod("EmitWatchOverflow", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); @@ -360,11 +365,15 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() Assert.Equal("buffer full", doc.RootElement.GetProperty("overflow_reason").GetString()); var recovery = doc.RootElement.GetProperty("recovery_command"); Assert.Equal("cdidx", recovery.GetProperty("command").GetString()); - Assert.Equal("index", recovery.GetProperty("args")[0].GetString()); - Assert.Equal("/repo", recovery.GetProperty("args")[1].GetString()); - Assert.Equal("--db", recovery.GetProperty("args")[2].GetString()); - Assert.Equal(resolvedDbPath, recovery.GetProperty("args")[3].GetString()); - Assert.Contains("--json", recovery.GetProperty("args").EnumerateArray().Select(static item => item.GetString())); + var args = recovery.GetProperty("args").EnumerateArray().Select(static item => item.GetString()).ToList(); + Assert.Equal("index", args[0]); + Assert.Equal("/repo", args[1]); + Assert.Contains("--json", args); + Assert.Contains("--quiet", args); + AssertOptionValue(args, "--db", resolvedDbPath); + AssertOptionValue(args, "--max-file-bytes", "4096"); + AssertOptionValue(args, "--max-symbols-per-file", "42"); + AssertOptionValue(args, "--follow-symlinks", "all"); } [Fact] @@ -573,6 +582,23 @@ public void RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled() } } + private static void AssertOptionValue(IReadOnlyList args, string option, string expectedValue) + { + var index = -1; + for (var i = 0; i < args.Count; i++) + { + if (string.Equals(args[i], option, StringComparison.Ordinal)) + { + index = i; + break; + } + } + + Assert.True(index >= 0, $"Expected option {option} in recovery command."); + Assert.True(index + 1 < args.Count, $"Expected value after option {option}."); + Assert.Equal(expectedValue, args[index + 1]); + } + private static string? ExtractStatus(string jsonLine) { try From 6adfe5c3320f0a27a0a01b68540f8e2c734500fe Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:07:53 +0900 Subject: [PATCH 07/11] Forward watch symbol-kind filters (#3568) --- src/CodeIndex/Cli/IndexWatchRunner.cs | 10 ++++++++++ tests/CodeIndex.Tests/IndexWatchRunnerTests.cs | 3 +++ 2 files changed, 13 insertions(+) diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 60787254e4..058d7b9488 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -229,6 +229,16 @@ private static List BuildSubRunArgs(IndexCommandOptions baseOptions, str args.Add("--follow-symlinks"); args.Add(baseOptions.SymlinkPolicy.ToString().ToLowerInvariant()); } + if (baseOptions.SymbolKindFilter.Include.Count > 0) + { + args.Add("--include-symbol-kind"); + args.Add(string.Join(",", baseOptions.SymbolKindFilter.Include)); + } + if (baseOptions.SymbolKindFilter.Exclude.Count > 0) + { + args.Add("--exclude-symbol-kind"); + args.Add(string.Join(",", baseOptions.SymbolKindFilter.Exclude)); + } return args; } diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index e142dd90ad..2e2da7b8d1 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -337,6 +337,7 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() MaxFileSizeBytes = 4096, MaxSymbolsPerFile = 42, SymlinkPolicy = FileIndexer.SymlinkPolicy.All, + SymbolKindFilter = SymbolKindFilter.Create(["class", "function"], ["test.method"], parseError: null), }; var method = typeof(IndexWatchRunner).GetMethod("EmitWatchOverflow", BindingFlags.NonPublic | BindingFlags.Static); Assert.NotNull(method); @@ -374,6 +375,8 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() AssertOptionValue(args, "--max-file-bytes", "4096"); AssertOptionValue(args, "--max-symbols-per-file", "42"); AssertOptionValue(args, "--follow-symlinks", "all"); + AssertOptionValue(args, "--include-symbol-kind", "class,function"); + AssertOptionValue(args, "--exclude-symbol-kind", "test.method"); } [Fact] From 2e497cebff623fb801ebbbef792c68d357410a66 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:24:02 +0900 Subject: [PATCH 08/11] Estimate dry-run rename purges (#3569) --- .../Cli/IndexCommandRunner.DryRun.cs | 76 ++++++++++++++++++- .../IndexCommandRunnerTests.cs | 70 +++++++++++++++++ 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index e65bea656a..6c771c313a 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -30,6 +30,11 @@ private static int RunDryRun( DryRunScanMetadata dryScanMetadata; var resolvedDbPath = DbPathResolver.NormalizeDbPath(DbPathResolver.ResolveForIndex(projectPath, options.DbPath, options.DataDir).DbPath); var dbSnapshot = ReadDryRunDbSnapshot(resolvedDbPath); + var normalizedProjectRoot = Path.GetFullPath(projectPath); + var normalizedPriorIndexedProjectRoot = string.IsNullOrWhiteSpace(dbSnapshot.IndexedProjectRoot) + ? null + : Path.GetFullPath(dbSnapshot.IndexedProjectRoot); + var projectRootWritten = PathsEqual(normalizedPriorIndexedProjectRoot, normalizedProjectRoot); var retainedRelativePaths = new HashSet(StringComparer.Ordinal); var projectedDeletePaths = new HashSet(StringComparer.Ordinal); var projectedPurgePaths = new HashSet(StringComparer.Ordinal); @@ -107,7 +112,17 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) unsupportedTotal++; if (dbSnapshot.Files.ContainsKey(relativePath)) + { projectedDeletePaths.Add(relativePath); + } + else if (!authoritativeFullScan && projectRootWritten && probe.Error == null) + { + AddProjectedPartialStalePurges( + projectedPurgePaths, + dbSnapshot, + projectPath, + relativePath); + } if (probe.Error != null) { @@ -120,6 +135,14 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) dryFileCount++; retainedRelativePaths.Add(relativePath); + if (!authoritativeFullScan && projectRootWritten) + { + AddProjectedPartialStalePurges( + projectedPurgePaths, + dbSnapshot, + projectPath, + relativePath); + } AddEstimatedUpdateMutation(estimatedTableMutations, dbSnapshot, relativePath); if (dryFileSamples.Count < DryRunFileSampleLimit) dryFileSamples.Add(relativePath); @@ -415,6 +438,32 @@ private static void AddProjectedFullScanPurges( } } + private static void AddProjectedPartialStalePurges( + HashSet projectedPurgePaths, + DryRunDbSnapshot dbSnapshot, + string projectPath, + string retainedRelativePath) + { + var retainedDirectory = GetDirectoryPath(retainedRelativePath); + var retainedStem = GetRelativeFileStem(retainedRelativePath); + if (retainedStem.Length == 0) + return; + + foreach (var relativePath in dbSnapshot.Files.Keys) + { + if (string.Equals(relativePath, retainedRelativePath, StringComparison.Ordinal) + || !string.Equals(GetDirectoryPath(relativePath), retainedDirectory, StringComparison.Ordinal) + || !string.Equals(GetRelativeFileStem(relativePath), retainedStem, StringComparison.Ordinal)) + { + continue; + } + + var absolutePath = Path.Combine(projectPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(LongPath.EnsureWindowsPrefix(absolutePath))) + projectedPurgePaths.Add(relativePath); + } + } + private static bool HasListedParentDirectory(string path, IReadOnlySet listedDirectories) => listedDirectories.Contains(GetDirectoryPath(path)); @@ -442,6 +491,15 @@ private static string GetDirectoryPath(string path) return separatorIndex >= 0 ? path[..separatorIndex] : string.Empty; } + private static string GetRelativeFileStem(string relativePath) + { + var normalized = relativePath.Replace('\\', '/'); + var slashIndex = normalized.LastIndexOf('/'); + var fileName = slashIndex < 0 ? normalized : normalized[(slashIndex + 1)..]; + var dotIndex = fileName.LastIndexOf('.'); + return dotIndex <= 0 ? fileName : fileName[..dotIndex]; + } + private static DryRunFileProbe ProbeDryRunFile(FileIndexer indexer, string absolutePath) { var indexability = FileIndexer.GetFileIndexability(absolutePath); @@ -526,6 +584,7 @@ private static DryRunDbSnapshot ReadDryRunDbSnapshot(string dbPath) if (!DryRunTableExists(connection, "files")) return DryRunDbSnapshot.Empty; + var indexedProjectRoot = DryRunReadMetaString(connection, DbContext.IndexedProjectRootMetaKey); var hasChunks = DryRunTableExists(connection, "chunks"); var hasSymbols = DryRunTableExists(connection, "symbols"); var hasSymbolReferences = DryRunTableExists(connection, "symbol_references"); @@ -555,7 +614,7 @@ FROM files f reader.GetInt64(5)); } - return new DryRunDbSnapshot(files); + return new DryRunDbSnapshot(files, indexedProjectRoot); } catch (SqliteException) { @@ -579,6 +638,17 @@ private static bool DryRunTableExists(SqliteConnection connection, string tableN return command.ExecuteScalar() != null; } + private static string? DryRunReadMetaString(SqliteConnection connection, string key) + { + if (!DryRunTableExists(connection, "codeindex_meta")) + return null; + + using var command = connection.CreateCommand(); + command.CommandText = "SELECT value FROM codeindex_meta WHERE key = @key LIMIT 1"; + command.Parameters.AddWithValue("@key", key); + return command.ExecuteScalar() as string; + } + private static int WriteDryRunInterrupted(IndexCommandOptions options, JsonSerializerOptions jsonOptions) => WriteCommandError( options.Json, jsonOptions, @@ -587,9 +657,9 @@ private static int WriteDryRunInterrupted(IndexCommandOptions options, JsonSeria "Rerun `cdidx index --dry-run` when you are ready to inspect the candidate files again.", CommandErrorCodes.Interrupted); - private sealed record DryRunDbSnapshot(IReadOnlyDictionary Files) + private sealed record DryRunDbSnapshot(IReadOnlyDictionary Files, string? IndexedProjectRoot) { - public static DryRunDbSnapshot Empty { get; } = new(new Dictionary(StringComparer.Ordinal)); + public static DryRunDbSnapshot Empty { get; } = new(new Dictionary(StringComparer.Ordinal), null); } private readonly record struct DryRunExistingFileRows( diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index e5ba7fbca0..f148f33f8c 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -8354,6 +8354,76 @@ public void Run_DryRun_WithFiles_ReportsProjectedUpdatesDeletesAndUnknowns() } } + [Fact] + public void Run_DryRun_WithFiles_ReportsSupportedExtensionRenamePurgeWithoutWriting() + { + var projectRoot = CreateTempProject(); + try + { + var oldPath = Path.Combine(projectRoot, "foo.py"); + var newPath = Path.Combine(projectRoot, "foo.md"); + File.WriteAllText(oldPath, "print('hello')\n"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--files", "foo.py", "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(1, CountRows(dbPath, "files")); + + File.Move(oldPath, newPath); + File.AppendAllText(newPath, "# Updated during rename\n"); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "foo.md", "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("files_total").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); + Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("files").GetInt64() >= 2); + Assert.Equal(1, CountRows(dbPath, "files")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_DryRun_WithFiles_ReportsUnsupportedExtensionRenamePurgeWithoutWriting() + { + var projectRoot = CreateTempProject(); + try + { + var oldPath = Path.Combine(projectRoot, "foo.py"); + var newPath = Path.Combine(projectRoot, "foo.bin"); + File.WriteAllText(oldPath, "print('hello')\n"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--files", "foo.py", "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(1, CountRows(dbPath, "files")); + + File.Move(oldPath, newPath); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "foo.bin", "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.Equal(0, json.GetProperty("files_total").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); + Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("files").GetInt64() >= 1); + Assert.Equal(1, CountRows(dbPath, "files")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_FullScan_ReportsProjectedPurgesWithoutWriting() { From c627c8038c7d2038808d7015db129aacceaa9b0e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:24:14 +0900 Subject: [PATCH 09/11] Forward watch parallelism to sub-runs (#3568) --- src/CodeIndex/Cli/IndexWatchRunner.cs | 5 +++++ tests/CodeIndex.Tests/IndexWatchRunnerTests.cs | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 058d7b9488..ff423d16e3 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -224,6 +224,11 @@ private static List BuildSubRunArgs(IndexCommandOptions baseOptions, str args.Add("--max-symbols-per-file"); args.Add(baseOptions.MaxSymbolsPerFile.ToString(CultureInfo.InvariantCulture)); } + if (baseOptions.Parallelism != IndexCommandRunner.DefaultIndexParallelism()) + { + args.Add("--parallelism"); + args.Add(baseOptions.Parallelism.ToString(CultureInfo.InvariantCulture)); + } if (baseOptions.SymlinkPolicy != FileIndexer.SymlinkPolicy.None) { args.Add("--follow-symlinks"); diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 2e2da7b8d1..df0c116def 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -328,6 +328,7 @@ public void InvokeSubRunAndEmit_HumanSubRunFailure_IncludesExitCode() [Fact] public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() { + var parallelism = IndexCommandRunner.DefaultIndexParallelism() == 1 ? 2 : 1; var options = new IndexCommandOptions { ProjectPath = "/repo", @@ -336,6 +337,7 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() Watch = true, MaxFileSizeBytes = 4096, MaxSymbolsPerFile = 42, + Parallelism = parallelism, SymlinkPolicy = FileIndexer.SymlinkPolicy.All, SymbolKindFilter = SymbolKindFilter.Create(["class", "function"], ["test.method"], parseError: null), }; @@ -374,6 +376,7 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() AssertOptionValue(args, "--db", resolvedDbPath); AssertOptionValue(args, "--max-file-bytes", "4096"); AssertOptionValue(args, "--max-symbols-per-file", "42"); + AssertOptionValue(args, "--parallelism", parallelism.ToString(System.Globalization.CultureInfo.InvariantCulture)); AssertOptionValue(args, "--follow-symlinks", "all"); AssertOptionValue(args, "--include-symbol-kind", "class,function"); AssertOptionValue(args, "--exclude-symbol-kind", "test.method"); From 0d071af9619641f8fd57ec226773c3197e415c51 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:46:52 +0900 Subject: [PATCH 10/11] Estimate dry-run checksum rename purges (#3569) --- .../Cli/IndexCommandRunner.DryRun.cs | 55 ++++++++++++++++--- .../IndexCommandRunnerTests.cs | 35 ++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index 6c771c313a..bffdf71886 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -135,13 +135,22 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) dryFileCount++; retainedRelativePaths.Add(relativePath); - if (!authoritativeFullScan && projectRootWritten) + if (!authoritativeFullScan) { - AddProjectedPartialStalePurges( + AddProjectedPartialChecksumPurges( projectedPurgePaths, dbSnapshot, projectPath, - relativePath); + relativePath, + probe.Checksum); + if (projectRootWritten) + { + AddProjectedPartialStalePurges( + projectedPurgePaths, + dbSnapshot, + projectPath, + relativePath); + } } AddEstimatedUpdateMutation(estimatedTableMutations, dbSnapshot, relativePath); if (dryFileSamples.Count < DryRunFileSampleLimit) @@ -438,6 +447,30 @@ private static void AddProjectedFullScanPurges( } } + private static void AddProjectedPartialChecksumPurges( + HashSet projectedPurgePaths, + DryRunDbSnapshot dbSnapshot, + string projectPath, + string retainedRelativePath, + string? checksum) + { + if (string.IsNullOrEmpty(checksum)) + return; + + foreach (var (relativePath, rows) in dbSnapshot.Files) + { + if (string.Equals(relativePath, retainedRelativePath, StringComparison.Ordinal) + || !string.Equals(rows.Checksum, checksum, StringComparison.Ordinal)) + { + continue; + } + + var absolutePath = Path.Combine(projectPath, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(LongPath.EnsureWindowsPrefix(absolutePath))) + projectedPurgePaths.Add(relativePath); + } + } + private static void AddProjectedPartialStalePurges( HashSet projectedPurgePaths, DryRunDbSnapshot dbSnapshot, @@ -519,7 +552,7 @@ private static DryRunFileProbe ProbeDryRunFile(FileIndexer indexer, string absol try { var (record, _, _, warning) = indexer.BuildRecordWithRawBytes(absolutePath); - return new DryRunFileProbe(true, record.Lang ?? "unknown", warning, Unsupported: false, UnknownExtension: false); + return new DryRunFileProbe(true, record.Lang ?? "unknown", record.Checksum, warning, Unsupported: false, UnknownExtension: false); } catch (Exception ex) { @@ -594,6 +627,7 @@ private static DryRunDbSnapshot ReadDryRunDbSnapshot(string dbPath) using var command = connection.CreateCommand(); command.CommandText = $""" SELECT f.path, + f.checksum, {(hasChunks ? "(SELECT COUNT(*) FROM chunks c WHERE c.file_id = f.id)" : "0")} AS chunks_count, {(hasSymbols ? "(SELECT COUNT(*) FROM symbols s WHERE s.file_id = f.id)" : "0")} AS symbols_count, {(hasSymbolReferences ? "(SELECT COUNT(*) FROM symbol_references r WHERE r.file_id = f.id)" : "0")} AS symbol_references_count, @@ -607,11 +641,12 @@ FROM files f while (reader.Read()) { files[reader.GetString(0)] = new DryRunExistingFileRows( - reader.GetInt64(1), + reader.IsDBNull(1) ? null : reader.GetString(1), reader.GetInt64(2), reader.GetInt64(3), reader.GetInt64(4), - reader.GetInt64(5)); + reader.GetInt64(5), + reader.GetInt64(6)); } return new DryRunDbSnapshot(files, indexedProjectRoot); @@ -663,6 +698,7 @@ private sealed record DryRunDbSnapshot(IReadOnlyDictionary new(false, string.Empty, message, Unsupported: false, UnknownExtension: false); - public static DryRunFileProbe FromUnsupported() => new(false, string.Empty, null, Unsupported: true, UnknownExtension: false); - public static DryRunFileProbe FromUnknownExtension() => new(false, string.Empty, null, Unsupported: false, UnknownExtension: true); + public static DryRunFileProbe FromError(string message) => new(false, string.Empty, null, message, Unsupported: false, UnknownExtension: false); + public static DryRunFileProbe FromUnsupported() => new(false, string.Empty, null, null, Unsupported: true, UnknownExtension: false); + public static DryRunFileProbe FromUnknownExtension() => new(false, string.Empty, null, null, Unsupported: false, UnknownExtension: true); } } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index f148f33f8c..f5a4ac9e69 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -8354,6 +8354,41 @@ public void Run_DryRun_WithFiles_ReportsProjectedUpdatesDeletesAndUnknowns() } } + [Fact] + public void Run_DryRun_WithFiles_ReportsChecksumRenamePurgeWithoutWriting() + { + var projectRoot = CreateTempProject(); + try + { + var oldPath = Path.Combine(projectRoot, "old.py"); + var newPath = Path.Combine(projectRoot, "new.py"); + File.WriteAllText(oldPath, "print('hello')\n"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--files", "old.py", "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(1, CountRows(dbPath, "files")); + + File.Move(oldPath, newPath); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "new.py", "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("files_total").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_purges").GetInt32()); + Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("files").GetInt64() >= 2); + Assert.Equal(1, CountRows(dbPath, "files")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_WithFiles_ReportsSupportedExtensionRenamePurgeWithoutWriting() { From f23f8628f2aa4dd86b6e3089526993576636fa50 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:58:10 +0900 Subject: [PATCH 11/11] Normalize dry-run estimate DB paths (#3569) --- .../Cli/IndexCommandRunner.DryRun.cs | 32 ++++++++++--------- .../IndexCommandRunnerTests.cs | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index bffdf71886..74d00b0a44 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -93,13 +93,14 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) foreach (var f in dryCandidates) { - var relativePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, f)); + var displayRelativePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectPath, f)); + var dbRelativePath = FileIndexer.NormalizeIndexPath(displayRelativePath); var pathFilter = dryIndexer.EvaluatePathFilter(f); RecordDryRunScanErrors(pathFilter.Errors); if (pathFilter.ShouldSkip) { - if (pathFilter.ShouldDeleteExisting && dbSnapshot.Files.ContainsKey(relativePath)) - projectedDeletePaths.Add(relativePath); + if (pathFilter.ShouldDeleteExisting && dbSnapshot.Files.ContainsKey(dbRelativePath)) + projectedDeletePaths.Add(dbRelativePath); continue; } @@ -111,9 +112,9 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) else if (probe.Unsupported) unsupportedTotal++; - if (dbSnapshot.Files.ContainsKey(relativePath)) + if (dbSnapshot.Files.ContainsKey(dbRelativePath)) { - projectedDeletePaths.Add(relativePath); + projectedDeletePaths.Add(dbRelativePath); } else if (!authoritativeFullScan && projectRootWritten && probe.Error == null) { @@ -121,27 +122,27 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) projectedPurgePaths, dbSnapshot, projectPath, - relativePath); + dbRelativePath); } if (probe.Error != null) { - RecordDryRunError(relativePath, probe.Error); + RecordDryRunError(displayRelativePath, probe.Error); if (!options.Json && !options.Quiet) - ConsoleUi.PrintWarning($"{relativePath}: {probe.Error}"); + ConsoleUi.PrintWarning($"{displayRelativePath}: {probe.Error}"); } continue; } dryFileCount++; - retainedRelativePaths.Add(relativePath); + retainedRelativePaths.Add(dbRelativePath); if (!authoritativeFullScan) { AddProjectedPartialChecksumPurges( projectedPurgePaths, dbSnapshot, projectPath, - relativePath, + dbRelativePath, probe.Checksum); if (projectRootWritten) { @@ -149,19 +150,20 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) projectedPurgePaths, dbSnapshot, projectPath, - relativePath); + dbRelativePath); } } - AddEstimatedUpdateMutation(estimatedTableMutations, dbSnapshot, relativePath); + AddEstimatedUpdateMutation(estimatedTableMutations, dbSnapshot, dbRelativePath); if (dryFileSamples.Count < DryRunFileSampleLimit) - dryFileSamples.Add(relativePath); + dryFileSamples.Add(displayRelativePath); langCounts[probe.Language] = langCounts.GetValueOrDefault(probe.Language) + 1; } foreach (var relativePath in dryDeleteCandidates) { - if (dbSnapshot.Files.ContainsKey(relativePath)) - projectedDeletePaths.Add(relativePath); + var dbRelativePath = FileIndexer.NormalizeIndexPath(relativePath); + if (dbSnapshot.Files.ContainsKey(dbRelativePath)) + projectedDeletePaths.Add(dbRelativePath); } if (authoritativeFullScan && dbSnapshot.Files.Count > 0) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index f5a4ac9e69..0e404583a1 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -8354,6 +8354,38 @@ public void Run_DryRun_WithFiles_ReportsProjectedUpdatesDeletesAndUnknowns() } } + [Fact] + public void Run_DryRun_WithFiles_NormalizesUnicodeDbPathForEstimates() + { + var projectRoot = CreateTempProject(); + try + { + var nfdFileName = "cafe\u0301.py"; + File.WriteAllText(Path.Combine(projectRoot, nfdFileName), "print('hello')\n"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--files", nfdFileName, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + Assert.Equal(1, CountRows(dbPath, "files")); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", nfdFileName, "--dry-run", "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("dry_run", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("files_total").GetInt32()); + Assert.Equal(1, json.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_deletes").GetInt32()); + Assert.Equal(0, json.GetProperty("projected_file_purges").GetInt32()); + Assert.True(json.GetProperty("estimated_table_mutations").GetProperty("chunks").GetInt64() > 0); + Assert.Equal(1, CountRows(dbPath, "files")); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_WithFiles_ReportsChecksumRenamePurgeWithoutWriting() {