From 4b56d38cef8b01ca6d762a747c44df31e133a0c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 22 Aug 2026 12:58:45 +0900 Subject: [PATCH 1/4] Fix polling directory symlink tracking (#5124) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5124.fixed.md | 18 +++ .../Cli/IndexWatchRunner.Watchers.cs | 17 ++- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 130 ++++++++++++++++++ 5 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/5124.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index a72ed269d..dd7c744d3 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -531,7 +531,7 @@ scans. | Malformed rule | Reports a scan error, skips that line, and continues the run. | | Symlink modes | `none` is the default. `internal` follows targets under the workspace root; `all` follows every resolvable target. | | Target identity | Discovery, dry-run, C# preflight, and content loading share one resolved identity. Stable allowed external targets are indexed; links retargeted after preflight are rejected as source drift. | -| Watch symlink reconciliation | Under `--watch --follow-symlinks internal`, an event for a lexical in-workspace symlink that now resolves outside the workspace remains a reconciliation input. The scoped update removes any stale indexed row without opening or indexing the disallowed target. | +| Watch symlink reconciliation | Under `--watch --follow-symlinks internal`, an event for a lexical in-workspace symlink that now resolves outside the workspace remains a reconciliation input. The scoped update removes any stale indexed row without opening or indexing the disallowed target. The polling backend traverses allowed directory links under `internal` / `all`, preserves their lexical paths, and deduplicates resolved directory identities to bound cycles and aliases. | | Explicit-file preflight | Explicit `--files` updates retain each directly supplied token and its normalization provenance, classify that complete list before any database write, and atomically return `UsageError` when a token is invalid, is a directory, or resolves to a duplicate canonical target. A bare `--files` is rejected before artifacts instead of becoming a full scan. `--project` expansion remains derived scope input in `UpdateFiles` and is not reclassified as a direct `--files` selection. Real and dry-run modes expose the same `rejected_paths` entries (`input_index`, support-safe `path`, stable `reason`) and bounded count/truncation metadata for outside-root, symlink-escaping or symlink-policy-disallowed, nonexistent, filtered, unsupported, and duplicate selections. Canonical duplicate checks use each resolved target's filesystem casing rather than the workspace-wide policy, and `none` mode detects actual symlink/reparse segments instead of treating native spelling normalization as a link. Windows 8.3 ancestor aliases canonicalize to the spelling already established in indexed-path membership (#5122). Indexed missing, filtered, or unsupported paths remain valid cleanup targets; indexed control files that become directories, FIFOs, or policy-disallowed symlinks remain tombstones and are classified without opening or following the object. Existing relevant ancestor ignore files remain valid reconciliation controls. In-workspace extractor/configuration inputs, including missing or deleted inputs used as reconciliation signals, also remain valid; generated code remains a valid explicit target. TypeScript path-alias configuration reads carry the active `none` / `internal` / `all` policy into both in-process and worker extraction, resolve only targets permitted by that policy, and secure-open only regular files. Failure to read indexed-path membership fails closed with `E008_DB_ERROR`. This validation does not change the successful zero-file contract for implicit full scans without `--files`. | | Symlink warnings | Dangling links and directory-target permission failures are scan warnings. Dry-run exposes them through `warnings_total` and `warnings` while retaining successful exit behavior. | | Windows attributes | Hidden or System paths are rejected before language detection. Clear those attributes on project-owned source because ignore rules cannot re-include the path. | @@ -4466,7 +4466,7 @@ literal、`origin: decode_replacement` が不正 byte に対して decoder が | malformed rule | scan error を報告してその行だけを skip し、run は継続します。 | | symlink mode | 既定は `none` です。`internal` は workspace root 内の target、`all` は解決可能な全 target を追跡します。 | | target identity | discovery、dry-run、C# preflight、content loading は同じ解決済み identity を使います。安定した許可済み外部 target は index し、preflight 後に retarget された link は source drift として拒否します。 | -| watch の symlink reconciliation | `--watch --follow-symlinks internal` では、workspace 内にある lexical symlink が workspace 外へ解決されるようになっても、その event を reconciliation input として保持します。scoped update は禁止された target を open / index せず、残っている stale indexed row を削除します。 | +| watch の symlink reconciliation | `--watch --follow-symlinks internal` では、workspace 内にある lexical symlink が workspace 外へ解決されるようになっても、その event を reconciliation input として保持します。scoped update は禁止された target を open / index せず、残っている stale indexed row を削除します。polling backend は `internal` / `all` で許可された directory link を辿り、その lexical path を維持しつつ、解決済み directory identity を重複排除して cycle と alias を bounded に保ちます。 | | 明示 file の preflight | 明示的な `--files` 更新では、直接指定された各 token と正規化 provenance を保持し、DB へ書き込む前にそのリスト全体を分類します。無効な token、directory、または同じ canonical target に解決される重複が 1 件でもあれば、要求全体を atomic に `UsageError` とします。path token のない `--files` は artifact 作成前に拒否し、full scan へ移行しません。`--project` の展開結果は `UpdateFiles` 内の導出済み scope input のままとし、直接指定された `--files` 選択として再分類しません。通常実行と dry-run は、project root 外、symlink escape、選択した symlink policy で許可されない path、存在しない path、filter 対象、未対応、重複について、同じ `rejected_paths` entry(`input_index`、support-safe な `path`、安定した `reason`)と上限・件数・truncation metadata を返します。canonical duplicate 判定には workspace 全体の policy ではなく解決済み target ごとの filesystem casing を使い、`none` mode は native spelling の正規化差を link とみなさず、実在する symlink/reparse segment を検出します。Windows の 8.3 ancestor alias は indexed-path membership に確立済みの spelling へ canonicalize します (#5122)。既に index 済みで現在は missing / filtered / unsupported となった path は cleanup 対象として有効なままであり、indexed control file が directory、FIFO、または policy で禁止された symlink に変わっても tombstone として保持し、その object を open / follow せず分類します。既存の関連 ancestor ignore file は reconciliation control として許可します。workspace 内の extractor/configuration input も、reconciliation signal として使う missing / deleted input を含めて許可し、generated code も正常な明示対象として扱います。TypeScript path alias の configuration read は in-process / worker extraction の両方へ active な `none` / `internal` / `all` policy を渡し、その policy で許可された target だけを解決して regular file のみ secure-open します。indexed path membership を読めない場合は `E008_DB_ERROR` で fail closed します。この検証は、`--files` のない暗黙の full scan が 0 件でも成功する契約を変更しません。 | | symlink warning | dangling link と directory target の permission failure は scan warning です。dry-run は `warnings_total` / `warnings` に出し、成功終了を維持します。 | | Windows 属性 | Hidden / System path は言語検出前に拒否します。ignore rule では再包含できないため、project 所有 source では先に属性を外してください。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 41daa0271..4e23b4251 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -226,7 +226,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - `LegacySchemaMigrationTests.cs` End-to-end upgrade path: seeds a pre-column legacy DB, opens it through `TryMigrateForRead`, and exercises the read paths that touch nullable symbol ordinals (outline, symbol search, nearby, unused, analyze bundle) to lock in the real-world failure mode behind #58 / #49. Migration ownership coverage must also distinguish caller-owned transactions from owned transactions, propagate unrelated `BEGIN` errors, preserve populated foreign-key rows across rebuilds, and verify rollback, foreign-key restoration, and successful retry after injected failures. - `IndexWatchRunnerTests.cs` - Watch-event fallback coverage for #5091 keeps internally generated `--files` batches on one recovery contract. Exercise a directory with a valid child, an unsupported-language file with a valid edit, and deleted or renamed directories. When the first partial sub-run returns `UsageError`, assert that its failed watch event and captured rejection payload are absent, exactly one full-workspace rescan is emitted in the same `startup` or `incremental` phase, later partial batches stop, and valid sibling changes are reconciled. Keep successful `internal` / `all` symlink batches as non-fallback controls so option propagation remains covered. Polling coverage must resolve immediate and final aliases, exclude database sidecars and checkpoint/restore/backup/temporary artifacts plus ancestor-ignore aliases that reach them, retain ordinary allowed file symlinks, and apply target-directory casing to artifact identity. Directory-symlink subtree traversal belongs to #5124. + Watch-event fallback coverage for #5091 keeps internally generated `--files` batches on one recovery contract. Exercise a directory with a valid child, an unsupported-language file with a valid edit, and deleted or renamed directories. When the first partial sub-run returns `UsageError`, assert that its failed watch event and captured rejection payload are absent, exactly one full-workspace rescan is emitted in the same `startup` or `incremental` phase, later partial batches stop, and valid sibling changes are reconciled. Keep successful `internal` / `all` symlink batches as non-fallback controls so option propagation remains covered. Polling coverage must resolve immediate and final aliases, exclude database sidecars and checkpoint/restore/backup/temporary artifacts plus ancestor-ignore aliases that reach them, retain ordinary allowed file symlinks, and apply target-directory casing to artifact identity. Directory-symlink coverage for #5124 must track lexical paths under allowed internal/external links while preserving `none` / outside-`internal` rejection and bounded cycle/duplicate traversal. - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. Search-core orchestration coverage must keep validation ahead of database execution, preserve list-recipes > named-query > recipe > plain route precedence and the recipe-field-before-conflict / plain-conflict-before-field aggregation error order, keep count-mode zero/nonzero payloads aligned, retain strict-not-found exit semantics, and order profiled NDJSON as result records, profile metadata, then the terminal record. @@ -1325,7 +1325,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `LegacySchemaMigrationTests.cs` エンドツーエンドのアップグレード経路: カラム追加前のレガシー DB を用意し、`TryMigrateForRead` 経由で開いてから NULL になりうるシンボル列を触る read path(outline、シンボル検索、近傍、unused、analyze バンドル)を一通り叩き、#58 / #49 の実機失敗モードを固定する。migration ownership の coverage では、caller-owned transaction と cdidx-owned transaction を区別し、無関係な `BEGIN` error を伝播させ、foreign key が設定された既存 row を rebuild 後も保持し、注入 failure 後の rollback、foreign key 復元、retry 成功まで検証する。 - `IndexWatchRunnerTests.cs` - #5091 の watch event fallback coverage は、内部生成された `--files` batch を 1 つの recovery 契約に保ちます。有効な child を伴う directory、有効な edit を伴う未対応言語 file、削除または rename された directory を検証してください。最初の partial sub-run が `UsageError` を返した場合は、失敗 watch event と捕捉済み rejection payload が出力されず、同じ `startup` または `incremental` phase で workspace 全体の rescan がちょうど 1 回出力され、後続 partial batch が停止し、有効な sibling 変更が reconciliation されることを assertion します。option 伝播の coverage を維持するため、成功する `internal` / `all` symlink batch は non-fallback control として残してください。polling coverage では immediate / final alias を解決し、DB sidecar、checkpoint / restore / backup / temporary artifact と、それらに到達する ancestor ignore alias を除外し、通常の許可済み file symlink を保持し、artifact identity に target directory の casing を適用することを検証してください。directory symlink の subtree traversal は #5124 の対象です。 + #5091 の watch event fallback coverage は、内部生成された `--files` batch を 1 つの recovery 契約に保ちます。有効な child を伴う directory、有効な edit を伴う未対応言語 file、削除または rename された directory を検証してください。最初の partial sub-run が `UsageError` を返した場合は、失敗 watch event と捕捉済み rejection payload が出力されず、同じ `startup` または `incremental` phase で workspace 全体の rescan がちょうど 1 回出力され、後続 partial batch が停止し、有効な sibling 変更が reconciliation されることを assertion します。option 伝播の coverage を維持するため、成功する `internal` / `all` symlink batch は non-fallback control として残してください。polling coverage では immediate / final alias を解決し、DB sidecar、checkpoint / restore / backup / temporary artifact と、それらに到達する ancestor ignore alias を除外し、通常の許可済み file symlink を保持し、artifact identity に target directory の casing を適用することを検証してください。#5124 の directory symlink coverage では、許可された内部 / 外部 link 配下の lexical path を追跡しつつ、`none` / `internal` での外部拒否と bounded な cycle / duplicate traversal を維持してください。 - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 search core の orchestration coverage では、validation が database execution より先に行われること、list-recipes > named-query > recipe > plain の route precedence と recipe では field error が conflict より先・plain では conflict が field error より先になる aggregation error 順、count mode の zero/nonzero payload、strict-not-found の exit semantics、profile 付き NDJSON が result record、profile metadata、terminal record の順になることを維持してください。 diff --git a/changelog.d/unreleased/5124.fixed.md b/changelog.d/unreleased/5124.fixed.md new file mode 100644 index 000000000..97178afaa --- /dev/null +++ b/changelog.d/unreleased/5124.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 5124 +affected: + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md + - src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +--- + +## English + +- **Polling watch now follows allowed directory symlinks (#5124)** — `index --watch` polling observes changes beneath directory links allowed by `--follow-symlinks internal|all`, preserves symlink-relative update paths, and bounds cycles and duplicate targets by resolved directory identity. + +## 日本語 + +- **polling watch が許可された directory symlink を追跡するようになりました (#5124)** — `index --watch` の polling は `--follow-symlinks internal|all` で許可された directory link 配下の変更を監視し、symlink 相対の更新 path を維持しながら、解決済み directory identity によって cycle と重複 target を bounded に抑えます。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index e61087399..77919d267 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -883,6 +883,10 @@ private Dictionary CaptureSnapshot(CancellationToken cancella { var snapshot = new Dictionary(_pathComparer); var pendingDirectories = new Stack(); + var visitedDirectories = new HashSet(StringComparer.Ordinal) + { + FileIndexer.NormalizePathForIdentityComparison(_projectRoot), + }; pendingDirectories.Push(_projectRoot); while (pendingDirectories.Count > 0) @@ -901,11 +905,16 @@ private Dictionary CaptureSnapshot(CancellationToken cancella cancellationToken.ThrowIfCancellationRequested(); try { - if ((File.GetAttributes(childDirectory) & FileAttributes.ReparsePoint) == 0 - && !_fileIndexer.ShouldSkipPath(childDirectory, isDirectory: true)) - { + var attributes = File.GetAttributes(childDirectory); + if ((attributes & FileAttributes.ReparsePoint) != 0 + && _fileIndexer.ShouldSkipDirectoryTraversal(childDirectory)) + continue; + if (_fileIndexer.ShouldSkipPath(childDirectory, isDirectory: true)) + continue; + + var traversalIdentity = FileIndexer.NormalizePathForIdentityComparison(childDirectory); + if (visitedDirectories.Add(traversalIdentity)) pendingDirectories.Push(childDirectory); - } } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 0cb96191e..644eda5df 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -2096,6 +2096,136 @@ public void PollingSnapshot_AllowedSymlinkFileIsTrackedAndUpdated_Issue5091( } } + [Theory] + [InlineData(FileIndexer.SymlinkPolicy.None, false)] + [InlineData(FileIndexer.SymlinkPolicy.Internal, true)] + [InlineData(FileIndexer.SymlinkPolicy.All, true)] + public void PollingSnapshot_AllowedInternalDirectorySymlinkIsTrackedOnceAndUpdated_Issue5124( + FileIndexer.SymlinkPolicy symlinkPolicy, + bool expectTracked) + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + try + { + var targetDirectory = Path.Combine(projectRoot, "ignored-target"); + var targetPath = Path.Combine(targetDirectory, "tracked.cs"); + var firstLink = Path.Combine(projectRoot, "allowed-link-a"); + var secondLink = Path.Combine(projectRoot, "allowed-link-b"); + Directory.CreateDirectory(targetDirectory); + File.WriteAllText(Path.Combine(projectRoot, ".gitignore"), "ignored-target/\n"); + File.WriteAllText(targetPath, "public class BeforeDirectorySymlink5124 { }\n"); + try + { + Directory.CreateSymbolicLink(firstLink, targetDirectory); + Directory.CreateSymbolicLink(secondLink, targetDirectory); + Directory.CreateSymbolicLink(Path.Combine(targetDirectory, "cycle"), projectRoot); + } + catch (Exception ex) when (ShouldSkipWatchSymlinkFixtureFailure(ex)) + { + return; + } + + var candidatePaths = new[] + { + Path.Combine(firstLink, "tracked.cs"), + Path.Combine(secondLink, "tracked.cs"), + }; + var snapshotPaths = IndexWatchRunner.CapturePollingSnapshotPathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy); + var trackedSnapshotPaths = snapshotPaths + .Where(path => candidatePaths.Contains(path, StringComparer.Ordinal)) + .ToArray(); + + if (!expectTracked) + { + Assert.Empty(trackedSnapshotPaths); + return; + } + + var trackedPath = Assert.Single(trackedSnapshotPaths); + var updatedPaths = IndexWatchRunner.CapturePollingUpdatePathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy, + () => File.WriteAllText( + targetPath, + "public class AfterDirectorySymlink5124 { public void Changed() { } }\n")); + + Assert.Contains(trackedPath, updatedPaths); + Assert.Single(updatedPaths.Where(path => candidatePaths.Contains(path, StringComparer.Ordinal))); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData(FileIndexer.SymlinkPolicy.None, false)] + [InlineData(FileIndexer.SymlinkPolicy.Internal, false)] + [InlineData(FileIndexer.SymlinkPolicy.All, true)] + public void PollingSnapshot_OnlyAllTracksExternalDirectorySymlinkUpdates_Issue5124( + FileIndexer.SymlinkPolicy symlinkPolicy, + bool expectTracked) + { + var projectRoot = CreateTempProject(); + var externalRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + try + { + var targetPath = Path.Combine(externalRoot, "external.cs"); + var linkDirectory = Path.Combine(projectRoot, "external-link"); + var linkedPath = Path.Combine(linkDirectory, "external.cs"); + File.WriteAllText(targetPath, "public class BeforeExternalDirectorySymlink5124 { }\n"); + try + { + Directory.CreateSymbolicLink(linkDirectory, externalRoot); + } + catch (Exception ex) when (ShouldSkipWatchSymlinkFixtureFailure(ex)) + { + return; + } + + var snapshotPaths = IndexWatchRunner.CapturePollingSnapshotPathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy); + Assert.Equal(expectTracked, snapshotPaths.Contains(linkedPath)); + if (!expectTracked) + return; + + var updatedPaths = IndexWatchRunner.CapturePollingUpdatePathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy, + () => File.WriteAllText( + targetPath, + "public class AfterExternalDirectorySymlink5124 { public void Changed() { } }\n")); + + Assert.Contains(linkedPath, updatedPaths); + } + finally + { + DeleteDirectory(projectRoot); + DeleteDirectory(externalRoot); + } + } + [Theory] [InlineData(FileIndexer.SymlinkPolicy.Internal)] [InlineData(FileIndexer.SymlinkPolicy.All)] From 03e23efb639b22fdc6ad870ab1e96f1e51313741 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 22 Aug 2026 13:19:34 +0900 Subject: [PATCH 2/4] Address polling symlink review findings (#5124) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/5124.fixed.md | 4 +- .../Cli/IndexWatchRunner.Watchers.cs | 45 ++++++-- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 107 ++++++++++++++++++ 5 files changed, 146 insertions(+), 18 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index dd7c744d3..041b85e40 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -531,7 +531,7 @@ scans. | Malformed rule | Reports a scan error, skips that line, and continues the run. | | Symlink modes | `none` is the default. `internal` follows targets under the workspace root; `all` follows every resolvable target. | | Target identity | Discovery, dry-run, C# preflight, and content loading share one resolved identity. Stable allowed external targets are indexed; links retargeted after preflight are rejected as source drift. | -| Watch symlink reconciliation | Under `--watch --follow-symlinks internal`, an event for a lexical in-workspace symlink that now resolves outside the workspace remains a reconciliation input. The scoped update removes any stale indexed row without opening or indexing the disallowed target. The polling backend traverses allowed directory links under `internal` / `all`, preserves their lexical paths, and deduplicates resolved directory identities to bound cycles and aliases. | +| Watch symlink reconciliation | Under `--watch --follow-symlinks internal`, an event for a lexical in-workspace symlink that now resolves outside the workspace remains a reconciliation input. The scoped update removes any stale indexed row without opening or indexing the disallowed target. The polling backend traverses allowed directory links under `internal` / `all` in the full scanner's depth-first order, preserves the scanner-selected lexical alias, deduplicates resolved directory identities to bound cycles, and excludes internal database artifacts reached through directory aliases. | | Explicit-file preflight | Explicit `--files` updates retain each directly supplied token and its normalization provenance, classify that complete list before any database write, and atomically return `UsageError` when a token is invalid, is a directory, or resolves to a duplicate canonical target. A bare `--files` is rejected before artifacts instead of becoming a full scan. `--project` expansion remains derived scope input in `UpdateFiles` and is not reclassified as a direct `--files` selection. Real and dry-run modes expose the same `rejected_paths` entries (`input_index`, support-safe `path`, stable `reason`) and bounded count/truncation metadata for outside-root, symlink-escaping or symlink-policy-disallowed, nonexistent, filtered, unsupported, and duplicate selections. Canonical duplicate checks use each resolved target's filesystem casing rather than the workspace-wide policy, and `none` mode detects actual symlink/reparse segments instead of treating native spelling normalization as a link. Windows 8.3 ancestor aliases canonicalize to the spelling already established in indexed-path membership (#5122). Indexed missing, filtered, or unsupported paths remain valid cleanup targets; indexed control files that become directories, FIFOs, or policy-disallowed symlinks remain tombstones and are classified without opening or following the object. Existing relevant ancestor ignore files remain valid reconciliation controls. In-workspace extractor/configuration inputs, including missing or deleted inputs used as reconciliation signals, also remain valid; generated code remains a valid explicit target. TypeScript path-alias configuration reads carry the active `none` / `internal` / `all` policy into both in-process and worker extraction, resolve only targets permitted by that policy, and secure-open only regular files. Failure to read indexed-path membership fails closed with `E008_DB_ERROR`. This validation does not change the successful zero-file contract for implicit full scans without `--files`. | | Symlink warnings | Dangling links and directory-target permission failures are scan warnings. Dry-run exposes them through `warnings_total` and `warnings` while retaining successful exit behavior. | | Windows attributes | Hidden or System paths are rejected before language detection. Clear those attributes on project-owned source because ignore rules cannot re-include the path. | @@ -4466,7 +4466,7 @@ literal、`origin: decode_replacement` が不正 byte に対して decoder が | malformed rule | scan error を報告してその行だけを skip し、run は継続します。 | | symlink mode | 既定は `none` です。`internal` は workspace root 内の target、`all` は解決可能な全 target を追跡します。 | | target identity | discovery、dry-run、C# preflight、content loading は同じ解決済み identity を使います。安定した許可済み外部 target は index し、preflight 後に retarget された link は source drift として拒否します。 | -| watch の symlink reconciliation | `--watch --follow-symlinks internal` では、workspace 内にある lexical symlink が workspace 外へ解決されるようになっても、その event を reconciliation input として保持します。scoped update は禁止された target を open / index せず、残っている stale indexed row を削除します。polling backend は `internal` / `all` で許可された directory link を辿り、その lexical path を維持しつつ、解決済み directory identity を重複排除して cycle と alias を bounded に保ちます。 | +| watch の symlink reconciliation | `--watch --follow-symlinks internal` では、workspace 内にある lexical symlink が workspace 外へ解決されるようになっても、その event を reconciliation input として保持します。scoped update は禁止された target を open / index せず、残っている stale indexed row を削除します。polling backend は `internal` / `all` で許可された directory link を full scanner と同じ depth-first 順で辿り、scanner が選択した lexical alias を維持し、解決済み directory identity の重複排除で cycle を bounded に保ち、directory alias 経由で到達する内部 DB artifact を除外します。 | | 明示 file の preflight | 明示的な `--files` 更新では、直接指定された各 token と正規化 provenance を保持し、DB へ書き込む前にそのリスト全体を分類します。無効な token、directory、または同じ canonical target に解決される重複が 1 件でもあれば、要求全体を atomic に `UsageError` とします。path token のない `--files` は artifact 作成前に拒否し、full scan へ移行しません。`--project` の展開結果は `UpdateFiles` 内の導出済み scope input のままとし、直接指定された `--files` 選択として再分類しません。通常実行と dry-run は、project root 外、symlink escape、選択した symlink policy で許可されない path、存在しない path、filter 対象、未対応、重複について、同じ `rejected_paths` entry(`input_index`、support-safe な `path`、安定した `reason`)と上限・件数・truncation metadata を返します。canonical duplicate 判定には workspace 全体の policy ではなく解決済み target ごとの filesystem casing を使い、`none` mode は native spelling の正規化差を link とみなさず、実在する symlink/reparse segment を検出します。Windows の 8.3 ancestor alias は indexed-path membership に確立済みの spelling へ canonicalize します (#5122)。既に index 済みで現在は missing / filtered / unsupported となった path は cleanup 対象として有効なままであり、indexed control file が directory、FIFO、または policy で禁止された symlink に変わっても tombstone として保持し、その object を open / follow せず分類します。既存の関連 ancestor ignore file は reconciliation control として許可します。workspace 内の extractor/configuration input も、reconciliation signal として使う missing / deleted input を含めて許可し、generated code も正常な明示対象として扱います。TypeScript path alias の configuration read は in-process / worker extraction の両方へ active な `none` / `internal` / `all` policy を渡し、その policy で許可された target だけを解決して regular file のみ secure-open します。indexed path membership を読めない場合は `E008_DB_ERROR` で fail closed します。この検証は、`--files` のない暗黙の full scan が 0 件でも成功する契約を変更しません。 | | symlink warning | dangling link と directory target の permission failure は scan warning です。dry-run は `warnings_total` / `warnings` に出し、成功終了を維持します。 | | Windows 属性 | Hidden / System path は言語検出前に拒否します。ignore rule では再包含できないため、project 所有 source では先に属性を外してください。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 4e23b4251..5effa8440 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -226,7 +226,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result - `LegacySchemaMigrationTests.cs` End-to-end upgrade path: seeds a pre-column legacy DB, opens it through `TryMigrateForRead`, and exercises the read paths that touch nullable symbol ordinals (outline, symbol search, nearby, unused, analyze bundle) to lock in the real-world failure mode behind #58 / #49. Migration ownership coverage must also distinguish caller-owned transactions from owned transactions, propagate unrelated `BEGIN` errors, preserve populated foreign-key rows across rebuilds, and verify rollback, foreign-key restoration, and successful retry after injected failures. - `IndexWatchRunnerTests.cs` - Watch-event fallback coverage for #5091 keeps internally generated `--files` batches on one recovery contract. Exercise a directory with a valid child, an unsupported-language file with a valid edit, and deleted or renamed directories. When the first partial sub-run returns `UsageError`, assert that its failed watch event and captured rejection payload are absent, exactly one full-workspace rescan is emitted in the same `startup` or `incremental` phase, later partial batches stop, and valid sibling changes are reconciled. Keep successful `internal` / `all` symlink batches as non-fallback controls so option propagation remains covered. Polling coverage must resolve immediate and final aliases, exclude database sidecars and checkpoint/restore/backup/temporary artifacts plus ancestor-ignore aliases that reach them, retain ordinary allowed file symlinks, and apply target-directory casing to artifact identity. Directory-symlink coverage for #5124 must track lexical paths under allowed internal/external links while preserving `none` / outside-`internal` rejection and bounded cycle/duplicate traversal. + Watch-event fallback coverage for #5091 keeps internally generated `--files` batches on one recovery contract. Exercise a directory with a valid child, an unsupported-language file with a valid edit, and deleted or renamed directories. When the first partial sub-run returns `UsageError`, assert that its failed watch event and captured rejection payload are absent, exactly one full-workspace rescan is emitted in the same `startup` or `incremental` phase, later partial batches stop, and valid sibling changes are reconciled. Keep successful `internal` / `all` symlink batches as non-fallback controls so option propagation remains covered. Polling coverage must resolve immediate and final aliases, exclude database sidecars and checkpoint/restore/backup/temporary artifacts plus ancestor-ignore aliases that reach them, retain ordinary allowed file symlinks, and apply target-directory casing to artifact identity. Directory-symlink coverage for #5124 must track lexical paths under allowed internal/external links, exclude internal artifacts reached through directory aliases, select duplicate-target aliases in full-scanner depth-first order, and preserve `none` / outside-`internal` rejection plus bounded cycle traversal. - `IndexCommandRunner*Tests.cs`, `QueryCommandRunner*Tests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. Search-core orchestration coverage must keep validation ahead of database execution, preserve list-recipes > named-query > recipe > plain route precedence and the recipe-field-before-conflict / plain-conflict-before-field aggregation error order, keep count-mode zero/nonzero payloads aligned, retain strict-not-found exit semantics, and order profiled NDJSON as result records, profile metadata, then the terminal record. @@ -1325,7 +1325,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `LegacySchemaMigrationTests.cs` エンドツーエンドのアップグレード経路: カラム追加前のレガシー DB を用意し、`TryMigrateForRead` 経由で開いてから NULL になりうるシンボル列を触る read path(outline、シンボル検索、近傍、unused、analyze バンドル)を一通り叩き、#58 / #49 の実機失敗モードを固定する。migration ownership の coverage では、caller-owned transaction と cdidx-owned transaction を区別し、無関係な `BEGIN` error を伝播させ、foreign key が設定された既存 row を rebuild 後も保持し、注入 failure 後の rollback、foreign key 復元、retry 成功まで検証する。 - `IndexWatchRunnerTests.cs` - #5091 の watch event fallback coverage は、内部生成された `--files` batch を 1 つの recovery 契約に保ちます。有効な child を伴う directory、有効な edit を伴う未対応言語 file、削除または rename された directory を検証してください。最初の partial sub-run が `UsageError` を返した場合は、失敗 watch event と捕捉済み rejection payload が出力されず、同じ `startup` または `incremental` phase で workspace 全体の rescan がちょうど 1 回出力され、後続 partial batch が停止し、有効な sibling 変更が reconciliation されることを assertion します。option 伝播の coverage を維持するため、成功する `internal` / `all` symlink batch は non-fallback control として残してください。polling coverage では immediate / final alias を解決し、DB sidecar、checkpoint / restore / backup / temporary artifact と、それらに到達する ancestor ignore alias を除外し、通常の許可済み file symlink を保持し、artifact identity に target directory の casing を適用することを検証してください。#5124 の directory symlink coverage では、許可された内部 / 外部 link 配下の lexical path を追跡しつつ、`none` / `internal` での外部拒否と bounded な cycle / duplicate traversal を維持してください。 + #5091 の watch event fallback coverage は、内部生成された `--files` batch を 1 つの recovery 契約に保ちます。有効な child を伴う directory、有効な edit を伴う未対応言語 file、削除または rename された directory を検証してください。最初の partial sub-run が `UsageError` を返した場合は、失敗 watch event と捕捉済み rejection payload が出力されず、同じ `startup` または `incremental` phase で workspace 全体の rescan がちょうど 1 回出力され、後続 partial batch が停止し、有効な sibling 変更が reconciliation されることを assertion します。option 伝播の coverage を維持するため、成功する `internal` / `all` symlink batch は non-fallback control として残してください。polling coverage では immediate / final alias を解決し、DB sidecar、checkpoint / restore / backup / temporary artifact と、それらに到達する ancestor ignore alias を除外し、通常の許可済み file symlink を保持し、artifact identity に target directory の casing を適用することを検証してください。#5124 の directory symlink coverage では、許可された内部 / 外部 link 配下の lexical path を追跡し、directory alias 経由の内部 artifact を除外し、重複 target の alias を full scanner と同じ depth-first 順で選択しつつ、`none` / `internal` での外部拒否と bounded な cycle traversal を維持してください。 - `IndexCommandRunner*Tests.cs`、`QueryCommandRunner*Tests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 search core の orchestration coverage では、validation が database execution より先に行われること、list-recipes > named-query > recipe > plain の route precedence と recipe では field error が conflict より先・plain では conflict が field error より先になる aggregation error 順、count mode の zero/nonzero payload、strict-not-found の exit semantics、profile 付き NDJSON が result record、profile metadata、terminal record の順になることを維持してください。 diff --git a/changelog.d/unreleased/5124.fixed.md b/changelog.d/unreleased/5124.fixed.md index 97178afaa..4eb3b6027 100644 --- a/changelog.d/unreleased/5124.fixed.md +++ b/changelog.d/unreleased/5124.fixed.md @@ -11,8 +11,8 @@ affected: ## English -- **Polling watch now follows allowed directory symlinks (#5124)** — `index --watch` polling observes changes beneath directory links allowed by `--follow-symlinks internal|all`, preserves symlink-relative update paths, and bounds cycles and duplicate targets by resolved directory identity. +- **Polling watch now follows allowed directory symlinks (#5124)** — `index --watch` polling observes changes beneath directory links allowed by `--follow-symlinks internal|all`, preserves the full scanner's depth-first lexical alias, excludes internal database artifacts reached through aliases, and bounds cycles and duplicate targets by resolved directory identity. ## 日本語 -- **polling watch が許可された directory symlink を追跡するようになりました (#5124)** — `index --watch` の polling は `--follow-symlinks internal|all` で許可された directory link 配下の変更を監視し、symlink 相対の更新 path を維持しながら、解決済み directory identity によって cycle と重複 target を bounded に抑えます。 +- **polling watch が許可された directory symlink を追跡するようになりました (#5124)** — `index --watch` の polling は `--follow-symlinks internal|all` で許可された directory link 配下の変更を監視し、full scanner と同じ depth-first の lexical alias を維持し、alias 経由の内部 DB artifact を除外しながら、解決済み directory identity によって cycle と重複 target を bounded に抑えます。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index 77919d267..df7a2060d 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -883,16 +883,17 @@ private Dictionary CaptureSnapshot(CancellationToken cancella { var snapshot = new Dictionary(_pathComparer); var pendingDirectories = new Stack(); - var visitedDirectories = new HashSet(StringComparer.Ordinal) - { - FileIndexer.NormalizePathForIdentityComparison(_projectRoot), - }; + var visitedDirectories = new HashSet(StringComparer.Ordinal); pendingDirectories.Push(_projectRoot); while (pendingDirectories.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); var directory = pendingDirectories.Pop(); + var traversalIdentity = FileIndexer.NormalizePathForIdentityComparison(directory); + if (!visitedDirectories.Add(traversalIdentity)) + continue; + foreach (var file in CodeIndex.FileSystemTraversalPolicy.EnumerateFiles(directory)) { cancellationToken.ThrowIfCancellationRequested(); @@ -900,6 +901,7 @@ private Dictionary CaptureSnapshot(CancellationToken cancella AddFileStamp(snapshot, file); } + var childDirectories = new List(); foreach (var childDirectory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(directory)) { cancellationToken.ThrowIfCancellationRequested(); @@ -907,19 +909,24 @@ private Dictionary CaptureSnapshot(CancellationToken cancella { var attributes = File.GetAttributes(childDirectory); if ((attributes & FileAttributes.ReparsePoint) != 0 - && _fileIndexer.ShouldSkipDirectoryTraversal(childDirectory)) + && (_fileIndexer.ShouldSkipDirectoryTraversal(childDirectory) + || ResolvesToWatchInternalPath(childDirectory))) continue; if (_fileIndexer.ShouldSkipPath(childDirectory, isDirectory: true)) continue; - var traversalIdentity = FileIndexer.NormalizePathForIdentityComparison(childDirectory); - if (visitedDirectories.Add(traversalIdentity)) - pendingDirectories.Push(childDirectory); + childDirectories.Add(childDirectory); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { } } + + // The full scanner visits child directories depth-first in enumeration order. + // Push in reverse and claim resolved identities only when a directory is popped, + // so polling retains the same lexical alias when several links share a target. + for (var index = childDirectories.Count - 1; index >= 0; index--) + pendingDirectories.Push(childDirectories[index]); } foreach (var ignorePath in EnumerateAncestorIgnorePaths( @@ -956,11 +963,25 @@ private bool ShouldTrackFile(string path) private bool ResolvesToWatchInternalPath(string path) { - if (!TryResolveReparsePointPaths(path, out var immediatePath, out var finalPath)) - return false; + if (TryResolveReparsePointPaths(path, out var immediatePath, out var finalPath) + && (IsResolvedWatchInternalPath(immediatePath) + || IsResolvedWatchInternalPath(finalPath))) + { + return true; + } - return IsResolvedWatchInternalPath(immediatePath) - || IsResolvedWatchInternalPath(finalPath); + try + { + // The leaf may be ordinary while an ancestor directory is a link. Resolve + // the complete path so database descendants reached through such aliases do + // not feed the polling watcher with its own writes. + return IsResolvedWatchInternalPath( + FileIndexer.NormalizePathForIdentityComparison(path)); + } + catch (Exception ex) when (CodeIndex.FileSystemTraversalPolicy.IsExpectedTraversalException(ex)) + { + return false; + } } private bool IsResolvedWatchInternalPath(string resolvedPath) diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 644eda5df..f924a890d 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -2226,6 +2226,113 @@ public void PollingSnapshot_OnlyAllTracksExternalDirectorySymlinkUpdates_Issue51 } } + [Theory] + [InlineData(FileIndexer.SymlinkPolicy.Internal)] + [InlineData(FileIndexer.SymlinkPolicy.All)] + public void PollingSnapshot_DirectoryAliasToInternalArtifactsIsExcluded_Issue5124( + FileIndexer.SymlinkPolicy symlinkPolicy) + { + var projectRoot = CreateTempProject(); + var dataDirectory = Path.Combine(projectRoot, ".cdidx"); + var dbPath = Path.Combine(dataDirectory, "codeindex.db"); + var aliasDirectory = Path.Combine(projectRoot, "state-alias"); + try + { + Directory.CreateDirectory(dataDirectory); + File.WriteAllText(dbPath, "initial-db"); + File.WriteAllText(dbPath + "-wal", "initial-wal"); + try + { + Directory.CreateSymbolicLink(aliasDirectory, dataDirectory); + } + catch (Exception ex) when (ShouldSkipWatchSymlinkFixtureFailure(ex)) + { + return; + } + + var snapshotPaths = IndexWatchRunner.CapturePollingSnapshotPathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy); + Assert.DoesNotContain( + snapshotPaths, + path => path.StartsWith(aliasDirectory + Path.DirectorySeparatorChar, StringComparison.Ordinal)); + + var updatedPaths = IndexWatchRunner.CapturePollingUpdatePathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy, + () => File.WriteAllText(dbPath + "-wal", $"updated-wal-{Guid.NewGuid():N}")); + Assert.DoesNotContain( + updatedPaths, + path => path.StartsWith(aliasDirectory + Path.DirectorySeparatorChar, StringComparison.Ordinal)); + } + finally + { + DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData(FileIndexer.SymlinkPolicy.Internal)] + [InlineData(FileIndexer.SymlinkPolicy.All)] + public void PollingSnapshot_DirectoryAliasSelectionMatchesDepthFirstScannerOrder_Issue5124( + FileIndexer.SymlinkPolicy symlinkPolicy) + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + try + { + var parentDirectory = Path.Combine(projectRoot, "a-parent"); + var targetDirectory = Path.Combine(projectRoot, "ignored-target"); + var nestedAlias = Path.Combine(parentDirectory, "nested-link"); + var directAlias = Path.Combine(projectRoot, "z-direct-link"); + Directory.CreateDirectory(parentDirectory); + Directory.CreateDirectory(targetDirectory); + File.WriteAllText(Path.Combine(projectRoot, ".gitignore"), "ignored-target/\n"); + File.WriteAllText( + Path.Combine(targetDirectory, "tracked.cs"), + "public class DepthFirstDirectorySymlink5124 { }\n"); + try + { + Directory.CreateSymbolicLink(nestedAlias, targetDirectory); + Directory.CreateSymbolicLink(directAlias, targetDirectory); + } + catch (Exception ex) when (ShouldSkipWatchSymlinkFixtureFailure(ex)) + { + return; + } + + var rootDirectories = Directory.EnumerateDirectories(projectRoot).ToArray(); + Assert.True( + Array.IndexOf(rootDirectories, parentDirectory) < Array.IndexOf(rootDirectories, directAlias), + "The fixture requires the parent directory to precede the direct alias in enumeration order."); + + var nestedPath = Path.Combine(nestedAlias, "tracked.cs"); + var directPath = Path.Combine(directAlias, "tracked.cs"); + var snapshotPaths = IndexWatchRunner.CapturePollingSnapshotPathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true, + symlinkPolicy); + + Assert.Contains(nestedPath, snapshotPaths); + Assert.DoesNotContain(directPath, snapshotPaths); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Theory] [InlineData(FileIndexer.SymlinkPolicy.Internal)] [InlineData(FileIndexer.SymlinkPolicy.All)] From 9d5546a02eb43531d2f942e7c882f383649d2397 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 22 Aug 2026 13:33:56 +0900 Subject: [PATCH 3/4] Bound polling alias canonicalization (#5124) --- DEVELOPER_GUIDE.md | 17 +++++++------ USER_GUIDE.md | 12 +++++---- changelog.d/unreleased/5124.fixed.md | 1 + .../Cli/IndexWatchRunner.Watchers.cs | 24 ++++++++++-------- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 25 +++++++++++++------ 5 files changed, 48 insertions(+), 31 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 041b85e40..958a54938 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2162,10 +2162,11 @@ batch from being stranded by a transient path-shape race. Polling snapshots resolve immediate and final symlink/reparse targets before adding a path. Aliases of the configured database, SQLite sidecars, lock/info, checkpoint, restore/backup, and atomic-temporary artifacts are excluded, as are -ancestor-ignore aliases that resolve to those artifacts. Ordinary file symlinks -allowed by `internal` or `all` remain tracked, with target-directory casing used -for internal-artifact identity. Polling does not yet traverse allowed -directory-symlink subtrees; #5124 tracks policy-aware, cycle-safe traversal. +ancestor-ignore aliases that resolve to those artifacts. Ordinary file and +directory symlinks allowed by `internal` or `all` remain tracked. Directory +subtrees use full-scanner depth-first lexical alias selection, resolve descendant +paths only while beneath an alias, and deduplicate resolved directory identities +to bound cycles and duplicate targets. `--commits` uses `git diff-tree --no-commit-id -r --name-only` to resolve changed file paths. `--changed-between` uses `git diff --name-status -M ` and includes both old and new rename paths so stale indexed paths can be purged. @@ -6043,10 +6044,10 @@ phase で `--files` を付けない workspace 全体 rescan をちょうど 1 polling snapshot は path を追加する前に symlink / reparse の immediate target と final target を解決する。configured DB、SQLite sidecar、lock/info、checkpoint、restore/backup、 atomic temporary artifact の alias と、それら artifact に解決される ancestor ignore -alias は除外する。`internal` / `all` で許可される通常の file symlink は追跡を維持し、 -internal artifact identity には target directory の casing を使う。polling は現時点で -許可済み directory symlink の subtree を traverse せず、policy-aware かつ cycle-safe -な traversal は #5124 で追跡する。 +alias は除外する。`internal` / `all` で許可される通常の file / directory symlink は +追跡を維持する。directory subtree は full scanner と同じ depth-first の lexical alias +選択を使い、alias 配下だけ descendant path を解決し、解決済み directory identity を +重複排除して cycle と重複 target を bounded に保つ。 `--commits` は `git diff-tree --no-commit-id -r --name-only` で変更ファイルパスを解決します。 `--changed-between` は `git diff --name-status -M ` を使い、rename の旧パスと新パスを両方含めるため、古い indexed path も purge できます。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b3f30564c..24929fa9c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2726,9 +2726,10 @@ TypeScript path-alias configuration reads use the active `none`, `internal`, or `all` symlink policy and securely open only regular resolved targets. Polling watch similarly resolves symlink/reparse aliases before classification: aliases of database artifacts and ancestor ignore files that resolve to those artifacts -are excluded, while ordinary file symlinks allowed by the selected policy remain -watchable. Polling does not yet traverse directory-symlink subtrees; that -follow-up is tracked in #5124. +are excluded, while ordinary file and directory symlinks allowed by the selected +policy remain watchable. Polling traverses directory links in full-scanner +depth-first order and deduplicates resolved directory identities to bound cycles +and duplicate targets. cdidx scans your project directory, applies the built-in skip lists plus user `.gitignore` / `.cdidxignore` rules, skips Windows Hidden/System paths before language detection, splits each remaining source file into overlapping chunks, and stores everything in a SQLite database with FTS5 full-text search. In each directory, `.gitignore` is loaded before `.cdidxignore`; later rules are additive, so a `!` pattern in `.cdidxignore` can re-include a path ignored earlier by `.gitignore` in the same directory scope. Incremental mode (default) first purges database entries for files that no longer exist on disk, then checks each file's last-modified timestamp against the database — only files whose timestamp exactly matches are skipped, and any difference (newer or older) triggers re-indexing. Newly appeared files are indexed as new entries. The same path filter is reused for scoped `--files` / `--commits` refreshes, commit-based refreshes automatically switch to a full scan when ignore files changed, and Git-managed workspaces follow the repository's `core.ignorecase` setting when evaluating ignore rules. This means re-indexing after a branch switch only processes the files that actually differ unless ignore rules themselves changed. @@ -6285,8 +6286,9 @@ TypeScript path alias の configuration read は active な `none` / `internal` symlink policy を使用し、解決後の regular target だけを secure-open します。polling watch も分類前に symlink / reparse alias を解決し、DB artifact の alias と、その artifact に解決される ancestor ignore file の alias を除外する一方、選択した policy -で許可される通常の file symlink は watch 対象として保持します。polling は現時点で -directory symlink の subtree を traverse せず、その follow-up は #5124 で追跡します。 +で許可される通常の file / directory symlink は watch 対象として保持します。polling は +directory link を full scanner と同じ depth-first 順で辿り、解決済み directory identity +を重複排除して cycle と重複 target を bounded に保ちます。 cdidxはプロジェクトディレクトリを走査し、組み込みのスキップ対象とユーザーの `.gitignore` / `.cdidxignore` を適用し、Windows の Hidden/System パスを言語検出前にスキップしたうえで、各ソースファイルを重複を持つチャンクに分割し、FTS5全文検索付きのSQLiteデータベースに格納します。同じディレクトリでは `.gitignore` を先に読み、`.cdidxignore` を後から読むため、後の `.cdidxignore` ルールは加算的に適用され、`!` パターンで同じディレクトリスコープの `.gitignore` 除外を再包含できます。インクリメンタルモード(デフォルト)では各ファイルの最終更新タイムスタンプをDB内の値と比較し、完全一致するファイルのみスキップします。タイムスタンプが異なれば(新しくても古くても)再インデックスされるため、ブランチ切り替え後も正確にインデックスが更新されます。`--files` / `--commits` の部分更新も同じパスフィルタを再利用し、commit 側で ignore ファイルが変わったときは自動でフルスキャンへ切り替わります。Git 管理下の ignore 判定は OS 固定ではなく `core.ignorecase` を参照し、`**` も Git の path-form globstar だけを特別扱いするため、差分更新でも Git と同じ範囲で ignore されます。つまり ignore ルール自体が変わらない限り、差分再インデックスは実際に変わったファイルだけに比例します。 diff --git a/changelog.d/unreleased/5124.fixed.md b/changelog.d/unreleased/5124.fixed.md index 4eb3b6027..052e1f68b 100644 --- a/changelog.d/unreleased/5124.fixed.md +++ b/changelog.d/unreleased/5124.fixed.md @@ -5,6 +5,7 @@ issues: affected: - DEVELOPER_GUIDE.md - TESTING_GUIDE.md + - USER_GUIDE.md - src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs --- diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index df7a2060d..59403f566 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -882,14 +882,14 @@ internal IReadOnlyCollection CaptureUpdatePaths( private Dictionary CaptureSnapshot(CancellationToken cancellationToken) { var snapshot = new Dictionary(_pathComparer); - var pendingDirectories = new Stack(); + var pendingDirectories = new Stack<(string Directory, bool ResolveAncestorAliases)>(); var visitedDirectories = new HashSet(StringComparer.Ordinal); - pendingDirectories.Push(_projectRoot); + pendingDirectories.Push((_projectRoot, false)); while (pendingDirectories.Count > 0) { cancellationToken.ThrowIfCancellationRequested(); - var directory = pendingDirectories.Pop(); + var (directory, resolveAncestorAliases) = pendingDirectories.Pop(); var traversalIdentity = FileIndexer.NormalizePathForIdentityComparison(directory); if (!visitedDirectories.Add(traversalIdentity)) continue; @@ -897,25 +897,26 @@ private Dictionary CaptureSnapshot(CancellationToken cancella foreach (var file in CodeIndex.FileSystemTraversalPolicy.EnumerateFiles(directory)) { cancellationToken.ThrowIfCancellationRequested(); - if (ShouldTrackFile(file)) + if (ShouldTrackFile(file, resolveAncestorAliases)) AddFileStamp(snapshot, file); } - var childDirectories = new List(); + var childDirectories = new List<(string Directory, bool ResolveAncestorAliases)>(); foreach (var childDirectory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(directory)) { cancellationToken.ThrowIfCancellationRequested(); try { var attributes = File.GetAttributes(childDirectory); - if ((attributes & FileAttributes.ReparsePoint) != 0 + var isReparsePoint = (attributes & FileAttributes.ReparsePoint) != 0; + if (isReparsePoint && (_fileIndexer.ShouldSkipDirectoryTraversal(childDirectory) || ResolvesToWatchInternalPath(childDirectory))) continue; if (_fileIndexer.ShouldSkipPath(childDirectory, isDirectory: true)) continue; - childDirectories.Add(childDirectory); + childDirectories.Add((childDirectory, resolveAncestorAliases || isReparsePoint)); } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { @@ -941,9 +942,9 @@ private Dictionary CaptureSnapshot(CancellationToken cancella return snapshot; } - private bool ShouldTrackFile(string path) + private bool ShouldTrackFile(string path, bool resolveAncestorAliases = false) { - if (ResolvesToWatchInternalPath(path)) + if (ResolvesToWatchInternalPath(path, resolveAncestorAliases)) return false; if (FileIndexer.ClassifyIndexInputInvalidation(_projectRoot, path) @@ -961,7 +962,7 @@ private bool ShouldTrackFile(string path) && !_fileIndexer.ShouldSkipPath(path); } - private bool ResolvesToWatchInternalPath(string path) + private bool ResolvesToWatchInternalPath(string path, bool resolveAncestorAliases = false) { if (TryResolveReparsePointPaths(path, out var immediatePath, out var finalPath) && (IsResolvedWatchInternalPath(immediatePath) @@ -970,6 +971,9 @@ private bool ResolvesToWatchInternalPath(string path) return true; } + if (!resolveAncestorAliases) + return false; + try { // The leaf may be ordinary while an ancestor directory is a link. Resolve diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index f924a890d..0b7dc8a92 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -2233,14 +2233,18 @@ public void PollingSnapshot_DirectoryAliasToInternalArtifactsIsExcluded_Issue512 FileIndexer.SymlinkPolicy symlinkPolicy) { var projectRoot = CreateTempProject(); - var dataDirectory = Path.Combine(projectRoot, ".cdidx"); + var dataDirectory = Path.Combine(projectRoot, "ignored-state"); var dbPath = Path.Combine(dataDirectory, "codeindex.db"); var aliasDirectory = Path.Combine(projectRoot, "state-alias"); + var ordinaryTargetPath = Path.Combine(dataDirectory, "ordinary.cs"); + var ordinaryAliasPath = Path.Combine(aliasDirectory, "ordinary.cs"); try { Directory.CreateDirectory(dataDirectory); + File.WriteAllText(Path.Combine(projectRoot, ".gitignore"), "ignored-state/\n"); File.WriteAllText(dbPath, "initial-db"); File.WriteAllText(dbPath + "-wal", "initial-wal"); + File.WriteAllText(ordinaryTargetPath, "public class BeforeInternalDirectoryAlias5124 { }\n"); try { Directory.CreateSymbolicLink(aliasDirectory, dataDirectory); @@ -2257,9 +2261,9 @@ public void PollingSnapshot_DirectoryAliasToInternalArtifactsIsExcluded_Issue512 ignoreCase: false, dbPathExplicit: true, symlinkPolicy); - Assert.DoesNotContain( - snapshotPaths, - path => path.StartsWith(aliasDirectory + Path.DirectorySeparatorChar, StringComparison.Ordinal)); + Assert.DoesNotContain(Path.Combine(aliasDirectory, "codeindex.db"), snapshotPaths); + Assert.DoesNotContain(Path.Combine(aliasDirectory, "codeindex.db-wal"), snapshotPaths); + Assert.Contains(ordinaryAliasPath, snapshotPaths); var updatedPaths = IndexWatchRunner.CapturePollingUpdatePathsForTesting( projectRoot, @@ -2268,10 +2272,15 @@ public void PollingSnapshot_DirectoryAliasToInternalArtifactsIsExcluded_Issue512 ignoreCase: false, dbPathExplicit: true, symlinkPolicy, - () => File.WriteAllText(dbPath + "-wal", $"updated-wal-{Guid.NewGuid():N}")); - Assert.DoesNotContain( - updatedPaths, - path => path.StartsWith(aliasDirectory + Path.DirectorySeparatorChar, StringComparison.Ordinal)); + () => + { + File.WriteAllText(dbPath + "-wal", $"updated-wal-{Guid.NewGuid():N}"); + File.WriteAllText( + ordinaryTargetPath, + "public class AfterInternalDirectoryAlias5124 { public void Changed() { } }\n"); + }); + Assert.DoesNotContain(Path.Combine(aliasDirectory, "codeindex.db-wal"), updatedPaths); + Assert.Contains(ordinaryAliasPath, updatedPaths); } finally { From 4a19a492d45d692900ba9251da81f101c7c659fe Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 22 Aug 2026 15:28:08 +0900 Subject: [PATCH 4/4] Make polling alias test cross-platform (#5124) --- tests/CodeIndex.Tests/IndexWatchRunnerTests.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 0b7dc8a92..9a6f30d75 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -2319,9 +2319,8 @@ public void PollingSnapshot_DirectoryAliasSelectionMatchesDepthFirstScannerOrder } var rootDirectories = Directory.EnumerateDirectories(projectRoot).ToArray(); - Assert.True( - Array.IndexOf(rootDirectories, parentDirectory) < Array.IndexOf(rootDirectories, directAlias), - "The fixture requires the parent directory to precede the direct alias in enumeration order."); + var parentPrecedesDirectAlias = + Array.IndexOf(rootDirectories, parentDirectory) < Array.IndexOf(rootDirectories, directAlias); var nestedPath = Path.Combine(nestedAlias, "tracked.cs"); var directPath = Path.Combine(directAlias, "tracked.cs"); @@ -2333,8 +2332,8 @@ public void PollingSnapshot_DirectoryAliasSelectionMatchesDepthFirstScannerOrder dbPathExplicit: true, symlinkPolicy); - Assert.Contains(nestedPath, snapshotPaths); - Assert.DoesNotContain(directPath, snapshotPaths); + Assert.Contains(parentPrecedesDirectAlias ? nestedPath : directPath, snapshotPaths); + Assert.DoesNotContain(parentPrecedesDirectAlias ? directPath : nestedPath, snapshotPaths); } finally {