Skip to content

Commit a29ac30

Browse files
committed
Fix FileIndexer race and symlink handling (#1654 #1655 #1656 #1711)
1 parent 26ac4aa commit a29ac30

14 files changed

Lines changed: 379 additions & 48 deletions

DEVELOPER_GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,13 @@ The lock files for projects with zero direct `PackageReference` entries (e.g. `t
114114
### Indexing pipeline
115115

116116
```
117-
Directory scan / shared path filter (built-in skip lists + `.gitignore` / `.cdidxignore` + reparse/Windows Hidden/System attribute pruning)
117+
Directory scan / shared path filter (built-in skip lists + `.gitignore` / `.cdidxignore` + directory symlink policy + reparse/Windows Hidden/System attribute pruning)
118118
→ Parallel extraction workers (`--parallelism`, `CDIDX_INDEX_PARALLELISM`; default CPU count capped at 16) read UTF-8, split chunks, extract symbols/references, and validate content
119119
→ Single SQLite writer checks unchanged-file reuse, UPSERTs file records, runs post-extraction hooks, and inserts chunks + symbols + references + issues in per-file transactions
120120
→ Populate FTS5 index
121121
```
122122

123-
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.
123+
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. Directory symlinks default to `--follow-symlinks none`; `internal` follows only targets that resolve under the workspace root, and `all` preserves the broad historical behavior. Dangling symlinks are counted and warned separately. 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.
124124

125125
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.
126126

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1654
5+
affected:
6+
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
7+
- tests/CodeIndex.Tests/FileIndexerTests.cs
8+
---
9+
10+
## English
11+
12+
- **File reads now retry once when mtime changes during indexing (#1654)**`FileIndexer` rechecks `LastWriteTimeUtc` after reading content and retries once before persisting metadata, reducing stale mtime/content races.
13+
14+
## 日本語
15+
16+
- **index 中に mtime が変わったファイル読み取りを 1 回 retry するようになりました (#1654)**`FileIndexer` は content 読み取り後に `LastWriteTimeUtc` を再確認し、metadata 保存前に 1 回 retry することで stale mtime/content race を減らします。
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1655
5+
affected:
6+
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
7+
- tests/CodeIndex.Tests/FileIndexerTests.cs
8+
---
9+
10+
## English
11+
12+
- **Mid-scan deletes remain non-fatal purge candidates (#1655)** — files that disappear during scan probing are recorded as skipped non-indexable paths with warnings, preserving directory purge authority instead of leaving orphan rows behind.
13+
14+
## 日本語
15+
16+
- **scan 中に削除されたファイルを非 fatal な purge 候補として扱います (#1655)** — probe 中に消えたファイルは warning 付きの non-indexable path として記録され、directory purge の authority を失わず orphan row を残しにくくなります。
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 1656
5+
affected:
6+
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
7+
- src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs
8+
- src/CodeIndex/Cli/JsonOutputContracts.cs
9+
- tests/CodeIndex.Tests/FileIndexerTests.cs
10+
---
11+
12+
## English
13+
14+
- **Dangling symlinks are now reported distinctly (#1656)** — directory symlink targets that cannot be resolved are warned as dangling symlinks and counted as `dangling_symlinks_skipped` in full-scan JSON summaries.
15+
16+
## 日本語
17+
18+
- **dangling symlink を個別に報告するようになりました (#1656)** — 解決できない directory symlink target は dangling symlink として warning され、full-scan JSON summary の `dangling_symlinks_skipped` に計上されます。
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
category: added
3+
issues:
4+
- 1711
5+
affected:
6+
- src/CodeIndex/Indexer/Scanning/FileIndexer.cs
7+
- src/CodeIndex/Cli/IndexCommandRunner.Parse.cs
8+
- src/CodeIndex/Cli/ConsoleUi.cs
9+
- src/CodeIndex/Cli/IndexWatchRunner.cs
10+
- DEVELOPER_GUIDE.md
11+
- tests/CodeIndex.Tests/FileIndexerTests.cs
12+
---
13+
14+
## English
15+
16+
- **Added `--follow-symlinks none|internal|all` for directory scans (#1711)** — indexing now defaults to not following directory symlinks, can opt into workspace-internal targets, or can opt into all targets explicitly.
17+
18+
## 日本語
19+
20+
- **directory scan 向けに `--follow-symlinks none|internal|all` を追加しました (#1711)** — indexing は既定で directory symlink を辿らず、workspace 内 target のみ、または全 target を明示 opt-in できます。

src/CodeIndex/Cli/ConsoleUi.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public static class ConsoleUi
6060

6161
private static readonly (string Command, string Usage)[] CommandUsageLines =
6262
[
63-
("index", "cdidx index <projectPath> [--db <path>] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]] [--watch [--debounce <ms>]]"),
63+
("index", "cdidx index <projectPath> [--db <path>] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--follow-symlinks <none|internal|all>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]] [--watch [--debounce <ms>]]"),
6464
("hooks", "cdidx hooks <install|uninstall|status> [--project <path>] [--force] [--json]"),
6565
("backfill-fold", "cdidx backfill-fold [--db <path>] [--json]"),
6666
("optimize", "cdidx optimize [--db <path>] [--json]"),
@@ -776,6 +776,7 @@ private static void PrintFlagReference(Action<string> WriteHelpLine)
776776
Console.WriteLine(" --duration-format <format> Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms");
777777
WriteHelpLine(" --max-file-bytes <bytes> Index only files up to this size (default: 4MiB; also honors CDIDX_MAX_FILE_BYTES; accepts K/M/G suffixes)");
778778
WriteHelpLine(" --parallelism <n> Full-scan extraction workers (default: CPU count capped at 16; also honors CDIDX_INDEX_PARALLELISM)");
779+
WriteHelpLine(" --follow-symlinks <mode> Directory symlink policy: none (default), internal, or all");
779780
WriteHelpLine(" --include-symbol-kind <kind>[,<kind>] Keep only matching symbol kinds during indexing");
780781
WriteHelpLine(" --exclude-symbol-kind <kind>[,<kind>] Drop matching symbol kinds during indexing");
781782
Console.WriteLine(" --commits <id> [id ...] Update only files changed in the specified git commits (preferred after commits)");

src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ private static int RunDryRun(
1515
CancellationToken cancellationToken)
1616
{
1717
var projectPath = options.ProjectPath!;
18-
var dryIndexer = new FileIndexer(projectPath, ignoreCase, ignoreRuleRoot, options.MaxFileSizeBytes);
18+
var dryIndexer = new FileIndexer(projectPath, ignoreCase, ignoreRuleRoot, options.MaxFileSizeBytes, directoryIgnoreCaseProbe: null, symlinkPolicy: options.SymlinkPolicy);
1919
IReadOnlyList<string> dryCandidates;
2020
var errorList = new List<CliJsonMessage>();
2121
var dryScanErrorKeys = new HashSet<string>(StringComparer.Ordinal);

src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1325,6 +1325,7 @@ void StopJsonHeartbeat()
13251325
FilesScanned = files.Count,
13261326
FilesSkipped = skipped,
13271327
FilesPurged = purged,
1328+
DanglingSymlinksSkipped = scanResult.DanglingSymlinks.Count,
13281329
Warnings = warnings,
13291330
Errors = errors,
13301331
SymbolsDroppedByKindFilter = symbolsDroppedByKindFilter,
@@ -1370,6 +1371,7 @@ void StopJsonHeartbeat()
13701371
Console.WriteLine(ConsoleUi.FormatSummaryLine("Symbols", $"{totalSymbols:N0}", indent: " "));
13711372
Console.WriteLine(ConsoleUi.FormatSummaryLine("Refs", $"{totalReferences:N0}", indent: " "));
13721373
if (skipped > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Skipped", $"{skipped:N0} (unchanged)", indent: " "));
1374+
if (scanResult.DanglingSymlinks.Count > 0) Console.WriteLine(ConsoleUi.FormatSummaryLine("Dangling symlinks", $"{scanResult.DanglingSymlinks.Count:N0} skipped", indent: " "));
13731375
if (options.Verbose && scanResult.UnknownExtensionFiles.Count > 0)
13741376
{
13751377
Console.WriteLine($" Unknown extension files: {scanResult.UnknownExtensionFiles.Count:N0}");

src/CodeIndex/Cli/IndexCommandRunner.Parse.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public static partial class IndexCommandRunner
1414
[
1515
"--db", "--data-dir", "--rebuild", "--verbose", "--json", "--dry-run", "--force",
1616
"--yes", "--watch", "--debounce", "--duration-format", "--max-file-bytes",
17-
"--parallelism",
17+
"--parallelism", "--follow-symlinks",
1818
"--commits", "--changed-between", "--files", "--solution", "--project",
1919
"--include-symbol-kind", "--exclude-symbol-kind", "--optimize", "--help",
2020
"--read-only", "--immutable",
@@ -41,6 +41,7 @@ public static IndexCommandOptions ParseArgs(string[] args)
4141
var durationFormat = DurationOutputFormat.Auto;
4242
long? maxFileSizeBytes = ReadMaxFileSizeBytesFromEnvironment();
4343
var parallelism = ReadIndexParallelismFromEnvironment();
44+
var symlinkPolicy = FileIndexer.SymlinkPolicy.None;
4445
string? easterEgg = null;
4546
int spinnerFlagCount = 0;
4647
bool randomSpinner = false;
@@ -144,6 +145,12 @@ public static IndexCommandOptions ParseArgs(string[] args)
144145
case var option when option.StartsWith("--parallelism=", StringComparison.Ordinal):
145146
parallelism = ParseIndexParallelism(option["--parallelism=".Length..], parallelism, "--parallelism");
146147
break;
148+
case "--follow-symlinks" when i + 1 < args.Length:
149+
symlinkPolicy = ParseSymlinkPolicy(args[++i], symlinkPolicy, ref parseError);
150+
break;
151+
case var option when option.StartsWith("--follow-symlinks=", StringComparison.Ordinal):
152+
symlinkPolicy = ParseSymlinkPolicy(option["--follow-symlinks=".Length..], symlinkPolicy, ref parseError);
153+
break;
147154
case "--commits":
148155
while (i + 1 < args.Length && !args[i + 1].StartsWith('-'))
149156
{
@@ -289,10 +296,27 @@ public static IndexCommandOptions ParseArgs(string[] args)
289296
DurationFormat = durationFormat,
290297
MaxFileSizeBytes = maxFileSizeBytes,
291298
Parallelism = parallelism,
299+
SymlinkPolicy = symlinkPolicy,
292300
SymbolKindFilter = SymbolKindFilter.Create(includeSymbolKinds, excludeSymbolKinds, symbolKindFilterError),
293301
};
294302
}
295303

304+
private static FileIndexer.SymlinkPolicy ParseSymlinkPolicy(string value, FileIndexer.SymlinkPolicy fallback, ref string? parseError)
305+
{
306+
switch (value.Trim().ToLowerInvariant())
307+
{
308+
case "none":
309+
return FileIndexer.SymlinkPolicy.None;
310+
case "internal":
311+
return FileIndexer.SymlinkPolicy.Internal;
312+
case "all":
313+
return FileIndexer.SymlinkPolicy.All;
314+
default:
315+
parseError ??= $"invalid --follow-symlinks value '{value}': expected none, internal, or all";
316+
return fallback;
317+
}
318+
}
319+
296320
private static string BuildUnknownIndexOptionError(string token)
297321
{
298322
var name = TrimInlineValue(token);

src/CodeIndex/Cli/IndexCommandRunner.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C
239239
AddToGitExclude(options.ProjectPath, dbPath);
240240

241241
var writer = new DbWriter(db);
242-
var indexer = new FileIndexer(options.ProjectPath, ignoreCase, ignoreRuleRoot, options.MaxFileSizeBytes);
242+
var indexer = new FileIndexer(options.ProjectPath, ignoreCase, ignoreRuleRoot, options.MaxFileSizeBytes, directoryIgnoreCaseProbe: null, symlinkPolicy: options.SymlinkPolicy);
243243
var currentHotspotFamilyMarkerFingerprints = GetHotspotFamilyMarkerFingerprints(indexer);
244244
var projectRoot = Path.GetFullPath(options.ProjectPath!);
245245

@@ -1172,6 +1172,7 @@ public sealed class IndexCommandOptions
11721172
public DurationOutputFormat DurationFormat { get; init; } = DurationOutputFormat.Auto;
11731173
public long? MaxFileSizeBytes { get; init; }
11741174
public int Parallelism { get; init; } = IndexCommandRunner.DefaultIndexParallelism();
1175+
public FileIndexer.SymlinkPolicy SymlinkPolicy { get; init; } = FileIndexer.SymlinkPolicy.None;
11751176
public SymbolKindFilter SymbolKindFilter { get; init; } = SymbolKindFilter.Empty;
11761177
}
11771178

0 commit comments

Comments
 (0)