From 9e8c9f594714660935be46a6584e3772feea0fd8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:29:48 +0900 Subject: [PATCH 1/8] Fix ignore leading whitespace parsing (#1620) --- changelog.d/unreleased/1620.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 2 ++ tests/CodeIndex.Tests/FileIndexerTests.cs | 10 +++++----- 3 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/1620.fixed.md diff --git a/changelog.d/unreleased/1620.fixed.md b/changelog.d/unreleased/1620.fixed.md new file mode 100644 index 0000000000..420fb82ccb --- /dev/null +++ b/changelog.d/unreleased/1620.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1620 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs +--- + +## English + +- **Ignore parsing now follows leading-whitespace comment and pattern rules (#1620)** — `.gitignore` and `.cdidxignore` lines now trim leading unescaped whitespace before detecting comments or patterns, while escaped leading spaces and escaped `#` remain literal pattern characters. + +## 日本語 + +- **ignore parsing が先頭空白つきのコメントとパターン規則に従うようになりました (#1620)** — `.gitignore` / `.cdidxignore` の行はコメントやパターン判定の前に未エスケープの先頭空白を取り除き、エスケープされた先頭空白と `#` は引き続きリテラルのパターン文字として扱います。 diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index a3b737ee30..9356d8ff29 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -596,6 +596,8 @@ private static bool TryTokenize(string rawLine, out List tokens) while (tokens.Count > 0 && tokens[^1] is { Value: ' ' or '\t', Escaped: false }) tokens.RemoveAt(tokens.Count - 1); + while (tokens.Count > 0 && tokens[0] is { Value: ' ' or '\t', Escaped: false }) + tokens.RemoveAt(0); return tokens.Count > 0; } diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 867fe0b024..80e6db7196 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -1890,11 +1890,11 @@ public void ScanFiles_HandlesGitIgnoreWhitespaceLikeGit() File.WriteAllText( Path.Combine(tempDir, ".gitignore"), " #*.py\n *.py\n*.cs\t\n"); - File.WriteAllText(Path.Combine(tempDir, " #x.py"), "print('ignored because leading-space # is literal')"); - File.WriteAllText(Path.Combine(tempDir, "a.py"), "print('kept because leading spaces are literal')"); - File.WriteAllText(Path.Combine(tempDir, " a.py"), "print('ignored by leading-space pattern')"); + File.WriteAllText(Path.Combine(tempDir, " #x.py"), "print('kept because leading-space # is a comment')"); + File.WriteAllText(Path.Combine(tempDir, "a.py"), "print('ignored after leading-space trim')"); + File.WriteAllText(Path.Combine(tempDir, " a.py"), "print('ignored by trimmed basename pattern')"); File.WriteAllText(Path.Combine(tempDir, "a.cs"), "public class IgnoredAfterTrailingTabTrim { }"); - File.WriteAllText(Path.Combine(tempDir, "keep.py"), "print('kept')"); + File.WriteAllText(Path.Combine(tempDir, "keep.js"), "export const kept = true;"); var indexer = new FileIndexer(tempDir); var files = indexer.ScanFiles() @@ -1902,7 +1902,7 @@ public void ScanFiles_HandlesGitIgnoreWhitespaceLikeGit() .OrderBy(path => path, StringComparer.Ordinal) .ToList(); - Assert.Equal([".gitignore", "a.py", "keep.py"], files); + Assert.Equal([".gitignore", "keep.js"], files); } finally { From 4d6c9aa81602d3a591e66c4504da2def6af67fa3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:30:06 +0900 Subject: [PATCH 2/8] Document cdidxignore semantics (#1621) --- USER_GUIDE.md | 4 ++++ changelog.d/unreleased/1621.docs.md | 16 ++++++++++++++++ src/CodeIndex/Cli/ConsoleUi.cs | 1 + 3 files changed, 21 insertions(+) create mode 100644 changelog.d/unreleased/1621.docs.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 24b1a62d65..96485b806b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1334,6 +1334,10 @@ Successful narrow update JSON reports `mode: "update"` with `summary.updated`, ` `cdidx index` automatically adds `.cdidx/` to `.git/info/exclude`. You don't need to edit `.gitignore` just to hide the local index, and user-authored `.gitignore` rules are honored during scanning and scoped updates. If you want cdidx-only exclusions without changing Git behavior, add a `.cdidxignore` file. +### Project-local ignore + +Place `.cdidxignore` in the project root or any subdirectory to add cdidx-only ignore rules without changing Git behavior. It uses the same Git-style pattern syntax as `.gitignore`: leading unescaped spaces and tabs are ignored, `#` comments are recognized after that leading whitespace, trailing unescaped whitespace is trimmed, and `\#` or `\ ` keep literal characters in the pattern. In each directory, `.gitignore` is loaded first and `.cdidxignore` second, so `.cdidxignore` extends the active rules and later `!` patterns can re-include paths ignored earlier in the same directory scope. A project-root `.codeindex/.cdidxignore` is also loaded as a workspace-scoped ignore file for cdidx-only rules that should not sit at the repository root. + `.git/info/exclude` is a standard Git mechanism that works just like `.gitignore`. Many tools use `.git/info/exclude` or store data inside `.git/` to avoid polluting `.gitignore` — git-lfs, git-secret, git-crypt, git-annex, Husky, pre-commit, JetBrains IDEs, VS Code (GitLens), Eclipse, etc. ## Git branch switching diff --git a/changelog.d/unreleased/1621.docs.md b/changelog.d/unreleased/1621.docs.md new file mode 100644 index 0000000000..d7c2f08f62 --- /dev/null +++ b/changelog.d/unreleased/1621.docs.md @@ -0,0 +1,16 @@ +--- +category: docs +issues: + - 1621 +affected: + - USER_GUIDE.md + - src/CodeIndex/Cli/ConsoleUi.cs +--- + +## English + +- **Documented project-local `.cdidxignore` semantics (#1621)** — the user guide and CLI help now describe where `.cdidxignore` is loaded, its Git-style syntax, and how it extends `.gitignore` rules. + +## 日本語 + +- **project-local `.cdidxignore` の意味論を文書化しました (#1621)** — user guide と CLI help で、`.cdidxignore` の読み込み場所、Git 形式の構文、`.gitignore` ルールを拡張する扱いを説明しました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index d12896ca83..eee292571b 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -682,6 +682,7 @@ void WriteHelpLine(string line = "") Console.WriteLine(); Console.WriteLine("Index and update options:"); Console.WriteLine(" --db Database file path (default for index: /.cdidx/codeindex.db)"); + WriteHelpLine(" .cdidxignore Optional project-local ignore file; loaded after .gitignore in each directory"); Console.WriteLine(" --rebuild Delete existing DB and rebuild from scratch"); Console.WriteLine(" --verbose Show per-file status ([OK ]/[SKIP]/[DEL ]/[ERR ])"); Console.WriteLine(" --dry-run Scan files without writing to the database"); From a92fd18fbbfbc45088ed6a02938d3f0c3f986667 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:31:23 +0900 Subject: [PATCH 3/8] Harden ignore file reads against TOCTOU (#1653) --- DEVELOPER_GUIDE.md | 4 +++- changelog.d/unreleased/1653.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 15 +++++++-------- 3 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/1653.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index abed5af659..371f9c87de 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -93,7 +93,9 @@ Hook failures are isolated to that hook invocation: assembly load, construction, ### Ignore file parsing -`.gitignore` and `.cdidxignore` parsing follows Git's whitespace rules for pattern lines: leading spaces and tabs are literal pattern characters, `#` starts a comment only when it is the first unescaped character, and unescaped trailing spaces or tabs are trimmed. Escape a trailing space or tab with `\` when the whitespace is part of the filename pattern. +`.gitignore` and `.cdidxignore` parsing follows Git's whitespace rules for pattern lines: leading unescaped spaces and tabs are ignored before comment/pattern parsing, `#` starts a comment only when it is the first unescaped character after that trim, and unescaped trailing spaces or tabs are trimmed. Escape a leading, trailing, or `#` character with `\` when it is part of the filename pattern. + +Ignore-file reads avoid `File.Exists` / `File.ReadLines` time-of-check/time-of-use races: the scanner attempts the UTF-8 read directly, treats missing files as absent rules, treats permission-denied ignore files as warnings while preserving inherited ancestor rules, and treats other I/O failures as unavailable rules so callers can avoid indexing with stale or unknown local ignore state. Ignore patterns are capped at 512 tokens and compiled with the non-backtracking regex engine plus a match timeout so malformed or untrusted ignore files cannot stall a scan with excessive regex work. Bracket expressions follow Git-compatible glob behavior: both `[!a]` and `[^a]` are treated as negated character classes when `!` or `^` appears immediately after `[`. A caret elsewhere in the class is literal (`[a^b]`), and a literal leading caret must be escaped (`[\^a]`). diff --git a/changelog.d/unreleased/1653.fixed.md b/changelog.d/unreleased/1653.fixed.md new file mode 100644 index 0000000000..e1341a0f8e --- /dev/null +++ b/changelog.d/unreleased/1653.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1653 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Ignore-file reads now avoid exists/read TOCTOU gaps (#1653)** — the scanner reads ignore files directly, treats files deleted during the read window as absent rules, and keeps other I/O failures from silently indexing with unknown local ignore state. + +## 日本語 + +- **ignore file 読み込みが exists/read の TOCTOU 差分を避けるようになりました (#1653)** — scanner は ignore file を直接読み、読み込み窓で削除されたファイルはルールなしとして扱い、その他の I/O 失敗では未知のローカル ignore 状態で黙って index しないようにしました。 diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 9356d8ff29..5178736a69 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -2232,8 +2232,6 @@ private IgnoreRuleLoadResult LoadIgnoreRulesForDirectory( { var ignorePath = Path.Combine(dir, ignoreFileName); var prefixedIgnorePath = LongPath.EnsureWindowsPrefix(ignorePath); - if (!File.Exists(prefixedIgnorePath)) - continue; try { @@ -2249,9 +2247,13 @@ private IgnoreRuleLoadResult LoadIgnoreRulesForDirectory( } catch (UnauthorizedAccessException) { - errors.Add(new ScanError(ToRelativePath(ignorePath), $"Could not read {ignoreFileName}.")); - fullyScanned = false; - ignoreRulesAvailable = false; + errors.Add(new ScanError(ToRelativePath(ignorePath), $"Could not read {ignoreFileName} due to permissions.", ScanIssueSeverity.Warning)); + } + catch (FileNotFoundException) + { + } + catch (DirectoryNotFoundException) + { } catch (IOException) { @@ -2272,9 +2274,6 @@ private IgnoreRuleLoadResult LoadWorkspaceConfigIgnoreRules( ref bool fullyScanned) { var configIgnorePath = Path.Combine(_projectRoot, ".codeindex", ".cdidxignore"); - if (!File.Exists(LongPath.EnsureWindowsPrefix(configIgnorePath))) - return new IgnoreRuleLoadResult(inheritedIgnoreRules, IgnoreRulesAvailable: true); - return LoadIgnoreRulesFile( sourceDirectory: _projectRoot, ignorePath: configIgnorePath, From 206f50a54301bf253f9c77252b003e5758068967 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:32:26 +0900 Subject: [PATCH 4/8] Preserve ignore inheritance on permission errors (#1657) --- changelog.d/unreleased/1657.fixed.md | 16 ++++++++++++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 13 +++++++--- tests/CodeIndex.Tests/FileIndexerTests.cs | 25 ++++++++++++++----- 3 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 changelog.d/unreleased/1657.fixed.md diff --git a/changelog.d/unreleased/1657.fixed.md b/changelog.d/unreleased/1657.fixed.md new file mode 100644 index 0000000000..cddccf425d --- /dev/null +++ b/changelog.d/unreleased/1657.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1657 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs +--- + +## English + +- **Permission-denied ignore files now preserve inherited rules (#1657)** — unreadable `.gitignore` / `.cdidxignore` files are reported as warnings while previously loaded ancestor rules remain active for the scan. + +## 日本語 + +- **権限不足の ignore file でも継承済みルールを維持するようになりました (#1657)** — 読み取れない `.gitignore` / `.cdidxignore` は warning として報告しつつ、既に読み込まれた ancestor rules は scan 中に有効なまま維持します。 diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 5178736a69..52d405f973 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -2329,9 +2329,16 @@ private IgnoreRuleLoadResult LoadIgnoreRulesFile( } catch (UnauthorizedAccessException) { - errors.Add(new ScanError(ToRelativePath(ignorePath), $"Could not read {ignoreFileName}.")); - fullyScanned = false; - return new IgnoreRuleLoadResult(inheritedIgnoreRules, IgnoreRulesAvailable: false); + errors.Add(new ScanError(ToRelativePath(ignorePath), $"Could not read {ignoreFileName} due to permissions.", ScanIssueSeverity.Warning)); + return new IgnoreRuleLoadResult(inheritedIgnoreRules, IgnoreRulesAvailable: true); + } + catch (FileNotFoundException) + { + return new IgnoreRuleLoadResult(inheritedIgnoreRules, IgnoreRulesAvailable: true); + } + catch (DirectoryNotFoundException) + { + return new IgnoreRuleLoadResult(inheritedIgnoreRules, IgnoreRulesAvailable: true); } catch (IOException) { diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 80e6db7196..08c461c6e0 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -1608,7 +1608,7 @@ public void BuildRecord_NormalizesRelativePathToNfc() } [Fact] - public void ScanFiles_FailsClosedWhenRootIgnoreFileIsUnreadable() + public void ScanFiles_PreservesInheritedRulesWhenRootIgnoreFileIsUnreadable() { if (OperatingSystem.IsWindows()) return; @@ -1628,8 +1628,17 @@ public void ScanFiles_FailsClosedWhenRootIgnoreFileIsUnreadable() var indexer = new FileIndexer(tempDir); var result = indexer.ScanFilesDetailed(); - Assert.Empty(result.Files); - Assert.Contains(result.Errors, error => error.Path == ".gitignore" && error.Message == "Could not read .gitignore."); + var files = result.Files + .Select(path => Path.GetRelativePath(tempDir, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.Equal([".gitignore", "keep.py", "secret.py"], files); + Assert.Contains(result.Errors, error => + error.Path == ".gitignore" && + error.Message == "Could not read .gitignore due to permissions." && + error.Severity == FileIndexer.ScanIssueSeverity.Warning); + Assert.False(result.HadErrors); } finally { @@ -1641,7 +1650,7 @@ public void ScanFiles_FailsClosedWhenRootIgnoreFileIsUnreadable() } [Fact] - public void ScanFiles_FailsClosedWhenNestedIgnoreFileIsUnreadable() + public void ScanFiles_PreservesInheritedRulesWhenNestedIgnoreFileIsUnreadable() { if (OperatingSystem.IsWindows()) return; @@ -1667,8 +1676,12 @@ public void ScanFiles_FailsClosedWhenNestedIgnoreFileIsUnreadable() .OrderBy(path => path, StringComparer.Ordinal) .ToList(); - Assert.Equal(["keep.py"], files); - Assert.Contains(result.Errors, error => error.Path == "src/.gitignore" && error.Message == "Could not read .gitignore."); + Assert.Equal(["keep.py", "src/.gitignore", "src/keep_nested.py", "src/secret.py"], files); + Assert.Contains(result.Errors, error => + error.Path == "src/.gitignore" && + error.Message == "Could not read .gitignore due to permissions." && + error.Severity == FileIndexer.ScanIssueSeverity.Warning); + Assert.False(result.HadErrors); } finally { From ba3a241d6f44e620cd4abfe92ed22176a355ceea Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:32:44 +0900 Subject: [PATCH 5/8] Bound ignore pattern regex complexity (#1658) --- changelog.d/unreleased/1658.fixed.md | 17 ++++++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 13 ++++- tests/CodeIndex.Tests/FileIndexerTests.cs | 56 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/1658.fixed.md diff --git a/changelog.d/unreleased/1658.fixed.md b/changelog.d/unreleased/1658.fixed.md new file mode 100644 index 0000000000..25adda1549 --- /dev/null +++ b/changelog.d/unreleased/1658.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1658 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Ignore-pattern regexes now have complexity bounds (#1658)** — ignore rules are capped in length and compiled with the non-backtracking regex engine plus a match timeout to avoid excessive regex work from malformed patterns. + +## 日本語 + +- **ignore pattern regex に複雑性上限を追加しました (#1658)** — malformed pattern による過剰な regex 処理を避けるため、ignore rule の長さを制限し、non-backtracking regex engine と match timeout でコンパイルします。 diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 52d405f973..456f008b71 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -79,6 +79,8 @@ PathFilterKind.ExcludedByDefaultFile or private static readonly string[] HotspotFamilyMarkerLanguages = ["csharp", "vb", "fsharp", "msbuild"]; private const int ConflictMarkerScanLimitBytes = 50 * 1024; private static readonly string[] IgnoreFileNames = [".gitignore", ".cdidxignore"]; + private const int MaxIgnorePatternLength = 512; + private static readonly TimeSpan IgnoreRegexMatchTimeout = TimeSpan.FromMilliseconds(100); // Extension-to-language mapping / 拡張子→言語名マッピング private static readonly Dictionary LangMap = new(StringComparer.OrdinalIgnoreCase) { @@ -496,6 +498,12 @@ internal static bool TryParse(string sourceDirectory, string rawLine, bool ignor if (!TryTokenize(rawLine, out var tokens)) return false; + if (tokens.Count > MaxIgnorePatternLength) + { + errorMessage = $"Invalid ignore rule skipped: pattern exceeds {MaxIgnorePatternLength} characters"; + return false; + } + if (tokens[0] is { Value: '#', Escaped: false }) return false; @@ -665,7 +673,10 @@ private static Regex BuildMatcher(IReadOnlyList pattern, bool igno } builder.Append('$'); - return new Regex(builder.ToString(), RegexOptions.CultureInvariant | RegexOptions.Compiled); + return new Regex( + builder.ToString(), + RegexOptions.CultureInvariant | RegexOptions.Compiled | RegexOptions.NonBacktracking, + IgnoreRegexMatchTimeout); } private static bool TryBuildCharacterClass(IReadOnlyList pattern, ref int index, StringBuilder builder, bool ignoreCase) diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 08c461c6e0..3a38ad47c0 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -1445,6 +1445,62 @@ public void ScanFiles_RespectsGitignorePatternsAndNegation() } } + [Fact] + public void ScanFiles_TrimsLeadingWhitespaceBeforeParsingIgnoreLines() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"codeindex_test_{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(tempDir); + File.WriteAllText(Path.Combine(tempDir, ".gitignore"), " # comment\n *.tmp\n\\ leading.py\n\\#literal.py\n", Encoding.UTF8); + File.WriteAllText(Path.Combine(tempDir, "keep.py"), "print('keep')"); + File.WriteAllText(Path.Combine(tempDir, "ignored.tmp"), "ignored"); + File.WriteAllText(Path.Combine(tempDir, " leading.py"), "print('literal leading space')"); + File.WriteAllText(Path.Combine(tempDir, "#literal.py"), "print('literal hash')"); + + var indexer = new FileIndexer(tempDir); + var files = indexer.ScanFiles() + .Select(path => Path.GetRelativePath(tempDir, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.Equal([".gitignore", "keep.py"], files); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public void ScanFiles_ReportsOverlongIgnorePatternAndContinues() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"codeindex_test_{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(tempDir); + File.WriteAllText(Path.Combine(tempDir, ".gitignore"), $"{new string('a', 513)}\n*.tmp\n", Encoding.UTF8); + File.WriteAllText(Path.Combine(tempDir, "keep.py"), "print('keep')"); + File.WriteAllText(Path.Combine(tempDir, "ignored.tmp"), "ignored"); + + var result = new FileIndexer(tempDir).ScanFilesDetailed(); + var files = result.Files + .Select(path => Path.GetRelativePath(tempDir, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + Assert.Equal([".gitignore", "keep.py"], files); + var warning = Assert.Single(result.Errors); + Assert.Equal(FileIndexer.ScanIssueSeverity.Warning, warning.Severity); + Assert.Contains("pattern exceeds 512 characters", warning.Message); + Assert.False(result.HadErrors); + } + finally + { + Directory.Delete(tempDir, true); + } + } + [Fact] public void ScanFiles_RespectsCdidxignoreAndNestedGitignore() { From 19781e1a88667871f5a18a7f19273ff421b6d9fc Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:08:12 +0900 Subject: [PATCH 6/8] Fix CI expectations for unreadable ignore warnings (#1657) --- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 6 ++ .../IndexCommandRunnerTests.cs | 93 ++++++++++--------- 2 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 456f008b71..cfe610a0c4 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -2258,6 +2258,9 @@ private IgnoreRuleLoadResult LoadIgnoreRulesForDirectory( } catch (UnauthorizedAccessException) { + if (!File.Exists(prefixedIgnorePath)) + throw; + errors.Add(new ScanError(ToRelativePath(ignorePath), $"Could not read {ignoreFileName} due to permissions.", ScanIssueSeverity.Warning)); } catch (FileNotFoundException) @@ -2340,6 +2343,9 @@ private IgnoreRuleLoadResult LoadIgnoreRulesFile( } catch (UnauthorizedAccessException) { + if (!File.Exists(prefixedIgnorePath)) + throw; + errors.Add(new ScanError(ToRelativePath(ignorePath), $"Could not read {ignoreFileName} due to permissions.", ScanIssueSeverity.Warning)); return new IgnoreRuleLoadResult(inheritedIgnoreRules, IgnoreRulesAvailable: true); } diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 1328b3d0ae..9a5d8f19a6 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -4257,15 +4257,16 @@ public void Run_UpdateMode_WithFiles_SkipsMutationWhenIgnoreRulesAreUnreadable() var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "secret.py", "--json"]); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("partial", json.GetProperty("status").GetString()); - Assert.Equal(0, json.GetProperty("summary").GetProperty("updated").GetInt32()); + Assert.Equal("success", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("updated").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("removed").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("skipped").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("errors").GetInt32()); - Assert.Equal(".gitignore", json.GetProperty("errors")[0].GetProperty("file").GetString()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("skipped").GetInt32()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.Equal(".gitignore", json.GetProperty("warnings")[0].GetProperty("file").GetString()); var indexedPaths = ReadIndexedPaths(Path.Combine(projectRoot, ".cdidx", "codeindex.db")); - Assert.DoesNotContain("secret.py", indexedPaths); + Assert.Contains("secret.py", indexedPaths); } finally { @@ -4311,15 +4312,16 @@ public void Run_UpdateMode_WithCommits_SkipsMutationWhenIgnoreRulesAreUnreadable var (exitCode, json) = RunAndCaptureJson([projectRoot, "--commits", commitId, "--json"]); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("partial", json.GetProperty("status").GetString()); + Assert.Equal("success", json.GetProperty("status").GetString()); Assert.Equal(0, json.GetProperty("summary").GetProperty("updated").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("removed").GetInt32()); Assert.Equal(1, json.GetProperty("summary").GetProperty("skipped").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("errors").GetInt32()); - Assert.False(json.GetProperty("graph_table_available").GetBoolean()); - Assert.False(json.GetProperty("issues_table_available").GetBoolean()); - Assert.False(json.GetProperty("fold_ready").GetBoolean()); - Assert.Equal(".gitignore", json.GetProperty("errors")[0].GetProperty("file").GetString()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.True(json.GetProperty("graph_table_available").GetBoolean()); + Assert.True(json.GetProperty("issues_table_available").GetBoolean()); + Assert.True(json.GetProperty("fold_ready").GetBoolean()); + Assert.Equal(".gitignore", json.GetProperty("warnings")[0].GetProperty("file").GetString()); var indexedPaths = ReadIndexedPaths(Path.Combine(projectRoot, ".cdidx", "codeindex.db")); Assert.Contains("secret.py", indexedPaths); @@ -4327,9 +4329,9 @@ public void Run_UpdateMode_WithCommits_SkipsMutationWhenIgnoreRulesAreUnreadable var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); var (statusExitCode, statusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, statusExitCode); - Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); + Assert.True(statusJson.GetProperty("graph_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("fold_ready").GetBoolean()); } finally { @@ -4366,21 +4368,22 @@ public void Run_UpdateMode_WithFiles_UnreadableIgnoreRulesDemoteReadinessForUnch var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "keep.py", "--json"]); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("partial", json.GetProperty("status").GetString()); + Assert.Equal("success", json.GetProperty("status").GetString()); Assert.Equal(0, json.GetProperty("summary").GetProperty("updated").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("removed").GetInt32()); Assert.Equal(1, json.GetProperty("summary").GetProperty("skipped").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("errors").GetInt32()); - Assert.Equal(".gitignore", json.GetProperty("errors")[0].GetProperty("file").GetString()); - Assert.False(json.GetProperty("graph_table_available").GetBoolean()); - Assert.False(json.GetProperty("issues_table_available").GetBoolean()); - Assert.False(json.GetProperty("fold_ready").GetBoolean()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.Equal(".gitignore", json.GetProperty("warnings")[0].GetProperty("file").GetString()); + Assert.True(json.GetProperty("graph_table_available").GetBoolean()); + Assert.True(json.GetProperty("issues_table_available").GetBoolean()); + Assert.True(json.GetProperty("fold_ready").GetBoolean()); var (statusExitCode, statusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, statusExitCode); - Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); + Assert.True(statusJson.GetProperty("graph_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("fold_ready").GetBoolean()); } finally { @@ -4417,22 +4420,23 @@ public void Run_UpdateMode_WithFiles_DemotesReadinessWhenIgnoreFileChangedThenBe var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "a.cs", "--json"]); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("partial", json.GetProperty("status").GetString()); + Assert.Equal("success", json.GetProperty("status").GetString()); Assert.Equal(0, json.GetProperty("summary").GetProperty("updated").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("removed").GetInt32()); Assert.Equal(1, json.GetProperty("summary").GetProperty("skipped").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("errors").GetInt32()); - Assert.Equal(".gitignore", json.GetProperty("errors")[0].GetProperty("file").GetString()); - Assert.False(json.GetProperty("graph_table_available").GetBoolean()); - Assert.False(json.GetProperty("issues_table_available").GetBoolean()); - Assert.False(json.GetProperty("fold_ready").GetBoolean()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.Equal(".gitignore", json.GetProperty("warnings")[0].GetProperty("file").GetString()); + Assert.True(json.GetProperty("graph_table_available").GetBoolean()); + Assert.True(json.GetProperty("issues_table_available").GetBoolean()); + Assert.True(json.GetProperty("fold_ready").GetBoolean()); Assert.Contains("a.cs", ReadIndexedPaths(dbPath)); var (statusExitCode, statusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, statusExitCode); - Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); + Assert.True(statusJson.GetProperty("graph_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("fold_ready").GetBoolean()); } finally { @@ -4471,21 +4475,22 @@ public void Run_UpdateMode_WithFiles_UnreadableIgnoreRulesDemoteReadinessForChan var (exitCode, json) = RunAndCaptureJson([projectRoot, "--files", "keep.py", "--json"]); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal("partial", json.GetProperty("status").GetString()); - Assert.Equal(0, json.GetProperty("summary").GetProperty("updated").GetInt32()); + Assert.Equal("success", json.GetProperty("status").GetString()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("updated").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("removed").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("skipped").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("errors").GetInt32()); - Assert.Equal(".gitignore", json.GetProperty("errors")[0].GetProperty("file").GetString()); - Assert.False(json.GetProperty("graph_table_available").GetBoolean()); - Assert.False(json.GetProperty("issues_table_available").GetBoolean()); - Assert.False(json.GetProperty("fold_ready").GetBoolean()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("skipped").GetInt32()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.Equal(".gitignore", json.GetProperty("warnings")[0].GetProperty("file").GetString()); + Assert.True(json.GetProperty("graph_table_available").GetBoolean()); + Assert.True(json.GetProperty("issues_table_available").GetBoolean()); + Assert.True(json.GetProperty("fold_ready").GetBoolean()); var (statusExitCode, statusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, statusExitCode); - Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); + Assert.True(statusJson.GetProperty("graph_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); + Assert.True(statusJson.GetProperty("fold_ready").GetBoolean()); } finally { From ef0a95d2310b8d74d865fdb977c21f41e9ace81f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:42:05 +0900 Subject: [PATCH 7/8] Fix commit update warning test expectation (#1657) --- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 9a5d8f19a6..83b7ed6b7a 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -4313,9 +4313,9 @@ public void Run_UpdateMode_WithCommits_SkipsMutationWhenIgnoreRulesAreUnreadable Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal("success", json.GetProperty("status").GetString()); - Assert.Equal(0, json.GetProperty("summary").GetProperty("updated").GetInt32()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("updated").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("removed").GetInt32()); - Assert.Equal(1, json.GetProperty("summary").GetProperty("skipped").GetInt32()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("skipped").GetInt32()); Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); Assert.True(json.GetProperty("graph_table_available").GetBoolean()); From 76a64fb3ef2ebb44182c929621b9cd62b561a2b0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:32:11 +0900 Subject: [PATCH 8/8] Fix readiness status expectations after merge (#1657) --- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index cc0f60cab2..7ede709b30 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -2273,10 +2273,10 @@ BEFORE UPDATE ON files Assert.False(json.GetProperty("fold_ready").GetBoolean()); var (_, statusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--json"]); - Assert.True(statusJson.GetProperty("graph_table_available").GetBoolean()); + Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.True(statusJson.GetProperty("file_issues_data_current").GetBoolean()); - Assert.True(statusJson.GetProperty("fold_ready").GetBoolean()); + Assert.False(statusJson.GetProperty("file_issues_data_current").GetBoolean()); + Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); using var verify = OpenNonPoolingConnection(dbPath); verify.Open(); @@ -5622,7 +5622,7 @@ public void Run_UpdateMode_WithFiles_DoesNotRemoveUnreadableExtensionlessScript( Assert.Equal(CommandExitCodes.Success, statusExitCode); Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.True(statusJson.GetProperty("file_issues_data_current").GetBoolean()); + Assert.False(statusJson.GetProperty("file_issues_data_current").GetBoolean()); Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); } finally @@ -5668,7 +5668,7 @@ public void Run_UpdateMode_WithFiles_DemotesReadinessForUnreadableKnownExtension Assert.Equal(CommandExitCodes.Success, statusExitCode); Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.True(statusJson.GetProperty("file_issues_data_current").GetBoolean()); + Assert.False(statusJson.GetProperty("file_issues_data_current").GetBoolean()); Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); } finally @@ -5712,7 +5712,7 @@ public void Run_UpdateMode_WithFiles_DemotesReadinessForUnreadableNewKnownExtens Assert.Equal(CommandExitCodes.Success, statusExitCode); Assert.False(statusJson.GetProperty("graph_table_available").GetBoolean()); Assert.True(statusJson.GetProperty("issues_table_available").GetBoolean()); - Assert.True(statusJson.GetProperty("file_issues_data_current").GetBoolean()); + Assert.False(statusJson.GetProperty("file_issues_data_current").GetBoolean()); Assert.False(statusJson.GetProperty("fold_ready").GetBoolean()); } finally