From ab6f35469df6659f4333b25319d8def4a34c11de Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 22:14:11 +0900 Subject: [PATCH] Fix ancestor ignore chain audit for issue #2024 --- DEVELOPER_GUIDE.md | 2 +- changelog.d/unreleased/2024.fixed.md | 17 +++++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 57 ++++++++++++--- tests/CodeIndex.Tests/FileIndexerTests.cs | 70 +++++++++++++++++++ 4 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/2024.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 376f58d6f0..34429025f7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -68,7 +68,7 @@ Directory scan / shared path filter (built-in skip lists + `.gitignore` / `.cdid → Populate FTS5 index ``` -Scoped `--files` / `--commits` refreshes reuse the same path filter as full scans. Within each directory, `FileIndexer` loads `.gitignore` before `.cdidxignore`, appends both rule sets in that order, and honors later `!` patterns as re-includes. If a commit-scoped refresh includes `.gitignore` or `.cdidxignore` changes, `IndexCommandRunner` falls back to a full scan so newly ignored files are purged safely. Malformed ignore lines are reported as scan errors and skipped instead of aborting the whole run. On Windows, files and directories with Hidden or System attributes are rejected before language detection; clear those attributes before indexing project-owned sources because ignore rules cannot re-include them. +Scoped `--files` / `--commits` refreshes reuse the same path filter as full scans. Before scanning a nested project root, `FileIndexer` loads ignore files from the resolved ignore-rule root through each existing ancestor directory down to the project root's parent, then loads the project directory's own rules during the normal walk. Within each directory, `FileIndexer` loads `.gitignore` before `.cdidxignore`, appends both rule sets in that order, and honors later `!` patterns as re-includes. If an ancestor ignore directory cannot be read, scanning fails closed with a scan error instead of silently skipping those rules; `ScanFilesResult.AncestorIgnoreDirectories` records the resolved ancestor list for troubleshooting. If a commit-scoped refresh includes `.gitignore` or `.cdidxignore` changes, `IndexCommandRunner` falls back to a full scan so newly ignored files are purged safely. Malformed ignore lines are reported as scan errors and skipped instead of aborting the whole run. On Windows, files and directories with Hidden or System attributes are rejected before language detection; clear those attributes before indexing project-owned sources because ignore rules cannot re-include them. Incremental refreshes that mutate `fts_chunks` increment `codeindex_meta.fts_incremental_writes_since_optimize`. When the counter reaches `DbWriter.DefaultFtsOptimizeIncrementalWriteThreshold`, the update path runs `INSERT INTO fts_chunks(fts_chunks) VALUES('optimize')`, resets the counter, and stamps `fts_last_optimized_at`. Users can run the same maintenance directly with `cdidx optimize --db ` or `cdidx index --optimize`; this may briefly hold the writer lock on large indexes. diff --git a/changelog.d/unreleased/2024.fixed.md b/changelog.d/unreleased/2024.fixed.md new file mode 100644 index 0000000000..57df60036f --- /dev/null +++ b/changelog.d/unreleased/2024.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2024 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Nested project ignore inheritance now audits the full ancestor chain (#2024)** — `FileIndexer` now walks real parent directories from the ignore-rule root to the project root, fails closed when an ancestor ignore directory cannot be read, and exposes the resolved ancestor list in `ScanFilesResult` for troubleshooting. + +## 日本語 + +- **ネストしたプロジェクトの ignore 継承が ancestor chain 全体を監査するようになりました (#2024)** — `FileIndexer` は ignore-rule root から project root まで実際の親ディレクトリを辿り、ancestor ignore directory を読めない場合は安全側で失敗し、調査用に解決済み ancestor list を `ScanFilesResult` に公開します。 diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index c8705ead8c..3ceb854e19 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -46,6 +46,7 @@ public readonly record struct ScanFilesResult( IReadOnlyList ListedDirectories, IReadOnlyList FullyScannedDirectories, IReadOnlySet CheckpointedDirectories, + IReadOnlyList AncestorIgnoreDirectories, IReadOnlyList AttributePrunedDirectories) { public bool HadErrors => Errors.Any(error => error.IsFatal); @@ -1631,6 +1632,7 @@ internal ScanFilesResult ScanFilesDetailed( listedDirectories.ToList(), fullyScannedDirectories.ToList(), activeCheckpointedDirectories.Concat(fullyScannedDirectories).ToHashSet(StringComparer.Ordinal), + _ancestorIgnoreDirectories.ToList(), attributePrunedDirectories.ToList()); } @@ -2136,6 +2138,13 @@ private IgnoreRuleLoadResult LoadAncestorIgnoreRules(List errors, ref var activeIgnoreRules = IgnoreRuleSet.Empty; foreach (var dir in _ancestorIgnoreDirectories) { + if (!CanReadDirectory(dir, out var reason)) + { + errors.Add(new ScanError(ToRelativePath(dir), $"Could not read ancestor ignore directory: {reason}.")); + fullyScanned = false; + return new IgnoreRuleLoadResult(activeIgnoreRules, IgnoreRulesAvailable: false); + } + var loadResult = LoadIgnoreRulesForDirectory(dir, activeIgnoreRules, errors, ref fullyScanned); activeIgnoreRules = loadResult.Rules; if (!loadResult.IgnoreRulesAvailable) @@ -2161,23 +2170,49 @@ private static IReadOnlyList BuildAncestorIgnoreDirectories(string ignor if (PathsEqual(ignoreRuleRoot, projectRoot)) return []; - var relativePath = NormalizeIgnorePath(Path.GetRelativePath(ignoreRuleRoot, projectRoot)); - if (relativePath.Length == 0 || relativePath == "." || relativePath.StartsWith("../", StringComparison.Ordinal)) + if (!IsPathEqualOrParent(ignoreRuleRoot, projectRoot)) return []; - var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments.Length == 0) - return []; + var directories = new Stack(); + var root = Path.GetFullPath(ignoreRuleRoot); + var current = Directory.GetParent(Path.GetFullPath(projectRoot)); + while (current != null) + { + directories.Push(current.FullName); + if (PathsEqual(current.FullName, root)) + return directories.ToList(); + + current = current.Parent; + } - var directories = new List { ignoreRuleRoot }; - var currentDirectory = ignoreRuleRoot; - for (var i = 0; i < segments.Length - 1; i++) + return []; + } + + private static bool CanReadDirectory(string dir, out string reason) + { + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(dir))) { - currentDirectory = Path.Combine(currentDirectory, segments[i]); - directories.Add(currentDirectory); + reason = "directory does not exist"; + return false; } - return directories; + try + { + using var enumerator = Directory.EnumerateFileSystemEntries(LongPath.EnsureWindowsPrefix(dir)).GetEnumerator(); + _ = enumerator.MoveNext(); + reason = string.Empty; + return true; + } + catch (UnauthorizedAccessException) + { + reason = "access denied"; + return false; + } + catch (IOException ex) + { + reason = ex.Message; + return false; + } } // Parse /.gitmodules and return submodule working-tree paths (and diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index d95454b002..c1d1b6f2e9 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -1511,6 +1511,76 @@ public void ScanFiles_FailsClosedWhenNestedIgnoreFileIsUnreadable() } } + [Fact] + public void ScanFilesDetailed_LoadsFullAncestorIgnoreChainAndReportsIt() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"codeindex_test_{Guid.NewGuid():N}"); + try + { + var workspace = Path.Combine(tempDir, "workspace"); + var projects = Path.Combine(workspace, "projects"); + var projectRoot = Path.Combine(projects, "subA"); + Directory.CreateDirectory(projectRoot); + File.WriteAllText(Path.Combine(workspace, ".cdidxignore"), "*.cs\n"); + File.WriteAllText(Path.Combine(projects, ".gitignore"), "!subA/App.cs\n"); + File.WriteAllText(Path.Combine(projectRoot, "App.cs"), "class App { }\n"); + File.WriteAllText(Path.Combine(projectRoot, "Other.cs"), "class Other { }\n"); + + var indexer = new FileIndexer(projectRoot, ignoreCase: false, ignoreRuleRoot: workspace); + var result = indexer.ScanFilesDetailed(); + var files = result.Files + .Select(path => Path.GetRelativePath(projectRoot, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.Equal(["App.cs"], files); + Assert.Equal([workspace, projects], result.AncestorIgnoreDirectories); + Assert.DoesNotContain(result.Errors, error => error.IsFatal); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ScanFilesDetailed_FailsClosedWhenAncestorIgnoreDirectoryIsUnreadable() + { + if (OperatingSystem.IsWindows()) + return; + + var tempDir = Path.Combine(Path.GetTempPath(), $"codeindex_test_{Guid.NewGuid():N}"); + var projects = Path.Combine(tempDir, "workspace", "projects"); + UnixFileMode? originalMode = null; + try + { + var workspace = Path.Combine(tempDir, "workspace"); + var projectRoot = Path.Combine(projects, "subA"); + Directory.CreateDirectory(projectRoot); + File.WriteAllText(Path.Combine(workspace, ".cdidxignore"), "*.cs\n"); + File.WriteAllText(Path.Combine(projectRoot, "App.cs"), "class App { }\n"); + originalMode = File.GetUnixFileMode(projects); + SetUnixPermissions(projects, UnixFileMode.None); + + var indexer = new FileIndexer(projectRoot, ignoreCase: false, ignoreRuleRoot: workspace); + var result = indexer.ScanFilesDetailed(); + + Assert.Empty(result.Files); + Assert.Contains(result.Errors, error => + error.Path == ".." + && error.Message.StartsWith("Could not read ancestor ignore directory:", StringComparison.Ordinal)); + Assert.True(result.HadErrors); + } + finally + { + if (originalMode.HasValue) + SetUnixPermissions(projects, originalMode.Value); + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + [Fact] public void ScanFilesDetailed_DoesNotMarkParentsFullyScannedWhenNestedDirectoryFails() {