Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` or `cdidx index <projectPath> --optimize`; this may briefly hold the writer lock on large indexes.

Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2024.fixed.md
Original file line number Diff line number Diff line change
@@ -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` に公開します。
57 changes: 46 additions & 11 deletions src/CodeIndex/Indexer/Scanning/FileIndexer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public readonly record struct ScanFilesResult(
IReadOnlyList<string> ListedDirectories,
IReadOnlyList<string> FullyScannedDirectories,
IReadOnlySet<string> CheckpointedDirectories,
IReadOnlyList<string> AncestorIgnoreDirectories,
IReadOnlyList<string> AttributePrunedDirectories)
{
public bool HadErrors => Errors.Any(error => error.IsFatal);
Expand Down Expand Up @@ -1631,6 +1632,7 @@ internal ScanFilesResult ScanFilesDetailed(
listedDirectories.ToList(),
fullyScannedDirectories.ToList(),
activeCheckpointedDirectories.Concat(fullyScannedDirectories).ToHashSet(StringComparer.Ordinal),
_ancestorIgnoreDirectories.ToList(),
attributePrunedDirectories.ToList());
}

Expand Down Expand Up @@ -2136,6 +2138,13 @@ private IgnoreRuleLoadResult LoadAncestorIgnoreRules(List<ScanError> 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)
Expand All @@ -2161,23 +2170,49 @@ private static IReadOnlyList<string> 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<string>();
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<string> { 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 <ignoreRuleRoot>/.gitmodules and return submodule working-tree paths (and
Expand Down
70 changes: 70 additions & 0 deletions tests/CodeIndex.Tests/FileIndexerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading