From 8bfb931a0ab00bc16e0293780a704a38a5c6b5d5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:38:57 +0900 Subject: [PATCH] Fix HEAD-scoped index freshness (#2809) --- SELF_IMPROVEMENT.md | 12 +- changelog.d/unreleased/2809.fixed.md | 26 ++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 5 +- src/CodeIndex/Cli/GitHelper.cs | 16 +++ .../Cli/IndexCommandRunner.DryRun.cs | 2 +- src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 4 +- .../Cli/IndexCommandRunner.Update.cs | 4 +- .../Cli/IndexCommandRunner.Validation.cs | 29 +++- src/CodeIndex/Cli/IndexCommandRunner.cs | 22 ++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 9 +- .../IndexCommandRunnerTests.cs | 133 +++++++++++++++++- tests/CodeIndex.Tests/ProgramCliTests.cs | 2 +- 13 files changed, 236 insertions(+), 30 deletions(-) create mode 100644 changelog.d/unreleased/2809.fixed.md diff --git a/SELF_IMPROVEMENT.md b/SELF_IMPROVEMENT.md index 1d06c20d6a..45419e4003 100644 --- a/SELF_IMPROVEMENT.md +++ b/SELF_IMPROVEMENT.md @@ -39,11 +39,11 @@ The loop is not just "suggest ideas". It is: - Before every commit, explicitly work through `.codex/workflows/precommit.md`. - Before every commit, review README `# Code Search Rules` and `# コードベース検索ルール`; strengthen them if AI behavior should change. - After every commit, rebuild `cdidx` from the latest local source, run `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll status --check --json`, and refresh `.cdidx/codeindex.db` with that freshly built binary only when the check does not report `index_matches_workspace: true`. -- Prefer the lightest truthful refresh mode: use `--files` only for known in-place edits or new files, use `--commits HEAD` after a normal commit because it tracks renames/deletes from git history, use `--changed-between ` after branch switches when both refs are known, and reserve a full `cdidx . --json` scan for history-moving operations or cases where repo-wide stale files must be purged. +- Prefer the lightest truthful refresh mode: use `--files` only for known in-place edits or new files, use `--commits HEAD` after a normal commit because it tracks renames/deletes from git history, use `--changed-between ` after branch switches when both refs are known, and reserve `cdidx . --json` for full-workspace scans after history-moving operations or cases where repo-wide stale files must be purged. `cdidx . --json` is not a forced rebuild and may report `mode:"incremental"` while skipping unchanged files; if `status --check --json` still reports stale after that scan, escalate to `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --rebuild --yes --json`. - Prefer the **locally built latest binary** (`dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll`) over an older globally installed `cdidx` whenever the repository code has changed. **Never fall back to a global `cdidx`** — the global version may have an older DB schema, missing query features, or stale extraction logic that silently produces wrong results. This is enforced at the Claude Code harness level via the repo-tracked `.claude/settings.json`, which denies the full set of shell code-search and file-discovery commands: `rg`, `grep`, `egrep`, `fgrep`, `zgrep`, `rgrep`, `ripgrep`, `ag`, `ack`, `ack-grep`, `git grep`, `find`, `locate`, `mlocate`, `mdfind`, and `cdidx`. Use the built-in Grep / Glob tools or the locally built binary instead. - Treat repo-tracked `.claude/settings.json`, `.claude/hooks/bash-guard.py`, `.codex/hooks.json`, and `.codex/hooks/bash_guard.py` as tool-specific guard adapters. Do not edit them during ordinary self-improvement work unless the task is explicitly about agent guard behavior. Shared Bash command policy lives in `.agent_harness/command_guard_core.py`; update that shared core for common policy and review both adapters only when tool-specific behavior changes. The current guard is deny-oriented: it is intentionally permissive for routine `dotnet`, `git`, `gh`, `codex exec`, `/tmp` work, and read-only shell inspection, while still blocking dangerous shell patterns and sensitive local read paths. - Respect the current local-privacy boundary in Claude Code: home-directory personal areas and credential-like files are intentionally read-denied by policy. Do not weaken those restrictions as a convenience workaround for self-improvement tasks; if the task genuinely requires changing the guard, treat that as the task itself and update docs accordingly. -- After `git switch`, re-index with the **locally built binary** using `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --changed-between --json` when the workflow has both the previous and current refs; this updates only the files changed between branches while still purging rename/delete old paths included by git. If the refs are not available, or after `git reset`, `git rebase`, `git commit --amend`, or `git merge`, use `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll . --json` so stale files are purged against the current checkout. +- After `git switch`, re-index with the **locally built binary** using `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --changed-between --json` when the workflow has both the previous and current refs; this updates only the files changed between branches while still purging rename/delete old paths included by git. If the refs are not available, or after `git reset`, `git rebase`, `git commit --amend`, or `git merge`, use `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll . --json` so stale files are purged against the current checkout. If the follow-up status check remains stale, run `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --rebuild --yes --json` instead of repeating `cdidx . --json`. - When searching and navigating code to investigate bugs, plan fixes, or verify changes, always use the **locally built binary** — not the globally installed version. This ensures query results reflect the latest extraction rules and DB schema from this branch. - If the **locally built binary** crashes, aborts unexpectedly, or exposes a new defect during this loop, do not silently work around it or fall back to an older/global binary. Notify the user with the concrete failure, explain that the self-improvement loop is now blocked or tainted by that defect, and propose fixing it as a separate task or as the next approved priority. - Treat the loop itself as ongoing regression coverage and light monkey testing. Do not limit yourself to only the safest or most standard workflows; actively exercise recent features, edge features, and less-traveled commands/options so the loop can indirectly surface crashes, bad assumptions, stale help text, and integration defects. @@ -144,7 +144,7 @@ If `status --json` reports `fold_ready: false` and you only need Unicode-aware ` dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll backfill-fold --json ``` -Use a full `cdidx . --json` rebuild only when you need a fresh source scan, when history-moving git operations just happened, when branch-switch refs are unavailable, or when repo-wide stale-file purge matters more than speed. In the ordinary commit-to-commit loop, prefer `--commits` first; after branch switches with known refs, prefer `--changed-between`; and use `--files` only for in-place edits/new files where no old path needs purging. +Use `cdidx . --json` as a full-workspace scan only when you need a fresh source scan, when history-moving git operations just happened, when branch-switch refs are unavailable, or when repo-wide stale-file purge matters more than speed. This path is not a forced rebuild: it may report `mode:"incremental"` and skip unchanged files. If `status --check --json` remains stale after that scan, run `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --rebuild --yes --json`. In the ordinary commit-to-commit loop, prefer `--commits` first; after branch switches with known refs, prefer `--changed-between`; and use `--files` only for in-place edits/new files where no old path needs purging. Typical sequence: @@ -369,11 +369,11 @@ Read `SELF_IMPROVEMENT.md`, inspect the current repo with cdidx itself, identify - 毎コミット前に、`.codex/workflows/precommit.md` を明示的に確認する。 - 毎コミット前に、README の `# Code Search Rules` と `# コードベース検索ルール` を見直し、AIの検索行動を変えるべきなら強化する。 - 毎コミット後に、ローカルソースの最新状態から `cdidx` を再ビルドし、`dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll status --check --json` を実行する。`index_matches_workspace: true` でなければ、その新しいバイナリで `.cdidx/codeindex.db` を更新する。 -- 更新モードは「正しさを保てる範囲で最も軽いもの」を優先する。`--files` は把握している in-place 編集や新規追加だけに使い、通常のコミット後は rename/delete も拾える `--commits HEAD` を使う。ブランチ切り替え後は前後の ref が分かるなら `--changed-between ` を使い、履歴を動かす操作や repo 全体で stale file を掃除したい場合だけ `cdidx . --json` のフルスキャンへ上げる。 +- 更新モードは「正しさを保てる範囲で最も軽いもの」を優先する。`--files` は把握している in-place 編集や新規追加だけに使い、通常のコミット後は rename/delete も拾える `--commits HEAD` を使う。ブランチ切り替え後は前後の ref が分かるなら `--changed-between ` を使い、履歴を動かす操作や repo 全体で stale file を掃除したい場合だけ `cdidx . --json` の全 workspace scan へ上げる。`cdidx . --json` は強制 rebuild ではなく、`mode:"incremental"` と表示して unchanged file を skip する場合があるため、その後の `status --check --json` がまだ stale なら `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --rebuild --yes --json` に上げる。 - リポジトリのコードを変更した後は、古いグローバルインストール版ではなく **ローカルでビルドした最新版バイナリ** (`dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll`) を使う。**グローバル版には絶対に戻らないこと** — グローバル版は DB スキーマが古い、クエリ機能が欠けている、抽出ロジックが古くて誤った結果を返す、といった問題が起こりうる。このルールはリポジトリ追跡の `.claude/settings.json` で harness レベルでも強制されており、shell のコード検索・ファイル探索系コマンドを網羅的に deny している(`rg`、`grep`、`egrep`、`fgrep`、`zgrep`、`rgrep`、`ripgrep`、`ag`、`ack`、`ack-grep`、`git grep`、`find`、`locate`、`mlocate`、`mdfind`、`cdidx`)。代わりに組み込みの Grep / Glob ツールかローカルビルド版を使うこと。 - リポジトリ追跡の `.claude/settings.json` と `.claude/hooks/bash-guard.py` はポリシーファイルとして扱う。通常の自己改善作業では編集せず、Claude Code のガード挙動自体を変えるタスクのときだけ触る。現在のガードは deny ベースで、日常的な `dotnet`、`git`、`gh`、`codex exec`、`/tmp` 作業、読み取り中心の shell 確認は止めにくくしつつ、危険な shell パターンと機微なローカル read path は止める設計になっている。 - Claude Code のローカルプライバシー境界を尊重すること。ホーム配下の私物領域や資格情報系ファイルはポリシーで read deny されているため、自己改善タスクを進めやすくする目的でそれを緩めない。もし guard の変更自体が本当に必要なら、それを独立したタスクとして扱い、関連ドキュメントも同じコミットで更新する。 -- `git switch` の後は、切り替え前後の ref が分かる場合、**ローカルビルド版** で `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --changed-between --json` を実行する。これにより、ブランチ間で変わったファイルだけを更新しつつ、git が返す rename/delete の旧 path も purge できる。ref が分からない場合や、`git reset`、`git rebase`、`git commit --amend`、`git merge` の後は、`dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll . --json` のフルスキャンで現在の checkout に対する stale file を掃除する。 +- `git switch` の後は、切り替え前後の ref が分かる場合、**ローカルビルド版** で `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --changed-between --json` を実行する。これにより、ブランチ間で変わったファイルだけを更新しつつ、git が返す rename/delete の旧 path も purge できる。ref が分からない場合や、`git reset`、`git rebase`、`git commit --amend`、`git merge` の後は、`dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll . --json` の full-workspace scan で現在の checkout に対する stale file を掃除する。その後も status check が stale なら、`cdidx . --json` を繰り返さず `dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --rebuild --yes --json` を実行する。 - バグ調査、修正計画、変更検証のためにコード検索・ナビゲーションを行うときも、常に **ローカルビルド版** を使う。グローバルインストール版は使わない。これにより、このブランチの最新の抽出ルールと DB スキーマを反映した検索結果が得られる。 - このループ中に **ローカルビルド版** がクラッシュしたり、異常終了したり、新しい不具合を露呈した場合は、黙って回避したり古い版・グローバル版へ逃げたりしないこと。具体的な失敗内容をユーザーに通知し、その不具合によって自己改善ループがブロックされている、または結果の信頼性が損なわれていることを説明したうえで、別タスクまたは次の承認済み優先事項として修正提案を出すこと。 - このループ自体を、継続的なリグレッション確認と軽いモンキーテストとして扱うこと。最も安全で標準的なワークフローだけに偏らず、新しい機能、利用頻度の低い機能、枝葉末節のオプションも積極的に触り、間接的にクラッシュ、古いヘルプ文、想定漏れ、統合不具合をあぶり出すこと。 @@ -471,7 +471,7 @@ dotnet run --project src/CodeIndex -- . --json dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll backfill-fold --json ``` -ソース再走査も必要なとき、履歴を動かす git 操作の直後、ブランチ切り替え前後の ref が分からないとき、または repo 全体で stale file を掃除したいときだけ、`cdidx . --json` のフル更新を使います。通常のコミット間ループでは rename/delete も追える `--commits`、ブランチ切り替えでは前後 ref を渡す `--changed-between` を先に考え、`--files` は in-place 編集や新規追加に限定してください。 +ソース再走査も必要なとき、履歴を動かす git 操作の直後、ブランチ切り替え前後の ref が分からないとき、または repo 全体で stale file を掃除したいときだけ、`cdidx . --json` の full-workspace scan を使います。この経路は強制 rebuild ではなく、`mode:"incremental"` と表示して unchanged file を skip する場合があります。その後の `status --check --json` がまだ stale なら、`dotnet ./src/CodeIndex/bin/Debug/net8.0/cdidx.dll index . --rebuild --yes --json` を実行してください。通常のコミット間ループでは rename/delete も追える `--commits`、ブランチ切り替えでは前後 ref を渡す `--changed-between` を先に考え、`--files` は in-place 編集や新規追加に限定してください。 典型例: diff --git a/changelog.d/unreleased/2809.fixed.md b/changelog.d/unreleased/2809.fixed.md new file mode 100644 index 0000000000..0c1c4bfaef --- /dev/null +++ b/changelog.d/unreleased/2809.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 2809 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/IndexCommandRunner.Parse.cs + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Cli/IndexCommandRunner.Update.cs + - src/CodeIndex/Cli/IndexCommandRunner.Validation.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/ProgramCliTests.cs + - SELF_IMPROVEMENT.md +--- + +## English + +- **Branch-switch freshness now recognizes git-scoped refreshes at HEAD (#2809)** — `status --check` no longer stays stale after a successful `--commits HEAD` or `--changed-between HEAD` refresh, and the agent guidance now clarifies when to escalate from `cdidx . --json` to a forced rebuild. + +## 日本語 + +- **ブランチ切り替え後の freshness が HEAD を対象にした git-scoped 更新を認識するようになりました (#2809)** — `--commits HEAD` または `--changed-between HEAD` の成功後に `status --check` が stale のまま残らないようにし、agent guidance では `cdidx . --json` から強制 rebuild へ上げる条件を明確化しました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index f87231355f..949d562f01 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -289,7 +289,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--max-symbols-per-file", ValuePlaceholder = "", Description = "Skip file content, symbols, and references when one file emits too many symbols", Commands = Set("index") }, new() { Name = "--parallelism", ValuePlaceholder = "", Description = "Full-scan extraction worker count (default: CPU count capped at 16; also honors CDIDX_INDEX_PARALLELISM)", Commands = Set("index") }, new() { Name = "--memory-trace", Description = "Include phase memory samples in index JSON output", Commands = Set("index") }, - new() { Name = "--commits", ValuePlaceholder = "", Description = "Update files changed in given git commits", Commands = Set("index") }, + new() { Name = "--commits", ValuePlaceholder = "", Description = "Update files changed in given git commits", Commands = Set("index") }, new() { Name = "--changed-between", ValuePlaceholder = " ", Description = "Update files changed between two git refs", Commands = Set("index") }, new() { Name = "--files", ValuePlaceholder = "", Description = "Update only the specified files", Commands = Set("index") }, new() { Name = "--watch", Description = "Continuous reindex on file changes (rejects --commits / --changed-between / --files / --dry-run)", Commands = Set("index") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index c1751f6617..f82d371206 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -75,7 +75,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("backfill-fold", "cdidx backfill-fold [--db ] [--dry-run] [--no-checkpoint] [--json]"), ("optimize", "cdidx optimize [--db ] [--json]"), ("vacuum", "cdidx vacuum [--db ] [--json]"), - ("index-commits", "cdidx index --commits [id ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), + ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("search", "cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank]"), @@ -917,7 +917,8 @@ private static void PrintFlagReference(Action WriteHelpLine) WriteHelpLine(" --follow-symlinks Directory symlink policy: none (default), internal, or all"); WriteHelpLine(" --include-symbol-kind [,] Keep only matching symbol kinds during indexing"); WriteHelpLine(" --exclude-symbol-kind [,] Drop matching symbol kinds during indexing"); - Console.WriteLine(" --commits [id ...] Update only files changed in the specified git commits (preferred after commits)"); + Console.WriteLine(" --commits [commit-ref ...]"); + Console.WriteLine(" Update only files changed in the specified git commits (preferred after commits)"); Console.WriteLine(" --changed-between "); Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 58723776ec..1d035ec1a6 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -170,6 +170,9 @@ public static List GetChangedFilesFromCommit(string projectRoot, string public static bool IsCommitObjectId(string value) => !string.IsNullOrWhiteSpace(value) && Regex.IsMatch(value, "^[0-9a-fA-F]{7,40}$"); + public static void ValidateCommitRef(string projectRoot, string commitRef) + => ValidateSingleCommitRef(projectRoot, commitRef); + private static void ValidateSingleCommitRef(string projectRoot, string commitId) { // Reject range/pathspec syntax before invoking git so --commits remains a list @@ -274,6 +277,19 @@ private static void ValidateGitRef(string value, string parameterName) : null; } + public static string? TryResolveCommit(string projectRoot, string refName) + { + try + { + ValidateGitRef(refName, nameof(refName)); + return TryRunGit(projectRoot, "rev-parse", "--verify", $"{refName}^{{commit}}")?.Trim(); + } + catch + { + return null; + } + } + public static GitHeadCommitResult TryGetHeadCommitResult(string projectRoot) => TryGetHeadCommitResult(projectRoot, gitEnvironmentOverrides: null); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index f3b48ebc6b..20256f9db7 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -156,7 +156,7 @@ private static bool TryResolveDryRunCandidates( jsonOptions, $"failed to resolve changed files from git commits: {ex.Message}", CommandExitCodes.UsageError, - "Check the commit IDs and rerun `cdidx index --commits [id ...]`.", + "Check the commit refs and rerun `cdidx index --commits [commit-ref ...]`.", CommandErrorCodes.UsageError); return false; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index ef7ef415f5..763483a696 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -175,11 +175,9 @@ public static IndexCommandOptions ParseArgs(string[] args) { var commit = args[++i]; commits.Add(commit); - if (!GitHelper.IsCommitObjectId(commit)) - parseError ??= $"invalid --commits value '{commit}': expected a 7-40 character hex commit ID; ranges and tag refs are not accepted"; } if (commits.Count == 0) - Console.Error.WriteLine("Warning: --commits specified but no commit IDs provided / --commits が指定されましたがコミットIDがありません"); + Console.Error.WriteLine("Warning: --commits specified but no commit refs provided / --commits が指定されましたがコミットrefがありません"); break; case "--changed-between": changedBetweenSpecified = true; diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs index d83aacdff5..6522f7bdd2 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.cs @@ -87,7 +87,7 @@ private static int RunUpdateMode( jsonOptions, $"failed to resolve changed files from git commits: {ex.Message}", CommandExitCodes.UsageError, - "Check the commit IDs and rerun `cdidx index --commits [id ...]`.", + "Check the commit refs and rerun `cdidx index --commits [commit-ref ...]`.", CommandErrorCodes.UsageError); } finally @@ -1116,7 +1116,7 @@ void ThrowIfUpdateCancelled() if (errors == 0) { StampIndexedHeadMetadata(writer, projectRoot); - StampCommitScopedFreshHeadMetadata(writer, options, currentHeadCommit); + StampCommitScopedFreshHeadMetadata(writer, options, projectRoot, currentHeadCommit); if (options.MemoryTrace) memorySamples.Add(CaptureMemorySample("finalize", stopwatch)); var memoryTimelineForStamp = BuildMemoryTimeline(memorySamples); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs b/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs index 36f1e21a2b..73d6ba8c4f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Validation.cs @@ -44,6 +44,10 @@ public static partial class IndexCommandRunner CommandErrorCodes.UsageError); } + var commitRefValidationExitCode = ValidateCommitRefsBeforeIndexSetup(options, jsonOptions); + if (commitRefValidationExitCode != null) + return commitRefValidationExitCode.Value; + if (options.SymbolKindFilter.ParseError != null) { return WriteCommandError( @@ -84,6 +88,29 @@ public static partial class IndexCommandRunner return null; } + private static int? ValidateCommitRefsBeforeIndexSetup(IndexCommandOptions options, JsonSerializerOptions jsonOptions) + { + if (options.Commits.Count == 0) + return null; + + try + { + foreach (var commit in options.Commits) + GitHelper.ValidateCommitRef(options.ProjectPath!, commit); + return null; + } + catch (Exception ex) + { + return WriteCommandError( + options.Json, + jsonOptions, + $"failed to resolve changed files from git commits: {ex.Message}", + CommandExitCodes.UsageError, + "Check the commit refs and rerun `cdidx index --commits [commit-ref ...]`.", + CommandErrorCodes.UsageError); + } + } + private static int? ValidateWatchOptions(IndexCommandOptions options, JsonSerializerOptions jsonOptions) { // --watch is the only mode that holds the process open after the initial scan, so @@ -113,7 +140,7 @@ private static int WriteRebuildUpdateModeConflict(IndexCommandOptions options, J // does not require a second `--help` round-trip to find the correct command. const string rebuildConflictSynopsis = "`cdidx index --rebuild`, " - + "`cdidx index --commits [id ...]`, " + + "`cdidx index --commits [commit-ref ...]`, " + "`cdidx index --changed-between `, " + "or `cdidx index --files [path ...]`"; if (options.Json) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index c9f522f31d..2a071e26b3 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -642,12 +642,13 @@ private static void StampIndexedHeadMetadata(DbWriter writer, string projectRoot StampWorkspacePathCaseSensitivity(writer, projectRoot); } - private static void StampCommitScopedFreshHeadMetadata(DbWriter writer, IndexCommandOptions options, string? currentHeadCommit) + private static void StampCommitScopedFreshHeadMetadata(DbWriter writer, IndexCommandOptions options, string projectRoot, string? currentHeadCommit) { try { var coveredHead = !string.IsNullOrWhiteSpace(currentHeadCommit) - && options.Commits.Any(commit => currentHeadCommit.StartsWith(commit, StringComparison.OrdinalIgnoreCase)) + && (options.Commits.Any(commit => GitRefCoversCurrentHead(projectRoot, commit, currentHeadCommit)) + || TryChangedBetweenCoversCurrentHead(options, projectRoot, currentHeadCommit)) ? currentHeadCommit : null; writer.SetMeta(DbContext.CommitScopedFreshHeadShaMetaKey, coveredHead); @@ -659,6 +660,23 @@ private static void StampCommitScopedFreshHeadMetadata(DbWriter writer, IndexCom } } + private static bool GitRefCoversCurrentHead(string projectRoot, string refName, string currentHeadCommit) + { + if (currentHeadCommit.StartsWith(refName, StringComparison.OrdinalIgnoreCase)) + return true; + + var resolvedRef = GitHelper.TryResolveCommit(projectRoot, refName); + return string.Equals(resolvedRef, currentHeadCommit, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryChangedBetweenCoversCurrentHead(IndexCommandOptions options, string projectRoot, string currentHeadCommit) + { + if (options.ChangedBetweenRefs.Count != 2) + return false; + + return GitRefCoversCurrentHead(projectRoot, options.ChangedBetweenRefs[1], currentHeadCommit); + } + // Issue #1546: capture the actual case-sensitivity of the workspace filesystem so // `cdidx status` can diagnose phantom path collapses on case-sensitive APFS / WSL // NTFS / ReFS volumes (where the OS-keyed heuristic would mismatch reality). Probed diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index cfa335be4c..056b0da6bf 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -93,7 +93,7 @@ public void PrintUsage_WithBanner_IncludesAsciiArt() Assert.Contains("██████╗", output); Assert.Contains("Usage:", output); - Assert.Contains("cdidx index --commits [id ...]", output); + Assert.Contains("cdidx index --commits [commit-ref ...]", output); } [Fact] @@ -105,7 +105,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("Usage:", output); Assert.Contains("cdidx index [--db ] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--memory-trace] [--duration-format ] [--notify ] [--max-file-bytes ] [--max-symbols-per-file ] [--follow-symlinks ]", output); Assert.Contains("cdidx hooks [--project ] [--force] [--json]", output); - Assert.Contains("cdidx index --commits [id ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ]", output); + Assert.Contains("cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ]", output); Assert.Contains("cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ]", output); Assert.Contains("cdidx backfill-fold [--db ] [--dry-run] [--no-checkpoint] [--json]", output); Assert.Contains("cdidx optimize [--db ] [--json]", output); @@ -139,7 +139,8 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("--kind definition/symbols/hotspots/unused: symbol kind; references: reference kind (call/instantiate/subscribe/attribute/annotation); callers/callees: call-graph kinds only (call/instantiate/subscribe — metadata kinds rejected, use references instead); validate: issue kind", output); Assert.Contains("--count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts", output); Assert.Contains("--no-dedup search only: return every raw overlapping chunk hit (debug/density)", output); - Assert.Contains("--commits [id ...] Update only files changed in the specified git commits (preferred after commits)", output); + Assert.Contains("--commits [commit-ref ...]", output); + Assert.Contains("Update only files changed in the specified git commits (preferred after commits)", output); Assert.Contains("--files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed", output); Assert.Contains("--optimize index only: optimize the existing FTS5 table for this project's DB without scanning files", output); Assert.Contains("--duration-format Index elapsed time format: `auto` (default), `seconds`, or `hms`; JSON keeps raw elapsed_ms", output); @@ -1007,7 +1008,7 @@ public void PrintUsageBrief_IsConciseAndPointsToExtendedHelp() Assert.Contains("cdidx --help-flags", output); Assert.Contains("validate Report encoding issues", output); Assert.DoesNotContain("Index and update options:", output); - Assert.DoesNotContain("cdidx index --commits ", output); + Assert.DoesNotContain("cdidx index --commits ", output); } [Fact] diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 2b3abe3adf..ecae312ca3 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1226,13 +1226,13 @@ public void ParseArgs_MaxFileBytesInvalidValue_IsIgnored() [InlineData("feature")] [InlineData("v1.0.0")] [InlineData("main..feature")] - public void ParseArgs_CommitsRejectsBranchTagAndRangeRefs(string commitRef) + [InlineData("HEAD")] + public void ParseArgs_CommitsAcceptsCommitishRefsForGitValidation(string commitRef) { var options = IndexCommandRunner.ParseArgs([".", "--commits", commitRef]); Assert.Equal([commitRef], options.Commits); - Assert.NotNull(options.ParseError); - Assert.Contains("expected a 7-40 character hex commit ID", options.ParseError); + Assert.Null(options.ParseError); } [Fact] @@ -1472,7 +1472,7 @@ public void Run_RebuildWithCommits_PrintsActionableHint() Assert.Contains("--rebuild cannot be used with --commits, --changed-between, or --files", stderr); Assert.Contains("Hint: use one of:", stderr); Assert.Contains("`cdidx index --rebuild`", stderr); - Assert.Contains("`cdidx index --commits [id ...]`", stderr); + Assert.Contains("`cdidx index --commits [commit-ref ...]`", stderr); Assert.Contains("`cdidx index --changed-between `", stderr); Assert.Contains("`cdidx index --files [path ...]`", stderr); } @@ -1497,7 +1497,7 @@ public void Run_RebuildWithCommits_JsonIncludesHint() Assert.NotNull(hint); Assert.StartsWith("Use one of:", hint); Assert.Contains("`cdidx index --rebuild`", hint); - Assert.Contains("`cdidx index --commits [id ...]`", hint); + Assert.Contains("`cdidx index --commits [commit-ref ...]`", hint); Assert.Contains("`cdidx index --changed-between `", hint); Assert.Contains("`cdidx index --files [path ...]`", hint); } @@ -1507,6 +1507,39 @@ public void Run_RebuildWithCommits_JsonIncludesHint() } } + [Theory] + [InlineData("HEAD..HEAD", false)] + [InlineData("v1.0.0", true)] + public void Run_CommitsInvalidCommitRef_JsonRejectsBeforeDbSetup(string commitRef, bool createTag) + { + var projectRoot = CreateTempProject(); + try + { + RunGit(projectRoot, "init"); + File.WriteAllText(Path.Combine(projectRoot, "app.cs"), "public class App { }\n"); + RunGit(projectRoot, "add", "."); + RunGit(projectRoot, "commit", "-m", "init"); + if (createTag) + RunGit(projectRoot, "tag", commitRef); + + var excludePath = Path.Combine(projectRoot, ".git", "info", "exclude"); + var excludeBefore = File.Exists(excludePath) ? File.ReadAllText(excludePath) : null; + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--commits", commitRef, "--json"]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal("error", json.GetProperty("status").GetString()); + Assert.Contains("failed to resolve changed files from git commits", json.GetProperty("message").GetString()); + Assert.False(File.Exists(Path.Combine(projectRoot, ".cdidx", "codeindex.db"))); + var excludeAfter = File.Exists(excludePath) ? File.ReadAllText(excludePath) : null; + Assert.Equal(excludeBefore, excludeAfter); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_WatchWithCommits_PrintsActionableHint() { @@ -8523,9 +8556,8 @@ public void RunStatusCheck_AfterCommitScopedRefreshAtHead_DoesNotReportHeadChang RunGit(projectRoot, "add", "."); RunGit(projectRoot, "commit", "-m", "add run"); var currentHead = RunGitCaptureStdOut(projectRoot, "rev-parse", "HEAD").Trim(); - var shortCurrentHead = currentHead[..12]; - var (refreshExitCode, _) = RunAndCaptureJson([projectRoot, "--commits", shortCurrentHead, "--json"]); + var (refreshExitCode, _) = RunAndCaptureJson([projectRoot, "--commits", "HEAD", "--json"]); Assert.Equal(CommandExitCodes.Success, refreshExitCode); var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); @@ -8546,6 +8578,68 @@ public void RunStatusCheck_AfterCommitScopedRefreshAtHead_DoesNotReportHeadChang } } + [Fact] + public void RunStatusCheck_AfterChangedBetweenRefreshAtHead_DotCommandRestampsFullScanHead() + { + var projectRoot = CreateTempProject(); + try + { + RunGit(projectRoot, "init"); + var sourcePath = Path.Combine(projectRoot, "app.cs"); + File.WriteAllText(sourcePath, "public class App { }\n"); + RunGit(projectRoot, "add", "."); + RunGit(projectRoot, "commit", "-m", "init"); + var initialHead = RunGitCaptureStdOut(projectRoot, "rev-parse", "HEAD").Trim(); + + var initialExitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + + RunGit(projectRoot, "checkout", "-b", "feature"); + File.WriteAllText(sourcePath, "public class App { public void Run() { } }\n"); + RunGit(projectRoot, "add", "."); + RunGit(projectRoot, "commit", "-m", "add run"); + var currentHead = RunGitCaptureStdOut(projectRoot, "rev-parse", "HEAD").Trim(); + + var (refreshExitCode, _) = RunAndCaptureJson([projectRoot, "--changed-between", initialHead, "HEAD", "--json"]); + Assert.Equal(CommandExitCodes.Success, refreshExitCode); + + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using (var db = new DbContext(dbPath)) + { + // `--changed-between` proves the current HEAD is covered for status freshness, + // but it must not overwrite the full-scan-only HEAD stamp. + // `--changed-between` は status freshness だけを満たし、full-scan 専用 HEAD は進めない。 + Assert.Equal(initialHead, db.GetMetaString(DbContext.IndexedHeadCommitMetaKey)); + } + + var (statusExitCode, statusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--check", "--json"]); + Assert.Equal(CommandExitCodes.Success, statusExitCode); + var check = statusJson.GetProperty("workspace_check"); + Assert.False(check.GetProperty("head_changed").GetBoolean()); + Assert.True(check.GetProperty("matches_workspace").GetBoolean()); + Assert.Equal("matched", check.GetProperty("reason").GetString()); + + var (dotExitCode, dotJson) = RunProgramAndCaptureJson([projectRoot, "--json"]); + Assert.Equal(CommandExitCodes.Success, dotExitCode); + Assert.Equal("success", dotJson.GetProperty("status").GetString()); + Assert.True(dotJson.GetProperty("head_changed").GetBoolean()); + Assert.Equal(initialHead, dotJson.GetProperty("prior_indexed_head_commit").GetString()); + Assert.Equal(currentHead, dotJson.GetProperty("current_head_commit").GetString()); + + using (var db = new DbContext(dbPath)) + Assert.Equal(currentHead, db.GetMetaString(DbContext.IndexedHeadCommitMetaKey)); + + var (postDotStatusExitCode, postDotStatusJson) = RunStatusAndCaptureJson(["--db", dbPath, "--check", "--json"]); + Assert.Equal(CommandExitCodes.Success, postDotStatusExitCode); + Assert.True(postDotStatusJson.GetProperty("workspace_check").GetProperty("matches_workspace").GetBoolean()); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void RunStatusCheck_AfterFilesRefreshAtHead_StillReportsHeadChanged() { @@ -8604,6 +8698,31 @@ public void RunStatusCheck_AfterFilesRefreshAtHead_StillReportsHeadChanged() } } + private (int ExitCode, JsonElement Json) RunProgramAndCaptureJson(string[] args) + { + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + var originalError = Console.Error; + using var stdout = new StringWriter(); + using var stderr = new StringWriter(); + + try + { + Console.SetOut(stdout); + Console.SetError(stderr); + var exitCode = ProgramRunner.Run(args, _jsonOptions, appVersion: "1.0.0-test", configStartDirectory: args[0]); + using var document = JsonDocument.Parse(stdout.ToString()); + return (exitCode, document.RootElement.Clone()); + } + finally + { + Console.SetOut(originalOut); + Console.SetError(originalError); + } + } + } + private static int CountRows(string dbPath, string tableName) { using var connection = new SqliteConnection($"Data Source={dbPath}"); diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index df1d8dfe4d..6cada86403 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -467,7 +467,7 @@ public void TopLevelHelp_DefaultIsBriefAndExtendedHelpKeepsFullReference() Assert.Equal(0, fullExit); Assert.Equal(string.Empty, fullStderr); Assert.Contains("Index and update options:", fullStdout); - Assert.Contains("cdidx index --commits ", fullStdout); + Assert.Contains("cdidx index --commits ", fullStdout); Assert.Contains("--limit , --top ", fullStdout); }