From 764722b6ee72d9be31a541a7eef71c7ccd1a3e8a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:43:24 +0900 Subject: [PATCH 1/4] Fix watch pending batch cap (#2903) --- changelog.d/unreleased/2903.fixed.md | 16 +++++++ src/CodeIndex/Cli/IndexWatchRunner.cs | 46 ++++++++++++++++--- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 24 +++++++++- 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/2903.fixed.md diff --git a/changelog.d/unreleased/2903.fixed.md b/changelog.d/unreleased/2903.fixed.md new file mode 100644 index 0000000000..a19901f936 --- /dev/null +++ b/changelog.d/unreleased/2903.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 2903 +affected: + - src/CodeIndex/Cli/IndexWatchRunner.cs + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +--- + +## English + +- **`index --watch` now caps pending path batches (#2903)** — watch mode collapses very large pending file-event sets into a full rescan request instead of retaining an unbounded list of changed paths. + +## 日本語 + +- **`index --watch` の pending path batch に上限を設けました (#2903)** — watch mode は大量のファイルイベントを個別 path の無制限リストとして保持せず、full rescan 要求へ畳み込むようになりました。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 2189e33a4b..e5b0b69f72 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -373,6 +373,8 @@ private static void EmitWatchStopped(IndexCommandOptions baseOptions) /// internal sealed class FileChangeBatcher { + internal const int DefaultMaxPendingPaths = 4096; + private readonly object _gate = new(); private readonly HashSet _pending; private DateTime _lastEventUtc = DateTime.MinValue; @@ -380,11 +382,20 @@ internal sealed class FileChangeBatcher private string? _overflowReason; private readonly TimeSpan _debounce; private readonly Func _clock; + private readonly int _maxPendingPaths; - public FileChangeBatcher(TimeSpan debounce, Func? clock = null, bool ignoreCase = true) + public FileChangeBatcher( + TimeSpan debounce, + Func? clock = null, + bool ignoreCase = true, + int maxPendingPaths = DefaultMaxPendingPaths) { + if (maxPendingPaths <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPendingPaths), "Maximum pending path count must be positive."); + _debounce = debounce; _clock = clock ?? (() => DateTime.UtcNow); + _maxPendingPaths = maxPendingPaths; // On case-sensitive filesystems (Linux ext4), `foo.py` and `Foo.py` are distinct files, // so coalescing them via OrdinalIgnoreCase would drop one rename leg and leave the // renamed-to file unindexed. The watch loop passes the filesystem's case sensitivity in. @@ -397,7 +408,24 @@ public void Add(string path) { lock (_gate) { - _pending.Add(path); + if (_overflowRequested) + { + _lastEventUtc = _clock(); + return; + } + + if (!_pending.Contains(path)) + { + if (_pending.Count >= _maxPendingPaths) + { + RequestFullRescanLocked( + $"pending path limit exceeded ({_maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)} paths)"); + return; + } + + _pending.Add(path); + } + _lastEventUtc = _clock(); } } @@ -406,10 +434,7 @@ public void RequestFullRescan(string? reason = null) { lock (_gate) { - _overflowRequested = true; - if (!string.IsNullOrEmpty(reason)) - _overflowReason = reason; - _lastEventUtc = _clock(); + RequestFullRescanLocked(reason); } } @@ -445,4 +470,13 @@ public bool TryDrain(out IReadOnlyList batch, out bool fullRescan, out s return true; } } + + private void RequestFullRescanLocked(string? reason) + { + _pending.Clear(); + _overflowRequested = true; + if (!string.IsNullOrEmpty(reason)) + _overflowReason = reason; + _lastEventUtc = _clock(); + } } diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index ae36651f3f..8b27969f39 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -94,7 +94,29 @@ public void FileChangeBatcher_RequestFullRescan_DrainsOverflowAndReason() Assert.True(batcher.TryDrain(out var batch, out var rescan, out var reason)); Assert.True(rescan); Assert.Equal("buffer overflowed", reason); - Assert.Single(batch); + Assert.Empty(batch); + } + + [Fact] + public void FileChangeBatcher_Add_WhenPendingPathLimitExceeded_CollapsesToFullRescan() + { + var clock = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var batcher = new FileChangeBatcher( + TimeSpan.FromMilliseconds(100), + () => clock, + maxPendingPaths: 2); + + batcher.Add("/repo/a.py"); + batcher.Add("/repo/b.py"); + batcher.Add("/repo/c.py"); + batcher.Add("/repo/d.py"); + + clock = clock.AddMilliseconds(200); + Assert.True(batcher.TryDrain(out var batch, out var rescan, out var reason)); + Assert.True(rescan); + Assert.Empty(batch); + Assert.Contains("pending path limit exceeded", reason); + Assert.Contains("2", reason); } [Fact] From f3c9f25c028f603e9f9aa061eb1bcbc7bebf2b95 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:57:16 +0900 Subject: [PATCH 2/4] Propagate watch sub-run failures (#2902) --- changelog.d/unreleased/2902.fixed.md | 17 +++ src/CodeIndex/Cli/IndexWatchRunner.cs | 58 ++++++--- src/CodeIndex/Cli/JsonOutputContracts.cs | 1 + .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 111 +++++++++++++++++- 4 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 changelog.d/unreleased/2902.fixed.md diff --git a/changelog.d/unreleased/2902.fixed.md b/changelog.d/unreleased/2902.fixed.md new file mode 100644 index 0000000000..6e56eb9501 --- /dev/null +++ b/changelog.d/unreleased/2902.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2902 +affected: + - src/CodeIndex/Cli/IndexWatchRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +--- + +## English + +- **`index --watch` now reports failed sub-runs (#2902)** — watch batch events and human summaries include the sub-run exit code, and failed sub-runs are surfaced as failed watch batches instead of normal updates. + +## 日本語 + +- **`index --watch` が sub-run failure を報告するようになりました (#2902)** — watch batch event と human summary に sub-run exit code を含め、失敗した sub-run を通常更新ではなく failed batch として表示します。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index e5b0b69f72..9e9f7b39ed 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -54,6 +54,7 @@ internal static int RunCore( var ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(projectRoot) ?? Path.GetFullPath(projectRoot); var fileIndexer = new FileIndexer(projectRoot, ignoreCase, ignoreRuleRoot); + var watchExitCode = CommandExitCodes.Success; FileSystemWatcher? watcher = null; try @@ -133,14 +134,14 @@ void Enqueue(string fullPath) if (fullRescan) { EmitWatchOverflow(baseOptions, overflowReason); - RunFullRescan(baseOptions, jsonOptions); + RecordSubRunExitCode(ref watchExitCode, RunFullRescan(baseOptions, jsonOptions)); continue; } if (batch.Count == 0) continue; - RunPartialUpdate(baseOptions, jsonOptions, batch); + RecordSubRunExitCode(ref watchExitCode, RunPartialUpdate(baseOptions, jsonOptions, batch)); } } finally @@ -153,10 +154,10 @@ void Enqueue(string fullPath) } EmitWatchStopped(baseOptions); - return CommandExitCodes.Success; + return watchExitCode; } - private static void RunPartialUpdate( + private static int RunPartialUpdate( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions, IReadOnlyList changedPaths) @@ -167,10 +168,10 @@ private static void RunPartialUpdate( foreach (var path in changedPaths) args.Add(path); - InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", changedPaths.Count); + return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", changedPaths.Count); } - private static void RunFullRescan( + private static int RunFullRescan( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions) { @@ -178,7 +179,13 @@ private static void RunFullRescan( var args = BuildSubRunArgs(baseOptions); // No --files: this is a default incremental full scan. // --files を付けない: 通常のインクリメンタル全件スキャン。 - InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null); + return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null); + } + + private static void RecordSubRunExitCode(ref int watchExitCode, int subRunExitCode) + { + if (subRunExitCode != CommandExitCodes.Success) + watchExitCode = subRunExitCode; } private static List BuildSubRunArgs(IndexCommandOptions baseOptions) @@ -214,7 +221,7 @@ private static List BuildSubRunArgs(IndexCommandOptions baseOptions) return args; } - private static void InvokeSubRunAndEmit( + private static int InvokeSubRunAndEmit( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions, List args, @@ -223,12 +230,13 @@ private static void InvokeSubRunAndEmit( int? batchSize) { string capturedJson; + int subRunExitCode; var previousOut = Console.Out; using var captureWriter = new StringWriter(); Console.SetOut(captureWriter); try { - IndexCommandRunner.Run(args.ToArray(), jsonOptions); + subRunExitCode = IndexCommandRunner.Run(args.ToArray(), jsonOptions); } finally { @@ -236,6 +244,10 @@ private static void InvokeSubRunAndEmit( } stopwatch.Stop(); capturedJson = captureWriter.ToString(); + var eventStatus = subRunExitCode == CommandExitCodes.Success ? status : "failed"; + var failureReason = subRunExitCode == CommandExitCodes.Success + ? null + : $"{status} sub-run exited with code {subRunExitCode.ToString(CultureInfo.InvariantCulture)}"; if (baseOptions.Json) { @@ -244,9 +256,11 @@ private static void InvokeSubRunAndEmit( // watch バッチであることを示すヘッダ行を先頭に流し、その後にサブ実行 JSON を出す。 Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult { - Status = status, + Status = eventStatus, BatchSize = batchSize, ElapsedMs = stopwatch.ElapsedMilliseconds, + ExitCode = subRunExitCode, + Reason = failureReason, }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); var trimmed = capturedJson.TrimEnd('\r', '\n'); @@ -255,14 +269,21 @@ private static void InvokeSubRunAndEmit( } else { - var human = FormatHumanSummary(status, batchSize, stopwatch.ElapsedMilliseconds, capturedJson); + var human = FormatHumanSummary(eventStatus, batchSize, stopwatch.ElapsedMilliseconds, capturedJson, subRunExitCode); Console.Error.WriteLine(human); } + + return subRunExitCode; } - private static string FormatHumanSummary(string status, int? batchSize, long elapsedMs, string subRunJson) + private static string FormatHumanSummary(string status, int? batchSize, long elapsedMs, string subRunJson, int exitCode) { - var prefix = status == "rescanned" ? "[watch] rescanned" : "[watch] updated"; + var prefix = status switch + { + "rescanned" => "[watch] rescanned", + "failed" => "[watch] failed", + _ => "[watch] updated", + }; var batchLabel = batchSize is int n ? $" {ConsoleUi.Counted(n, "path", format: "N0")}" : string.Empty; @@ -270,7 +291,10 @@ private static string FormatHumanSummary(string status, int? batchSize, long ela // Best-effort parse of the sub-run JSON to surface updated/removed/errors counts. // The summary is informational; a parse failure must not break the watch loop. // サブ実行 JSON から件数を best-effort で抽出。失敗してもループは続行する。 - string detail = string.Empty; + var details = new List + { + $"exit code {exitCode.ToString(CultureInfo.InvariantCulture)}", + }; try { var trimmed = subRunJson.TrimEnd('\r', '\n'); @@ -284,15 +308,17 @@ private static string FormatHumanSummary(string status, int? batchSize, long ela 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; - detail = $" (updated {updated}, removed {removed}, errors {errors})"; + details.Add($"updated {updated}"); + details.Add($"removed {removed}"); + details.Add($"errors {errors}"); } } } catch (JsonException) { - detail = string.Empty; } + var detail = details.Count > 0 ? $" ({string.Join(", ", details)})" : string.Empty; return $"{prefix}{batchLabel}{detail} in {elapsedMs.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} ms"; } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 540fcd3310..a8536a5e8e 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -181,6 +181,7 @@ internal sealed class IndexWatchEventJsonResult public int? DebounceMs { get; init; } public int? BatchSize { get; init; } public long? ElapsedMs { get; init; } + public int? ExitCode { get; init; } public string? Reason { get; init; } } diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 8b27969f39..c6bedbe910 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -1,5 +1,6 @@ -using System.Text.Json; +using System.Diagnostics; using System.Reflection; +using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; using Microsoft.Data.Sqlite; @@ -199,6 +200,114 @@ public void BuildSubRunArgs_MaxSymbolsPerFile_PreservesWatchOverride() Assert.Equal("42", args[flagIndex + 1]); } + [Fact] + public void InvokeSubRunAndEmit_JsonSubRunFailure_EmitsFailedStatusAndExitCode() + { + var projectRoot = CreateTempProject(); + try + { + var options = new IndexCommandOptions + { + ProjectPath = projectRoot, + Json = true, + Watch = true, + }; + var method = typeof(IndexWatchRunner).GetMethod("InvokeSubRunAndEmit", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var args = new List { projectRoot, "--json", "--quiet", "--unknown-watch-test-option" }; + string capturedOut; + int exitCode; + + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + var originalErr = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + Console.SetOut(stdout); + Console.SetError(stderr); + try + { + exitCode = Assert.IsType(method.Invoke( + null, + [options, _jsonOptions, args, Stopwatch.StartNew(), "updated", 3])); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalErr); + } + capturedOut = stdout.ToString(); + } + + Assert.NotEqual(CommandExitCodes.Success, exitCode); + 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(3, doc.RootElement.GetProperty("batch_size").GetInt32()); + Assert.Equal(exitCode, doc.RootElement.GetProperty("exit_code").GetInt32()); + var reason = doc.RootElement.GetProperty("reason").GetString(); + Assert.NotNull(reason); + Assert.Contains("updated sub-run exited with code", reason); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void InvokeSubRunAndEmit_HumanSubRunFailure_IncludesExitCode() + { + var projectRoot = CreateTempProject(); + try + { + var options = new IndexCommandOptions + { + ProjectPath = projectRoot, + Json = false, + Watch = true, + }; + var method = typeof(IndexWatchRunner).GetMethod("InvokeSubRunAndEmit", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(method); + + var args = new List { projectRoot, "--json", "--quiet", "--unknown-watch-test-option" }; + string capturedErr; + int exitCode; + + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + var originalErr = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + Console.SetOut(stdout); + Console.SetError(stderr); + try + { + exitCode = Assert.IsType(method.Invoke( + null, + [options, _jsonOptions, args, Stopwatch.StartNew(), "updated", 3])); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalErr); + } + capturedErr = stderr.ToString(); + } + + Assert.NotEqual(CommandExitCodes.Success, exitCode); + Assert.Contains("[watch] failed", capturedErr); + Assert.Contains($"exit code {exitCode}", capturedErr); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void RunCore_CancellationToken_StopsImmediately() { From a7f25a45dd01126cee8a0c69cc2d8270e6a31641 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:13:59 +0900 Subject: [PATCH 3/4] Document watch failure events (#2902) --- USER_GUIDE.md | 4 ++-- changelog.d/unreleased/2902.fixed.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 225521bee0..8561336fc9 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -724,7 +724,7 @@ small repositories, and minutes or longer on very large monorepos with around By default, `cdidx index` stores the database in `/.cdidx/codeindex.db`, even if you run the command from another directory. -`--watch` keeps the process alive after the initial scan and rebuilds the index incrementally as files are created, edited, renamed, or deleted. It uses `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows), debounces bursts of events (`--debounce `, default 500 ms) into a single `--files` update, releases the per-DB index lock between batches so other `cdidx` commands can still query, and falls back to a full incremental rescan if the watcher buffer overflows. With `--json` it streams `status: "watching" / "updated" / "rescanned" / "overflow" / "stopped"` lifecycle events to stdout; otherwise it writes `[watch] …` summaries to stderr. Stop the loop with Ctrl+C (or SIGTERM); the final exit code is `0` for a clean stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. +`--watch` keeps the process alive after the initial scan and rebuilds the index incrementally as files are created, edited, renamed, or deleted. It uses `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows), debounces bursts of events (`--debounce `, default 500 ms) into a single `--files` update, releases the per-DB index lock between batches so other `cdidx` commands can still query, and falls back to a full incremental rescan if the watcher buffer overflows. With `--json` it streams `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout, and update/rescan events include `exit_code`; otherwise it writes `[watch] …` summaries with exit codes to stderr. Stop the loop with Ctrl+C (or SIGTERM); the final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. Indexing keeps the built-in skip lists (`node_modules`, `bin`, `obj`, lockfiles, etc.) and also honors user `.gitignore` plus optional `.cdidxignore` rules across full scans, `--files`, and `--commits` updates. A project-root `.codeindex/.cdidxignore` is also loaded as a workspace-scoped ignore file, which lets multi-workspace manifests keep local cdidx-only ignore rules out of the repository root. Ignore files are read as UTF-8, so non-ASCII patterns behave the same across platforms. On Windows, paths marked with the Hidden or System attribute are skipped before language detection so broad scans do not enter OS-owned caches such as `System Volume Information` or `$Recycle.Bin`; clear those attributes before indexing project-owned source files because ignore rules only exclude additional paths. When the project is inside Git, ignore matching follows the repository's `core.ignorecase` setting, even when the indexed project path is a subdirectory inside that repo; repo-root and other ancestor `.gitignore` files above that subdirectory still apply, and `--commits` resolves changed paths from the repository root before narrowing them back to the indexed project root. Nested directories that contain their own `.git` directory or gitfile are treated as repository boundaries and skipped by default. Indexed file paths are stored in Unicode NFC form so composed and decomposed path spellings match across platforms. `**` only gets Git-style special handling in the documented path forms rather than as an unrestricted cross-directory wildcard. If an update refresh includes ignore-file changes, cdidx automatically falls back to a full scan so newly ignored files are purged safely. Invalid ignore lines are skipped with a warning instead of aborting the whole run, while unreadable ignore files fail closed for that directory scope so cdidx does not index with incomplete rules. @@ -2842,7 +2842,7 @@ interactive terminal では spinner と progress bar が動き続けます。待 `cdidx index` は、別ディレクトリから実行しても、デフォルトでは `/.cdidx/codeindex.db` にDBを保存します。 -`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce `(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合は変更を黙って捨てる代わりにフル差分再走査へフォールバックします。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "stopped"` のライフサイクルイベントを stdout に流し、そうでなければ `[watch] …` の要約を stderr に出力します。Ctrl+C(または SIGTERM)で正常に停止し、終了コードは正常停止で `0` です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 +`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce `(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合は変更を黙って捨てる代わりにフル差分再走査へフォールバックします。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流し、update/rescan event には `exit_code` を含めます。そうでなければ exit code 付きの `[watch] …` 要約を stderr に出力します。Ctrl+C(または SIGTERM)で停止し、すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 デフォルト出力: diff --git a/changelog.d/unreleased/2902.fixed.md b/changelog.d/unreleased/2902.fixed.md index 6e56eb9501..fc0be12930 100644 --- a/changelog.d/unreleased/2902.fixed.md +++ b/changelog.d/unreleased/2902.fixed.md @@ -3,6 +3,7 @@ category: fixed issues: - 2902 affected: + - USER_GUIDE.md - src/CodeIndex/Cli/IndexWatchRunner.cs - src/CodeIndex/Cli/JsonOutputContracts.cs - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs From fd5ef5c6aaa03da5f30fba5bfaf5884a04af5bd0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:16:12 +0900 Subject: [PATCH 4/4] Document watch pending cap fallback (#2903) --- USER_GUIDE.md | 4 ++-- changelog.d/unreleased/2903.fixed.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8561336fc9..25b1cf7879 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -724,7 +724,7 @@ small repositories, and minutes or longer on very large monorepos with around By default, `cdidx index` stores the database in `/.cdidx/codeindex.db`, even if you run the command from another directory. -`--watch` keeps the process alive after the initial scan and rebuilds the index incrementally as files are created, edited, renamed, or deleted. It uses `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows), debounces bursts of events (`--debounce `, default 500 ms) into a single `--files` update, releases the per-DB index lock between batches so other `cdidx` commands can still query, and falls back to a full incremental rescan if the watcher buffer overflows. With `--json` it streams `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout, and update/rescan events include `exit_code`; otherwise it writes `[watch] …` summaries with exit codes to stderr. Stop the loop with Ctrl+C (or SIGTERM); the final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. +`--watch` keeps the process alive after the initial scan and rebuilds the index incrementally as files are created, edited, renamed, or deleted. It uses `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows), debounces bursts of events (`--debounce `, default 500 ms) into a single `--files` update, releases the per-DB index lock between batches so other `cdidx` commands can still query, and falls back to a full incremental rescan if the watcher buffer overflows or a pending path batch reaches its safety cap. With `--json` it streams `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout, and update/rescan events include `exit_code`; otherwise it writes `[watch] …` summaries with exit codes to stderr. Stop the loop with Ctrl+C (or SIGTERM); the final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. Indexing keeps the built-in skip lists (`node_modules`, `bin`, `obj`, lockfiles, etc.) and also honors user `.gitignore` plus optional `.cdidxignore` rules across full scans, `--files`, and `--commits` updates. A project-root `.codeindex/.cdidxignore` is also loaded as a workspace-scoped ignore file, which lets multi-workspace manifests keep local cdidx-only ignore rules out of the repository root. Ignore files are read as UTF-8, so non-ASCII patterns behave the same across platforms. On Windows, paths marked with the Hidden or System attribute are skipped before language detection so broad scans do not enter OS-owned caches such as `System Volume Information` or `$Recycle.Bin`; clear those attributes before indexing project-owned source files because ignore rules only exclude additional paths. When the project is inside Git, ignore matching follows the repository's `core.ignorecase` setting, even when the indexed project path is a subdirectory inside that repo; repo-root and other ancestor `.gitignore` files above that subdirectory still apply, and `--commits` resolves changed paths from the repository root before narrowing them back to the indexed project root. Nested directories that contain their own `.git` directory or gitfile are treated as repository boundaries and skipped by default. Indexed file paths are stored in Unicode NFC form so composed and decomposed path spellings match across platforms. `**` only gets Git-style special handling in the documented path forms rather than as an unrestricted cross-directory wildcard. If an update refresh includes ignore-file changes, cdidx automatically falls back to a full scan so newly ignored files are purged safely. Invalid ignore lines are skipped with a warning instead of aborting the whole run, while unreadable ignore files fail closed for that directory scope so cdidx does not index with incomplete rules. @@ -2842,7 +2842,7 @@ interactive terminal では spinner と progress bar が動き続けます。待 `cdidx index` は、別ディレクトリから実行しても、デフォルトでは `/.cdidx/codeindex.db` にDBを保存します。 -`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce `(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合は変更を黙って捨てる代わりにフル差分再走査へフォールバックします。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流し、update/rescan event には `exit_code` を含めます。そうでなければ exit code 付きの `[watch] …` 要約を stderr に出力します。Ctrl+C(または SIGTERM)で停止し、すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 +`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce `(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合や pending path batch が安全上限に達した場合は、変更を黙って捨てる代わりにフル差分再走査へフォールバックします。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流し、update/rescan event には `exit_code` を含めます。そうでなければ exit code 付きの `[watch] …` 要約を stderr に出力します。Ctrl+C(または SIGTERM)で停止し、すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 デフォルト出力: diff --git a/changelog.d/unreleased/2903.fixed.md b/changelog.d/unreleased/2903.fixed.md index a19901f936..463e5691c9 100644 --- a/changelog.d/unreleased/2903.fixed.md +++ b/changelog.d/unreleased/2903.fixed.md @@ -3,6 +3,7 @@ category: fixed issues: - 2903 affected: + - USER_GUIDE.md - src/CodeIndex/Cli/IndexWatchRunner.cs - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs ---