Skip to content

Commit 7be0ff4

Browse files
authored
Fix ancestor ignore chain audit for issue #2024 (#2636)
2 parents d3a8691 + d507485 commit 7be0ff4

4 files changed

Lines changed: 134 additions & 12 deletions

File tree

DEVELOPER_GUIDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ Directory scan / shared path filter (built-in skip lists + `.gitignore` / `.cdid
6868
→ Populate FTS5 index
6969
```
7070

71-
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.
71+
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.
7272

7373
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 <path>` or `cdidx index <projectPath> --optimize`; this may briefly hold the writer lock on large indexes.
7474

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 2024
5+
affected:
6+
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
7+
- tests/CodeIndex.Tests/FileIndexerTests.cs
8+
- DEVELOPER_GUIDE.md
9+
---
10+
11+
## English
12+
13+
- **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.
14+
15+
## 日本語
16+
17+
- **ネストしたプロジェクトの ignore 継承が ancestor chain 全体を監査するようになりました (#2024)**`FileIndexer` は ignore-rule root から project root まで実際の親ディレクトリを辿り、ancestor ignore directory を読めない場合は安全側で失敗し、調査用に解決済み ancestor list を `ScanFilesResult` に公開します。

src/CodeIndex/Indexer/Scanning/FileIndexer.cs

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ public readonly record struct ScanFilesResult(
4747
IReadOnlyList<string> ListedDirectories,
4848
IReadOnlyList<string> FullyScannedDirectories,
4949
IReadOnlySet<string> CheckpointedDirectories,
50+
IReadOnlyList<string> AncestorIgnoreDirectories,
5051
IReadOnlyList<string> AttributePrunedDirectories)
5152
{
5253
public bool HadErrors => Errors.Any(error => error.IsFatal);
@@ -1658,6 +1659,7 @@ internal ScanFilesResult ScanFilesDetailed(
16581659
listedDirectories.ToList(),
16591660
fullyScannedDirectories.ToList(),
16601661
activeCheckpointedDirectories.Concat(fullyScannedDirectories).ToHashSet(StringComparer.Ordinal),
1662+
_ancestorIgnoreDirectories.ToList(),
16611663
attributePrunedDirectories.ToList());
16621664
}
16631665

@@ -2184,6 +2186,13 @@ private IgnoreRuleLoadResult LoadAncestorIgnoreRules(List<ScanError> errors, ref
21842186
var activeIgnoreRules = IgnoreRuleSet.Empty;
21852187
foreach (var dir in _ancestorIgnoreDirectories)
21862188
{
2189+
if (!CanReadDirectory(dir, out var reason))
2190+
{
2191+
errors.Add(new ScanError(ToRelativePath(dir), $"Could not read ancestor ignore directory: {reason}."));
2192+
fullyScanned = false;
2193+
return new IgnoreRuleLoadResult(activeIgnoreRules, IgnoreRulesAvailable: false);
2194+
}
2195+
21872196
var loadResult = LoadIgnoreRulesForDirectory(dir, activeIgnoreRules, errors, ref fullyScanned);
21882197
activeIgnoreRules = loadResult.Rules;
21892198
if (!loadResult.IgnoreRulesAvailable)
@@ -2209,23 +2218,49 @@ private static IReadOnlyList<string> BuildAncestorIgnoreDirectories(string ignor
22092218
if (PathsEqual(ignoreRuleRoot, projectRoot))
22102219
return [];
22112220

2212-
var relativePath = NormalizeIgnorePath(Path.GetRelativePath(ignoreRuleRoot, projectRoot));
2213-
if (relativePath.Length == 0 || relativePath == "." || relativePath.StartsWith("../", StringComparison.Ordinal))
2221+
if (!IsPathEqualOrParent(ignoreRuleRoot, projectRoot))
22142222
return [];
22152223

2216-
var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
2217-
if (segments.Length == 0)
2218-
return [];
2224+
var directories = new Stack<string>();
2225+
var root = Path.GetFullPath(ignoreRuleRoot);
2226+
var current = Directory.GetParent(Path.GetFullPath(projectRoot));
2227+
while (current != null)
2228+
{
2229+
directories.Push(current.FullName);
2230+
if (PathsEqual(current.FullName, root))
2231+
return directories.ToList();
2232+
2233+
current = current.Parent;
2234+
}
22192235

2220-
var directories = new List<string> { ignoreRuleRoot };
2221-
var currentDirectory = ignoreRuleRoot;
2222-
for (var i = 0; i < segments.Length - 1; i++)
2236+
return [];
2237+
}
2238+
2239+
private static bool CanReadDirectory(string dir, out string reason)
2240+
{
2241+
if (!Directory.Exists(LongPath.EnsureWindowsPrefix(dir)))
22232242
{
2224-
currentDirectory = Path.Combine(currentDirectory, segments[i]);
2225-
directories.Add(currentDirectory);
2243+
reason = "directory does not exist";
2244+
return false;
22262245
}
22272246

2228-
return directories;
2247+
try
2248+
{
2249+
using var enumerator = Directory.EnumerateFileSystemEntries(LongPath.EnsureWindowsPrefix(dir)).GetEnumerator();
2250+
_ = enumerator.MoveNext();
2251+
reason = string.Empty;
2252+
return true;
2253+
}
2254+
catch (UnauthorizedAccessException)
2255+
{
2256+
reason = "access denied";
2257+
return false;
2258+
}
2259+
catch (IOException ex)
2260+
{
2261+
reason = ex.Message;
2262+
return false;
2263+
}
22292264
}
22302265

22312266
// Parse <ignoreRuleRoot>/.gitmodules and return submodule working-tree paths (and

tests/CodeIndex.Tests/FileIndexerTests.cs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,6 +1511,76 @@ public void ScanFiles_FailsClosedWhenNestedIgnoreFileIsUnreadable()
15111511
}
15121512
}
15131513

1514+
[Fact]
1515+
public void ScanFilesDetailed_LoadsFullAncestorIgnoreChainAndReportsIt()
1516+
{
1517+
var tempDir = Path.Combine(Path.GetTempPath(), $"codeindex_test_{Guid.NewGuid():N}");
1518+
try
1519+
{
1520+
var workspace = Path.Combine(tempDir, "workspace");
1521+
var projects = Path.Combine(workspace, "projects");
1522+
var projectRoot = Path.Combine(projects, "subA");
1523+
Directory.CreateDirectory(projectRoot);
1524+
File.WriteAllText(Path.Combine(workspace, ".cdidxignore"), "*.cs\n");
1525+
File.WriteAllText(Path.Combine(projects, ".gitignore"), "!subA/App.cs\n");
1526+
File.WriteAllText(Path.Combine(projectRoot, "App.cs"), "class App { }\n");
1527+
File.WriteAllText(Path.Combine(projectRoot, "Other.cs"), "class Other { }\n");
1528+
1529+
var indexer = new FileIndexer(projectRoot, ignoreCase: false, ignoreRuleRoot: workspace);
1530+
var result = indexer.ScanFilesDetailed();
1531+
var files = result.Files
1532+
.Select(path => Path.GetRelativePath(projectRoot, path).Replace('\\', '/'))
1533+
.OrderBy(path => path, StringComparer.Ordinal)
1534+
.ToList();
1535+
1536+
Assert.Equal(["App.cs"], files);
1537+
Assert.Equal([workspace, projects], result.AncestorIgnoreDirectories);
1538+
Assert.DoesNotContain(result.Errors, error => error.IsFatal);
1539+
}
1540+
finally
1541+
{
1542+
if (Directory.Exists(tempDir))
1543+
Directory.Delete(tempDir, true);
1544+
}
1545+
}
1546+
1547+
[Fact]
1548+
public void ScanFilesDetailed_FailsClosedWhenAncestorIgnoreDirectoryIsUnreadable()
1549+
{
1550+
if (OperatingSystem.IsWindows())
1551+
return;
1552+
1553+
var tempDir = Path.Combine(Path.GetTempPath(), $"codeindex_test_{Guid.NewGuid():N}");
1554+
var projects = Path.Combine(tempDir, "workspace", "projects");
1555+
UnixFileMode? originalMode = null;
1556+
try
1557+
{
1558+
var workspace = Path.Combine(tempDir, "workspace");
1559+
var projectRoot = Path.Combine(projects, "subA");
1560+
Directory.CreateDirectory(projectRoot);
1561+
File.WriteAllText(Path.Combine(workspace, ".cdidxignore"), "*.cs\n");
1562+
File.WriteAllText(Path.Combine(projectRoot, "App.cs"), "class App { }\n");
1563+
originalMode = File.GetUnixFileMode(projects);
1564+
SetUnixPermissions(projects, UnixFileMode.None);
1565+
1566+
var indexer = new FileIndexer(projectRoot, ignoreCase: false, ignoreRuleRoot: workspace);
1567+
var result = indexer.ScanFilesDetailed();
1568+
1569+
Assert.Empty(result.Files);
1570+
Assert.Contains(result.Errors, error =>
1571+
error.Path == ".."
1572+
&& error.Message.StartsWith("Could not read ancestor ignore directory:", StringComparison.Ordinal));
1573+
Assert.True(result.HadErrors);
1574+
}
1575+
finally
1576+
{
1577+
if (originalMode.HasValue)
1578+
SetUnixPermissions(projects, originalMode.Value);
1579+
if (Directory.Exists(tempDir))
1580+
Directory.Delete(tempDir, true);
1581+
}
1582+
}
1583+
15141584
[Fact]
15151585
public void ScanFilesDetailed_DoesNotMarkParentsFullyScannedWhenNestedDirectoryFails()
15161586
{

0 commit comments

Comments
 (0)