diff --git a/.github/scripts/run-dotnet-tests.ps1 b/.github/scripts/run-dotnet-tests.ps1 index 509eb5d0e..2639d1843 100644 --- a/.github/scripts/run-dotnet-tests.ps1 +++ b/.github/scripts/run-dotnet-tests.ps1 @@ -76,6 +76,10 @@ function Invoke-TestRun { $runArgs += @("--filter", $TestFilter) } + # Performance budgets must distinguish instrumented runs from ordinary retries. + # performance budget が coverage 計測中と通常の retry を区別できるようにする。 + $env:CODEINDEX_TEST_COVERAGE = if ($IncludeCoverage) { "true" } else { "false" } + [int]$failureLogTailLineLimit = 2000 $retainedOutputTail = [System.Collections.Generic.Queue[string]]::new($failureLogTailLineLimit) [long]$totalOutputLineCount = 0 diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 527a77223..13fb0d9b7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1531,6 +1531,24 @@ Current stable codes and triggers: | Memory tracing | `index --json --memory-trace` adds a `memory_timeline` block to the CLI index result and persists peak working-set MB into `last_index_run`; dry-run results also emit live `start`, `snapshot`, `scan`, and `finalize` samples but never persist run metadata. `index --dry-run --rebuild` bypasses destructive confirmation because it does not delete or rewrite the index. `CDIDX_MEM_WARN_MB=` prints a warning when the sampled working set crosses that threshold. | | Newer schema protection | Writable opens reject databases whose `PRAGMA user_version` contains readiness bits outside the current binary's `CurrentSchemaVersion` mask. Read-only status/query paths may still surface `index_newer_than_reader=true` as a degraded audit signal, but write-capable paths must fail with `E003_SCHEMA_TOO_NEW` so an older cdidx cannot silently rewrite a DB stamped by a newer one. | +Status freshness summaries are classified by one shared evaluator. An +authoritative `status --check` supplies the file-level result. Without that check, +`last_workspace_freshened_at >= latest_modified` can prove a checksum-reused +no-op update fresh only when the worktree is clean and the runtime, +workspace-verified, and latest-index HEAD SHAs all agree, and Git reports no +`skip-worktree` or `assume-unchanged` entry that could hide a later change. +The Git dirtiness probe explicitly requests all untracked files, overriding a +repository-level `status.showUntrackedFiles=no` setting. Missing provenance, +hidden index state, future timestamps, and an ordinary-status dirty worktree are +`unknown`; a later modification is `stale`, and changed HEAD remains conservative. +An authoritative file check may prove freshness in a dirty repository when every +workspace path matches, so already-indexed untracked paths and dirtiness outside a +subdirectory index do not fail check mode. Status-level HEAD/branch drift is still +evaluated before that file check and propagated into checked failures, exit status, +and workspace member health. Ordinary +`head_freshness=head_current` semantics remain distinct from the authoritative +checked `fresh` value. + `vacuum --dry-run` accepts supported local SQLite URI spellings such as `file:/absolute/path/codeindex.db`, Windows `file:/C:/absolute/path/codeindex.db`, and canonical `file:///...` forms. Single-slash paths are canonicalized while retaining their original query string and ignoring URI fragments, and validation plus metric collection use that same query-only URI so an explicit `immutable=1` keeps its stale-snapshot semantics. ### Data directory resolution @@ -5708,6 +5726,21 @@ apply 時は `PRAGMA optimize` を実行します。 | memory tracing | `index --json --memory-trace` は CLI index 結果に `memory_timeline` block を追加し、peak working-set MB を `last_index_run` に保存します。dry-run 結果も live な `start`、`snapshot`、`scan`、`finalize` sample を返しますが、run metadata は保存しません。`index --dry-run --rebuild` は index を削除も rewrite もしないため destructive confirmation を bypass します。`CDIDX_MEM_WARN_MB=` は sampled working set がしきい値を超えたときに warning を出します。 | | newer schema protection | writable open は、`PRAGMA user_version` に current binary の `CurrentSchemaVersion` mask 外の readiness bit が含まれる database も拒否します。read-only status/query path は degraded audit signal として `index_newer_than_reader=true` を表示できますが、write-capable path は古い cdidx が新しい binary で stamp された DB を黙って rewrite しないよう `E003_SCHEMA_TOO_NEW` で失敗しなければなりません。 | +status freshness summary は1つの共有 evaluator で分類します。authoritative な +`status --check` は file-level の結果を提供します。check がない場合、 +`last_workspace_freshened_at >= latest_modified` が checksum 再利用 no-op update の +freshness を証明できるのは、worktree が clean で、runtime、workspace 検証済み、直近 +index の HEAD SHA がすべて一致し、後続変更を隠せる `skip-worktree` / `assume-unchanged` +entry が Git index に無い場合だけです。Git の dirtiness probe は未追跡 file を明示的に +すべて要求し、repository の `status.showUntrackedFiles=no` 設定を上書きします。provenance +不足、隠れた index state、未来 timestamp、通常 status の dirty worktree は `unknown`、 +後続の変更は `stale` とし、HEAD 変更は保守的な判定を維持します。authoritative file check +は全 workspace path が一致する場合、dirty repository でも freshness を証明できるため、 +index 済み未追跡 path や subdirectory index の scope 外にある dirtiness は check mode を +失敗させません。status-level の HEAD / branch drift は引き続き file check より先に評価し、 +checked failure、終了 status、workspace member health に伝播します。通常 status +の `head_freshness=head_current` は、authoritative check 済みの `fresh` と引き続き区別します。 + `vacuum --dry-run` は、`file:/absolute/path/codeindex.db`、Windows の `file:/C:/absolute/path/codeindex.db`、canonical な `file:///...` 形式を受け付けます。single-slash の path を canonicalize しつつ元の query string を維持して URI fragment を無視し、validation と metric 収集に同じ query-only URI を使うため、明示的な `immutable=1` の stale-snapshot semantics も維持されます。 ### データディレクトリ解決 diff --git a/README.md b/README.md index 0166bfe43..1dfbb90db 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,20 @@ visible here as a compact compatibility index. | Remediation | `degraded_root_cause`, `degraded_reason`, `recommended_action`, `alternative_action`, `readiness_degradations`, `repair_commands`. | | MCP-only session diagnostics | `mcp_session`, `mcp_session.metrics`, `queue_capacity`, `queue_depth`, `queued_event_count`, `written_event_count`, `dropped_event_count`, `queue_full_drop_count`, `serialization_failure_count`, `write_failure_count`, `rotation_failure_count`, `batch_flush_count`, `consecutive_failure_count`, `recovery_count`, `next_retry_at`, `last_recovery_at`, `last_failure`, `mcp_session.audit_log`, `queued_record_count`, `written_record_count`, `mcp.rate_limit.bucket_limit`, `mcp.rate_limit.bucket_limit_rejection_count`. | +Ordinary status summaries use `last_workspace_freshened_at` as freshness evidence +after a checksum-reused no-op update only when the runtime HEAD, +`workspace_verified_head_sha`, and `indexed_head_sha` agree and the worktree is +clean. Git index flags that can hide worktree changes (`skip-worktree` or +`assume-unchanged`) make this ordinary-status proof `unknown`, while the Git +dirtiness probe always includes untracked files even when +`status.showUntrackedFiles=no`. Missing provenance or a future timestamp also +yields `unknown`, as does a dirty worktree in ordinary status; an actual workspace +difference remains `stale`. `status --check` performs the authoritative workspace +comparison, so it may prove an already-indexed untracked path fresh even though +ordinary status remains conservatively unknown. A status-level HEAD/branch +transition still fails checked status and member health even when file checks +match. CLI, workspace, and MCP status surfaces share these outcomes. + Persisted JSON subdocuments for `last_index_run.reference_extraction_cap_hits`, `last_index_run.rebuild_reclaim`, and `last_failed_or_partial_index_run.file_errors` have a 512 KiB UTF-8 input limit @@ -443,6 +457,19 @@ field group を表に残します。 | remediation | `degraded_root_cause`、`degraded_reason`、`recommended_action`、`alternative_action`、`readiness_degradations`、`repair_commands`。 | | MCP-only session diagnostics | `mcp_session`、`mcp_session.metrics`、`queue_capacity`、`queue_depth`、`queued_event_count`、`written_event_count`、`dropped_event_count`、`queue_full_drop_count`、`serialization_failure_count`、`write_failure_count`、`rotation_failure_count`、`batch_flush_count`、`consecutive_failure_count`、`recovery_count`、`next_retry_at`、`last_recovery_at`、`last_failure`、`mcp_session.audit_log`、`queued_record_count`、`written_record_count`、`mcp.rate_limit.bucket_limit`、`mcp.rate_limit.bucket_limit_rejection_count`。 | +通常の status summary は、checksum 再利用による no-op update 後の鮮度証拠として +`last_workspace_freshened_at` を使います。ただし runtime HEAD、 +`workspace_verified_head_sha`、`indexed_head_sha` が一致し、worktree が clean な場合に +限ります。worktree 変更を隠せる Git index flag(`skip-worktree` または +`assume-unchanged`)がある場合、この通常 status の証拠は `unknown` です。また Git の +dirtiness probe は `status.showUntrackedFiles=no` の設定時も未追跡 file を必ず含めます。 +provenance が欠けている場合、timestamp が未来の場合、通常 status で worktree が dirty な +場合は `unknown`、実際の workspace 差分は引き続き `stale` です。`status --check` は +authoritative な workspace 比較を行うため、通常 status が保守的に unknown でも、index 済み +未追跡 path を fresh と証明できます。status-level の HEAD / branch 遷移は file check が +一致しても checked status と member health を失敗させます。CLI、workspace、MCP の status +surface はこれらの outcome を共有します。 + `last_index_run.reference_extraction_cap_hits`、`last_index_run.rebuild_reclaim`、 `last_failed_or_partial_index_run.file_errors` の永続化 JSON subdocument には、 UTF-8 で 512 KiB の入力上限と最大 depth 16 を適用します。file error と cap-hit diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 49d13f042..f105da7ee 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,6 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack +- Issue #5227 status-freshness coverage belongs in `StatusFreshnessEvaluatorTests.cs`, `GitHelperTests.cs`, `GitProcessRunnerTests.cs`, `IndexCommandRunnerTests.cs`, `PathCompatibilityMatrixTests.cs`, `WorkspaceCommandRunnerTests.cs`, and `WorkspaceMetadataEnricherTests.cs`. Keep deterministic timestamp boundaries for checksum-reused no-op updates, later real modifications, missing evidence, future timestamps, dirty worktrees, changed HEAD/branch provenance, `status.showUntrackedFiles=no`, and `skip-worktree` / `assume-unchanged` index flags, including already-indexed untracked paths whose authoritative content check matches and tracked-path output beyond the bounded diagnostic capture size. The integration fixture must keep `indexed_at` unchanged while `last_workspace_freshened_at` advances and must assert ordinary CLI, `status --check`, `head_freshness`, workspace member health/exit status, and MCP classification/check parity or an explicit conservative `unknown` across both target frameworks. The Git-index visibility scan must remain gated behind every ordinary-status trust precondition and must not run for authoritative check mode. - Issue #5226 impact-count coverage belongs in `DbReaderIssue5226Tests.cs`, `QueryCommandRunnerIssue5226Tests.cs`, `QueryCommandRunnerImpactTests.cs`, and `McpServerIssue5226Tests.cs`. Keep zero, one, above-default, multi-hop, include/exclude-filter, incomplete-graph, dedicated safety-cap, heuristic-fallback, cross-language SQL-readiness, and ordinary row-limited cases together. Human, JSON, compact, and MCP count modes must ignore the presentation limit while preserving authoritative/lower-bound metadata and omitting count-only result rows; capped human output must retain numeric stdout and warn with the reason on stderr. - Issue #5225 scoped C# dry-run coverage belongs in `IndexCommandRunnerDryRunTests.cs`. Keep paired preview/execution fixtures for `--files`, `--commits`, and `--changed-between`, plus static-interface and member-read expansion identities. Preserve non-C# no-expansion, candidate-cap lower-bound metadata, preflight-error and cancellation controls, human/JSON count and truncation output, and database/source non-mutation assertions across both target frameworks. - Issue #5197 dependency-cycle coverage belongs in `QueryCommandRunnerIssue5197Tests.cs`, `McpServerIssue5197Tests.cs`, and the `deps-cycles-summary.json` golden in `JsonOutputSnapshotTests.cs`. Keep a cycle larger than the 50-node presentation limit to verify complete analysis, authoritative counts, bounded default materialization in human, JSON, DOT, GraphML, and JSON graph output, compact summaries, explicit raw expansion, expansion-aware recovery guidance, largest-component/grouping metadata, and CLI/MCP parity. A later JSON graph cursor page must report its own materialized and omitted-node totals even when they differ from the global largest component. Verify that SCC summaries aggregate every advertised evidence dimension and that actual MCP summaries and expanded results validate against the advertised `deps` output schema, including node arrays beyond the ordinary 10,000-item row bound. MCP must reject summary mode combined with `format=json-graph` just as the CLI does. Keep the C# suppression fixture mixed: unresolved qualified calls and resolved same-name decoys are removable evidence only with a current identity contract, stale and absent contracts retain qualified calls with unavailable resolution evidence, and confirmed resolved targets must remain and drive the surviving SCC even when their target file contains same-name overloads. @@ -1173,6 +1174,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5227 の status freshness coverage は `StatusFreshnessEvaluatorTests.cs`、`GitHelperTests.cs`、`GitProcessRunnerTests.cs`、`IndexCommandRunnerTests.cs`、`PathCompatibilityMatrixTests.cs`、`WorkspaceCommandRunnerTests.cs`、`WorkspaceMetadataEnricherTests.cs` が担当します。checksum 再利用による no-op update、後続の実変更、証拠不足、未来 timestamp、dirty worktree、HEAD / branch provenance 変更、`status.showUntrackedFiles=no`、`skip-worktree` / `assume-unchanged` index flag、authoritative な content check が一致する index 済み未追跡 path、および bounded diagnostic capture size を超える tracked-path 出力について、決定的な境界を維持してください。integration fixture では `indexed_at` が変わらず `last_workspace_freshened_at` だけが進むことを確認し、通常 CLI、`status --check`、`head_freshness`、workspace member health / 終了 status、MCP の分類と check が両 target framework で一致するか、明示的に保守的な `unknown` となることを assertion します。Git index visibility scan は通常 status の全 trust precondition が成立した場合だけ実行し、authoritative check mode では実行してはいけません。 - Issue #5226 の impact count coverage は `DbReaderIssue5226Tests.cs`、`QueryCommandRunnerIssue5226Tests.cs`、`QueryCommandRunnerImpactTests.cs`、`McpServerIssue5226Tests.cs` が担当します。0件、1件、既定上限超過、multi-hop、include / exclude filter、不完全 graph、専用 safety cap、heuristic fallback、cross-language の SQL readiness、通常の row 上限制限を一緒に維持してください。human、JSON、compact、MCP の count mode は表示用 limit を無視し、authoritative / lower-bound metadata を保持しながら count-only の result row を生成しないことを検証します。cap 到達時の human 出力は stdout を数値のみのまま保ち、stderr に理由付き warning を出します。 - Issue #5225 の scoped C# dry-run coverage は `IndexCommandRunnerDryRunTests.cs` が担当します。`--files`、`--commits`、`--changed-between` の preview / 実行を対にした fixture と、static-interface / member-read 展開の target identity を維持してください。両 target framework で、非 C# の非展開、candidate cap の lower-bound metadata、preflight error / cancellation control、human / JSON の件数・truncation 出力、database / source の非変更 assertion も保ちます。 - Issue #5197 の dependency-cycle coverage は `QueryCommandRunnerIssue5197Tests.cs`、`McpServerIssue5197Tests.cs`、`JsonOutputSnapshotTests.cs` の `deps-cycles-summary.json` golden が担当します。50 node の表示上限を超える cycle を維持し、完全な解析、authoritative な件数、human、JSON、DOT、GraphML、JSON graph 出力における既定の上限付き materialization、compact summary、明示的な raw 展開、展開状態を考慮した recovery guidance、最大 component / grouping metadata、CLI / MCP parity を検証してください。後続の JSON graph cursor page は global な最大 component と異なる場合も、その page 自身の materialize 済み node 数と省略 node 数を報告します。SCC summary が公開するすべての evidence dimension を集計し、実際の MCP summary と展開結果が通常の 10,000 item の row 上限を超える node array も含めて公開 `deps` output schema に適合することを検証します。また MCP は CLI と同様に summary mode と `format=json-graph` の併用を拒否します。C# 抑制 fixture は、current な identity contract がある場合だけ未解決の修飾 call と解決済みcallの同名decoyを除外可能とし、stale / absent contract では unavailable な resolution evidence として修飾 call を保持し、target file に同名 overload がある場合も確認済みの解決済みtargetが残る SCC を構成する混在状態を維持します。 diff --git a/changelog.d/unreleased/5227.fixed.md b/changelog.d/unreleased/5227.fixed.md new file mode 100644 index 000000000..d9c5ee00a --- /dev/null +++ b/changelog.d/unreleased/5227.fixed.md @@ -0,0 +1,34 @@ +--- +category: fixed +issues: + - 5227 +affected: + - src/CodeIndex/Models/StatusFreshnessEvaluator.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/GitHelper.cs + - src/CodeIndex/Cli/GitProcessRunner.cs + - src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs + - src/CodeIndex/Cli/QueryCommandRunner.Status.cs + - src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - src/CodeIndex/Database/DbReader.WorkspaceHealth.cs + - src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs + - tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs + - tests/CodeIndex.Tests/GitHelperTests.cs + - tests/CodeIndex.Tests/GitProcessRunnerTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Checksum-reused no-op indexing now keeps status freshness consistent (#5227)** — The ordinary CLI, authoritative checked status, workspace status, and MCP status now share one conservative freshness classifier and check outcome. A trusted `last_workspace_freshened_at` can establish freshness without rewriting unchanged file rows, while untracked-file detection overrides `status.showUntrackedFiles=no`. Dirty ordinary status is explicitly unknown, an authoritative matching content check may still prove freshness for already-indexed untracked paths, and same-commit branch drift fails checked status and workspace health. This closes the residual status contradiction from #3238 while preserving stale, missing-provenance, hidden Git-index, changed-HEAD/branch, and clock-skew boundaries. + +## 日本語 + +- **checksum 再利用による no-op index 後も status freshness が一貫するようになりました (#5227)** — 通常 CLI、authoritative check 付き status、workspace status、MCP status は、1つの保守的な freshness classifier と check outcome を共有します。信頼できる `last_workspace_freshened_at` により未変更 file row を書き換えず freshness を確立する一方、未追跡 file の検出は `status.showUntrackedFiles=no` を上書きします。dirty な通常 status は明示的に unknown とし、authoritative な content check が一致すれば index 済み未追跡 path を fresh と証明でき、同一 commit の branch drift は checked status と workspace health を失敗させます。これにより #3238 に残っていた status の矛盾を解消しつつ、stale、provenance 不足、隠れた Git index state、HEAD / branch 変更、clock skew の境界を維持します。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index a0c3ac046..ab3541f1e 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -1110,7 +1110,14 @@ internal static bool ResolveIgnoreCase( /// public static WorktreeStatus? TryGetWorktreeStatus(string projectRoot, CancellationToken cancellationToken = default) { - var output = TryRunGit(projectRoot, cancellationToken, "-c", "core.quotePath=false", "status", "--porcelain"); + var output = TryRunGit( + projectRoot, + cancellationToken, + "-c", + "core.quotePath=false", + "status", + "--porcelain", + "--untracked-files=normal"); if (output == null) return null; @@ -1183,6 +1190,39 @@ private static string ParsePorcelainPath(string path) return paths; } + /// + /// Return whether tracked index flags can hide worktree changes from ordinary Git status. + /// Null means Git could not provide a trustworthy answer. Both skip-worktree and + /// assume-unchanged are visibility-limiting for freshness purposes (#5227). + /// 通常の Git status から worktree 変更を隠し得る tracked index flag の有無を返す。 + /// Git で確認できない場合は null。freshness 判定では skip-worktree と + /// assume-unchanged の両方を visibility 制限として扱う (#5227)。 + /// + internal static bool? TryHasWorktreeVisibilityLimitingIndexFlags( + string projectRoot, + CancellationToken cancellationToken = default) + { + return TryRunGitMatchingOutputLine( + projectRoot, + cancellationToken, + HasWorktreeVisibilityLimitingIndexFlag, + "-c", + "core.quotePath=false", + "ls-files", + "-v"); + } + + private static bool HasWorktreeVisibilityLimitingIndexFlag(string line) + { + if (line.Length < 3 || line[1] != ' ') + return false; + + // `S` is skip-worktree. With `-v`, any lowercase tag means the + // assume-unchanged bit is set (including lowercase `s`). + // `S` は skip-worktree。`-v` の小文字 tag は assume-unchanged を示す。 + return line[0] == 'S' || char.IsLower(line[0]); + } + internal static string? TryGetRepositoryRoot( string projectPath, IReadOnlyDictionary? gitEnvironmentOverrides, @@ -1231,6 +1271,37 @@ private static bool HasGitMetadataEntry(string projectRoot) private static string? TryRunGit(string projectRoot, CancellationToken cancellationToken, params string[] args) => TryRunGit(projectRoot, gitEnvironmentOverrides: null, cancellationToken, args); + private static bool? TryRunGitMatchingOutputLine( + string projectRoot, + CancellationToken cancellationToken, + Func predicate, + params string[] args) + { + try + { + var psi = TryCreateGitStartInfo(projectRoot); + if (psi == null) + return null; + + foreach (var arg in args) + psi.ArgumentList.Add(arg); + + return GitProcessRunner.RunMatchingStdoutLine( + psi, + GitCommandTimeout, + predicate, + cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + internal readonly record struct GitCommandResult( int? ExitCode, string? Output, diff --git a/src/CodeIndex/Cli/GitProcessRunner.cs b/src/CodeIndex/Cli/GitProcessRunner.cs index 804a9d31a..e86d347e1 100644 --- a/src/CodeIndex/Cli/GitProcessRunner.cs +++ b/src/CodeIndex/Cli/GitProcessRunner.cs @@ -44,6 +44,18 @@ internal static (int ExitCode, string Output, string Error)? RunCapturingOutput( CancellationToken cancellationToken = default) => RunCapturingResultAsync(psi, timeout, cancellationToken).GetAwaiter().GetResult(); + // Scan stdout line-by-line without retaining the complete path listing. This is for + // Git queries such as `ls-files` where the answer is existential but output can exceed + // the bounded diagnostic capture contract in large repositories. + // `ls-files` のように存在判定だけが必要な Git query を、巨大 repository でも path 一覧を + // 全保持せず stdout の行単位で走査する。 + internal static bool? RunMatchingStdoutLine( + ProcessStartInfo psi, + TimeSpan timeout, + Func predicate, + CancellationToken cancellationToken = default) + => RunMatchingStdoutLineAsync(psi, timeout, predicate, cancellationToken).GetAwaiter().GetResult(); + internal static string FormatDiagnostic(string diagnostic) { var boundedBeforeRedaction = DiagnosticRedactor.BoundDiagnosticText( @@ -160,6 +172,64 @@ void MarkFailure(GitCommandFailureKind kind, string diagnostic) return new CaptureResult(exitCode, output, error, GitCommandFailureKind.None, null); } + private static async Task RunMatchingStdoutLineAsync( + ProcessStartInfo psi, + TimeSpan timeout, + Func predicate, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(predicate); + cancellationToken.ThrowIfCancellationRequested(); + using var process = new Process { StartInfo = psi }; + var stderr = new StringBuilder(); + GitCommandFailureKind failureKind = GitCommandFailureKind.None; + var failureLock = new object(); + + void MarkFailure(GitCommandFailureKind kind, string _) + { + lock (failureLock) + { + if (failureKind != GitCommandFailureKind.None) + return; + failureKind = kind; + } + TryKillProcessTree(process); + } + + try + { + if (!process.Start()) + return null; + } + catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception or IOException or UnauthorizedAccessException) + { + return null; + } + + var stdoutTask = ReadMatchingOutputLinesAsync(process.StandardOutput, predicate, MarkFailure); + var stderrTask = ReadCapturedStreamAsync(process.StandardError, stderr, "stderr", MarkFailure); + var waitResult = await WaitForGitExitAsync(process, timeout, cancellationToken).ConfigureAwait(false); + var cancelled = waitResult.Cancelled; + if (!waitResult.Exited) + { + MarkFailure( + cancelled ? GitCommandFailureKind.Cancelled : GitCommandFailureKind.TimedOut, + cancelled + ? "git command cancelled." + : $"git command timed out after {FormatDuration(timeout)}."); + _ = await WaitForGitExitAfterKillAsync(process, GitKillWaitTimeout).ConfigureAwait(false); + } + + if (!await WaitForCaptureReadersAsync(stdoutTask, stderrTask).ConfigureAwait(false)) + MarkFailure(GitCommandFailureKind.OutputCaptureIncomplete, "git command output scan did not finish."); + + if (cancelled) + cancellationToken.ThrowIfCancellationRequested(); + if (failureKind != GitCommandFailureKind.None || !process.HasExited || process.ExitCode != 0) + return null; + return await stdoutTask.ConfigureAwait(false); + } + private readonly record struct GitExitWaitResult(bool Exited, bool Cancelled); private static async Task WaitForGitExitAsync( @@ -257,6 +327,34 @@ private static async Task ReadCapturedStreamAsync( } } + private static async Task ReadMatchingOutputLinesAsync( + TextReader reader, + Func predicate, + Action markFailure) + { + var matched = false; + try + { + while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line) + { + if (!matched && predicate(line)) + matched = true; + } + } + catch (IOException ex) + { + markFailure( + GitCommandFailureKind.CaptureFailed, + $"git command stdout scan failed: {DiagnosticRedactor.ClassifyException(ex)}"); + } + catch (ObjectDisposedException) + { + // Process cleanup closed the stream after another failure path. + } + + return matched; + } + private static bool AppendBoundedCapturedChars( StringBuilder builder, ReadOnlySpan data, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs b/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs index e640bff1c..dfdaf4c7c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.RepairCommands.cs @@ -15,7 +15,11 @@ private static string BuildFoldBackfillCommand(string dbPath, bool dbPathExplici private static string BuildCSharpCanonicalNameRepairCommand(DbReader reader, QueryCommandOptions options) { var status = reader.GetStatus(includeDatabaseSizeAttribution: false); - WorkspaceMetadataEnricher.Enrich(status, options.DbPath, options.DbPathExplicit); + WorkspaceMetadataEnricher.Enrich( + status, + options.DbPath, + options.DbPathExplicit, + evaluateOrdinaryFreshness: false); return BuildCSharpCanonicalNameRepairCommand(status.ProjectRoot, options.DbPath, options.DbPathExplicit); } @@ -25,7 +29,11 @@ private static string BuildCSharpCanonicalNameRepairCommand(string? projectRoot, private static string BuildSqlGraphContractRepairCommand(DbReader reader, QueryCommandOptions options) { var status = reader.GetStatus(includeDatabaseSizeAttribution: false); - WorkspaceMetadataEnricher.Enrich(status, options.DbPath, options.DbPathExplicit); + WorkspaceMetadataEnricher.Enrich( + status, + options.DbPath, + options.DbPathExplicit, + evaluateOrdinaryFreshness: false); return BuildSqlGraphContractRepairCommand(status.ProjectRoot, options.DbPath, options.DbPathExplicit); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs index 4781a82ce..1b27e780e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Status.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Status.cs @@ -112,7 +112,12 @@ public static int RunStatus( } var status = reader.GetStatus(includeDatabaseSizeAttribution: options.Json); - WorkspaceMetadataEnricher.Enrich(status, options.DbPath, options.DbPathExplicit, cancellationToken); + WorkspaceMetadataEnricher.Enrich( + status, + options.DbPath, + options.DbPathExplicit, + cancellationToken, + evaluateOrdinaryFreshness: !options.CheckWorkspace); status.DataDir = options.DataDir; status.DataDirSource = options.DataDirSource; status.DataDirMode = DataDirectorySecurity.GetUnixModeString(GetDataDirectoryPath(options.DbPath)); @@ -137,7 +142,9 @@ public static int RunStatus( cancellationToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(options.DbPath)); status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked - ? status.WorkspaceCheck.MatchesWorkspace + ? StatusFreshnessEvaluator.Evaluate( + status.WorkspaceCheck, + status.WorktreeHeadChanged).State == StatusFreshnessState.Fresh : null; status.StaleAfterSeconds = (long)Math.Round(staleAfter.Value.TotalSeconds, MidpointRounding.AwayFromZero); status.QueryContext = new StatusQueryContext @@ -1036,13 +1043,19 @@ private static IReadOnlyList BuildStatusCheckFailures(Status { failures.Add(new StatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); } - else if (!status.WorkspaceCheck.MatchesWorkspace) + else { var check = status.WorkspaceCheck; - failures.Add(new StatusCheckFailure( - "workspace_stale", - true, - $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + var freshness = StatusFreshnessEvaluator.Evaluate( + check, + status.WorktreeHeadChanged); + if (freshness.State != StatusFreshnessState.Fresh) + { + failures.Add(new StatusCheckFailure( + "workspace_stale", + true, + $"[stale] workspace_check reason={freshness.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + } } } @@ -1403,21 +1416,8 @@ private static string BuildFoldNotReadyExplanation(string? foldReadyReason) private static string BuildFoldNotReadyWarning(string? foldReadyReason, string backfillCommand, string rebuildCommand) => $"{BuildFoldNotReadyExplanation(foldReadyReason)} Run `{backfillCommand}` to restamp folded-name columns in place, or `{rebuildCommand}` for a full rebuild."; - private static string BuildStatusFreshnessLabel(StatusResult status) - { - if (status.WorkspaceCheck != null) - return status.WorkspaceCheck.Checked - ? (status.WorkspaceCheck.MatchesWorkspace ? "fresh" : "stale") - : "unknown"; - - if (!status.IndexedAt.HasValue || !status.LatestModified.HasValue) - return "unknown"; - - if (status.GitIsDirty == true) - return "stale"; - - return status.IndexedAt.Value >= status.LatestModified.Value ? "fresh" : "stale"; - } + private static string BuildStatusFreshnessLabel(StatusResult status, DateTime utcNow) + => StatusFreshnessEvaluator.Evaluate(status, utcNow).SummaryLabel; private static void WriteWorkspaceCheck(IndexFreshnessCheckResult check) { @@ -1512,10 +1512,10 @@ internal static void ApplyStatusSymbolKindLimits(StatusResult status, Dictionary status.SymbolsByLanguageKindNamesTruncated = truncatedLanguages; } - internal static string BuildStatusSummary(StatusResult status) + internal static string BuildStatusSummary(StatusResult status, DateTime? utcNow = null) { var topLangs = status.Languages.OrderByDescending(kv => kv.Value).Take(3).Select(kv => kv.Key); - var freshness = BuildStatusFreshnessLabel(status); + var freshness = BuildStatusFreshnessLabel(status, utcNow ?? GetUtcNow()); var dirty = status.GitIsDirty == true ? ", dirty" : ""; var degraded = IsStatusDegraded(status) ? ", DEGRADED" : ""; var incomplete = status.IndexComplete ? "" : ", INCOMPLETE"; diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 521d82813..137675506 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -444,17 +444,30 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( projectRoot, cancellationToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + var worktreeHeadChanged = WorkspaceMetadataEnricher.ResolveHeadChanged( + GitHelper.TryGetHeadCommit(projectRoot, cancellationToken), + GitHelper.TryGetHeadBranch(projectRoot, cancellationToken), + snapshot.WorkspaceVerifiedHeadSha, + snapshot.IndexedHeadSha, + snapshot.IndexedHeadBranch, + snapshot.IndexedHeadBranchStampPresent, + snapshot.IndexedHeadCommit, + snapshot.IndexedHeadCommitBranch, + snapshot.IndexedHeadCommitBranchStampPresent); + var freshnessEvaluation = StatusFreshnessEvaluator.Evaluate( + freshness, + worktreeHeadChanged); var status = "ready"; var reason = "ready"; - if (!freshness.Checked) + if (freshnessEvaluation.State == StatusFreshnessState.Unknown) { status = "degraded"; - reason = "freshness_check_unavailable"; + reason = freshnessEvaluation.Reason; } - else if (!freshness.MatchesWorkspace) + else if (freshnessEvaluation.State != StatusFreshnessState.Fresh) { status = "stale"; - reason = freshness.Reason; + reason = freshnessEvaluation.Reason; } else if (!snapshot.IndexComplete) { @@ -488,8 +501,10 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( projectRoot, dbPath), SchemaCompatible: true, - IndexMatchesWorkspace: freshness.Checked ? freshness.MatchesWorkspace : null, - FreshnessReason: freshness.Reason, + IndexMatchesWorkspace: freshness.Checked + ? freshnessEvaluation.State == StatusFreshnessState.Fresh + : null, + FreshnessReason: freshnessEvaluation.Reason, IndexedAt: snapshot.IndexedAt, LatestModified: snapshot.LatestModified, GraphTableAvailable: snapshot.GraphTableAvailable, diff --git a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs index 145fc2102..d15aeb2e9 100644 --- a/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs +++ b/src/CodeIndex/Cli/WorkspaceMetadataEnricher.cs @@ -15,7 +15,8 @@ public static void Enrich( StatusResult status, string dbPath, bool dbPathExplicit = false, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + bool evaluateOrdinaryFreshness = true) { if (status.HeadMetadataSnapshotCaptured) { @@ -39,6 +40,11 @@ public static void Enrich( status.IndexedHeadCommit, status.IndexedHeadCommitBranchSnapshot, status.IndexedHeadCommitBranchStampPresentSnapshot); + status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( + status, + runtime.ProjectRoot, + evaluateOrdinaryFreshness, + cancellationToken); if (runtime.ProjectRoot != null && !string.IsNullOrWhiteSpace(status.IndexedHeadSha)) { status.CommitsAheadOfIndexedHead = GitHelper.TryCountCommitsAhead( @@ -56,6 +62,11 @@ public static void Enrich( status.IndexedHeadCommit = metadata.LegacyIndexedHead; status.WorkspaceVerifiedHeadSha = metadata.WorkspaceVerifiedHead; status.WorktreeHeadChanged = metadata.HeadChanged; + status.GitIndexMayHideWorktreeChanges = ResolveGitIndexVisibility( + status, + metadata.ProjectRoot, + evaluateOrdinaryFreshness, + cancellationToken); // Keep commit-drift diagnostics tied to the latest-write SHA. Whole-workspace // freshness uses the separate verification stamp above, so these two provenance // signals remain explicit instead of silently substituting for each other. @@ -63,6 +74,47 @@ public static void Enrich( status.CommitsAheadOfIndexedHead = GitHelper.TryCountCommitsAhead(metadata.ProjectRoot, status.IndexedHeadSha, cancellationToken); } + private static bool? ResolveGitIndexVisibility( + StatusResult status, + string? projectRoot, + bool evaluateOrdinaryFreshness, + CancellationToken cancellationToken) + { + if (!ShouldProbeGitIndexVisibility(status, projectRoot, evaluateOrdinaryFreshness)) + return null; + + // Keep ordinary status cheap outside the checksum-reused no-op case. Only the + // fallback proof needs to rule out index flags that can mask later changes. + // checksum 再利用 no-op の fallback 証拠を使う場合だけ index flag を確認し、 + // それ以外の通常 status には追加の Git 列挙を行わない。 + return GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(projectRoot!, cancellationToken); + } + + internal static bool ShouldProbeGitIndexVisibility( + StatusResult status, + string? projectRoot, + bool evaluateOrdinaryFreshness) + { + if (!evaluateOrdinaryFreshness + || projectRoot == null + || status.GitIsDirty != false + || status.WorktreeHeadChanged != false + || !status.IndexedAt.HasValue + || !status.LatestModified.HasValue + || !status.LastWorkspaceFreshenedAt.HasValue + || status.IndexedAt.Value >= status.LatestModified.Value + || status.LastWorkspaceFreshenedAt.Value < status.LatestModified.Value) + { + return false; + } + + return !string.IsNullOrWhiteSpace(status.GitHead) + && !string.IsNullOrWhiteSpace(status.WorkspaceVerifiedHeadSha) + && !string.IsNullOrWhiteSpace(status.IndexedHeadSha) + && string.Equals(status.GitHead, status.WorkspaceVerifiedHeadSha, StringComparison.OrdinalIgnoreCase) + && string.Equals(status.WorkspaceVerifiedHeadSha, status.IndexedHeadSha, StringComparison.OrdinalIgnoreCase); + } + public static void Enrich( RepoMapResult map, string dbPath, @@ -210,7 +262,7 @@ private static RuntimeWorkspaceMetadata ResolveRuntime( GitHelper.TryIsWorktreeDirty(projectRoot, cancellationToken)); } - private static bool? ResolveHeadChanged( + internal static bool? ResolveHeadChanged( string? runtimeHead, string? runtimeBranch, string? workspaceVerifiedHead, diff --git a/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs b/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs index a806f9db0..9c5c2f54a 100644 --- a/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs +++ b/src/CodeIndex/Database/DbReader.WorkspaceHealth.cs @@ -9,7 +9,14 @@ internal sealed record WorkspaceIndexHealthSnapshot( bool IndexComplete, bool IndexNewerThanReader, IReadOnlyList IndexIncompleteReasons, - PersistedSymbolKindFilterPolicy SymbolKindFilterPolicy); + PersistedSymbolKindFilterPolicy SymbolKindFilterPolicy, + string? IndexedHeadCommit, + string? IndexedHeadCommitBranch, + bool IndexedHeadCommitBranchStampPresent, + string? WorkspaceVerifiedHeadSha, + string? IndexedHeadSha, + string? IndexedHeadBranch, + bool IndexedHeadBranchStampPresent); public partial class DbReader { @@ -38,6 +45,13 @@ internal WorkspaceIndexHealthSnapshot GetWorkspaceIndexHealth() persistedReadiness.IndexComplete, _indexNewerThanReader, persistedReadiness.IndexIncompleteReasons, - persistedReadiness.SymbolKindFilterPolicy); + persistedReadiness.SymbolKindFilterPolicy, + TryGetMetaStringInternal(DbContext.IndexedHeadCommitMetaKey), + TryGetMetaStringInternal(DbContext.IndexedHeadCommitBranchMetaKey), + HasMetaKeyInternal(DbContext.IndexedHeadCommitBranchMetaKey), + TryGetMetaStringInternal(DbContext.WorkspaceVerifiedHeadShaMetaKey), + TryGetMetaStringInternal(DbContext.IndexedHeadShaMetaKey), + TryGetMetaStringInternal(DbContext.IndexedHeadBranchMetaKey), + HasMetaKeyInternal(DbContext.IndexedHeadBranchMetaKey)); }); } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs index 47d842ee2..bea31f8ef 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Query.Status.cs @@ -46,7 +46,12 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) || projectionFields.Contains("database_size_attribution", StringComparer.Ordinal)); var status = reader.GetStatus(includeDatabaseSizeAttribution); QueryCommandRunner.ApplyStatusSymbolKindLimits(status, reader.GetSymbolKindCounts()); - WorkspaceMetadataEnricher.Enrich(status, _dbPath, _dbPathExplicit, requestToken); + WorkspaceMetadataEnricher.Enrich( + status, + _dbPath, + _dbPathExplicit, + requestToken, + evaluateOrdinaryFreshness: !checkWorkspace); status.DbFileMode = DbContext.GetUnixFileModeString( _dbPath, status.DatabasePermissionPolicy, @@ -68,7 +73,9 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) requestToken, internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(_dbPath)); status.IndexMatchesWorkspace = status.WorkspaceCheck.Checked - ? status.WorkspaceCheck.MatchesWorkspace + ? StatusFreshnessEvaluator.Evaluate( + status.WorkspaceCheck, + status.WorktreeHeadChanged).State == StatusFreshnessState.Fresh : null; status.StaleAfterSeconds = staleAfterSeconds; if (status.IndexedAt.HasValue) @@ -115,7 +122,7 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) status.RecommendedAction = BuildFoldBackfillCommand(_dbPath, _dbPathExplicit); status.AlternativeAction = BuildFoldRebuildRepairCommand(status.ProjectRoot, _dbPath, _dbPathExplicit); } - status.Summary = QueryCommandRunner.BuildStatusSummary(status); + status.Summary = QueryCommandRunner.BuildStatusSummary(status, GetUtcNow()); var checkFailures = checkWorkspace ? BuildMcpStatusCheckFailures(status, statusScopes) : []; @@ -342,13 +349,19 @@ private static IReadOnlyList BuildMcpStatusCheckFailures( { failures.Add(new McpStatusCheckFailure("workspace_unavailable", true, "[stale] workspace_check unavailable")); } - else if (!status.WorkspaceCheck.MatchesWorkspace) + else { var check = status.WorkspaceCheck; - failures.Add(new McpStatusCheckFailure( - "workspace_stale", - true, - $"[stale] workspace_check reason={check.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + var freshness = StatusFreshnessEvaluator.Evaluate( + check, + status.WorktreeHeadChanged); + if (freshness.State != StatusFreshnessState.Fresh) + { + failures.Add(new McpStatusCheckFailure( + "workspace_stale", + true, + $"[stale] workspace_check reason={freshness.Reason} changed={check.ChangedFileCount} missing={check.MissingFileCount} unindexed={check.UnindexedFileCount}")); + } } } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index ee0cca25e..b9e854603 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -1647,6 +1647,14 @@ public class StatusResult public StatusSqliteConnectionPolicy SqliteConnectionPolicy { get; set; } = new(); public string? GitHead { get; set; } public bool? GitIsDirty { get; set; } + // Ordinary status consults this internal runtime signal only when a no-op freshening + // stamp would otherwise prove freshness. False means Git confirmed that no tracked + // entry uses skip-worktree or assume-unchanged; true/null keeps that proof unverified. + // no-op freshening stamp を信頼する場合だけ使う runtime 内部 signal。 + // false は skip-worktree / assume-unchanged が無いことを Git で確認済み、 + // true/null は worktree 変更を隠せるため未検証として扱う。 + [JsonIgnore] + internal bool? GitIndexMayHideWorktreeChanges { get; set; } /// /// Best-effort Linux mandatory-access-control profile for the running process, such as /// `apparmor:snap.cdidx.cdidx` or `selinux:user_u:user_r:user_t:s0`. Null on non-Linux @@ -2093,12 +2101,17 @@ public sealed class StatusHeadFreshness var indexedHeadMatchesLatest = indexedHead != null && string.Equals(indexedHead, latestIndexHead, StringComparison.OrdinalIgnoreCase); var workspaceCheck = status.WorkspaceCheck; + var workspaceFreshness = workspaceCheck is null + ? (StatusFreshnessEvaluation?)null + : StatusFreshnessEvaluator.Evaluate( + workspaceCheck, + status.WorktreeHeadChanged); return new StatusHeadFreshness { - State = ResolveState(status, workspaceCheck), + State = ResolveState(status, workspaceCheck, workspaceFreshness), Scope = "workspace", - StateReason = ResolveStateReason(status, workspaceCheck), + StateReason = ResolveStateReason(status, workspaceCheck, workspaceFreshness), RuntimeHead = NullIfWhiteSpace(status.GitHead), IndexedHead = indexedHead, IndexedHeadSource = ResolveIndexedHeadSource(status), @@ -2109,7 +2122,11 @@ public sealed class StatusHeadFreshness IndexedHeadTimestamp = indexedHeadMatchesLatest ? status.IndexedHeadTimestamp : null, WorkspaceCheckIndexedHeadCommit = NullIfWhiteSpace(workspaceCheck?.IndexedHeadCommit), WorkspaceCheckWorkspaceHeadCommit = NullIfWhiteSpace(workspaceCheck?.WorkspaceHeadCommit), - WorkspaceMatchesIndex = status.IndexMatchesWorkspace ?? workspaceCheck?.MatchesWorkspace, + WorkspaceMatchesIndex = workspaceCheck is null + ? status.IndexMatchesWorkspace + : workspaceCheck.Checked + ? workspaceFreshness?.State == StatusFreshnessState.Fresh + : null, WorktreeHeadChanged = status.WorktreeHeadChanged, CommitsAheadOfIndexedHead = indexedHeadMatchesLatest ? status.CommitsAheadOfIndexedHead : null, }; @@ -2181,16 +2198,19 @@ status.WorkspaceCheck is not null || status.CommitsAheadOfIndexedHead.HasValue || status.IndexMatchesWorkspace.HasValue; - private static string ResolveState(StatusResult status, IndexFreshnessCheckResult? workspaceCheck) + private static string ResolveState( + StatusResult status, + IndexFreshnessCheckResult? workspaceCheck, + StatusFreshnessEvaluation? workspaceFreshness) { if (workspaceCheck is not null) { - if (!workspaceCheck.Checked) + if (workspaceFreshness?.State == StatusFreshnessState.Unknown) return "check_unavailable"; - if (!workspaceCheck.MatchesWorkspace) - return IsHeadChanged(status, workspaceCheck) - ? "head_changed" - : status.IndexComplete ? "stale" : "stale_and_incomplete"; + if (workspaceFreshness?.State == StatusFreshnessState.HeadChanged) + return "head_changed"; + if (workspaceFreshness?.State != StatusFreshnessState.Fresh) + return status.IndexComplete ? "stale" : "stale_and_incomplete"; return status.IndexComplete ? "fresh" : "fresh_but_incomplete"; } @@ -2201,12 +2221,15 @@ private static string ResolveState(StatusResult status, IndexFreshnessCheckResul return "unchecked"; } - private static string? ResolveStateReason(StatusResult status, IndexFreshnessCheckResult? workspaceCheck) + private static string? ResolveStateReason( + StatusResult status, + IndexFreshnessCheckResult? workspaceCheck, + StatusFreshnessEvaluation? workspaceFreshness) { - if (!status.IndexComplete && workspaceCheck?.Checked == true && workspaceCheck.MatchesWorkspace) + if (!status.IndexComplete && workspaceFreshness?.State == StatusFreshnessState.Fresh) return "index_incomplete"; if (workspaceCheck is not null) - return string.IsNullOrWhiteSpace(workspaceCheck.Reason) ? null : workspaceCheck.Reason; + return string.IsNullOrWhiteSpace(workspaceFreshness?.Reason) ? null : workspaceFreshness?.Reason; if (status.WorktreeHeadChanged == true) return "worktree_head_changed"; if (status.WorktreeHeadChanged == false) @@ -2214,11 +2237,6 @@ private static string ResolveState(StatusResult status, IndexFreshnessCheckResul return null; } - private static bool IsHeadChanged(StatusResult status, IndexFreshnessCheckResult workspaceCheck) => - workspaceCheck.HeadChanged - || status.WorktreeHeadChanged == true - || string.Equals(workspaceCheck.Reason, "head_changed", StringComparison.Ordinal); - private static string ResolveIndexedHeadSource(StatusResult status) { if (!string.IsNullOrWhiteSpace(status.WorkspaceVerifiedHeadSha)) diff --git a/src/CodeIndex/Models/StatusFreshnessEvaluator.cs b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs new file mode 100644 index 000000000..daf8d5837 --- /dev/null +++ b/src/CodeIndex/Models/StatusFreshnessEvaluator.cs @@ -0,0 +1,120 @@ +namespace CodeIndex.Database; + +internal enum StatusFreshnessState +{ + Fresh, + Stale, + Unknown, + HeadChanged, + ClockSkew, +} + +internal readonly record struct StatusFreshnessEvaluation( + StatusFreshnessState State, + string Reason) +{ + public string SummaryLabel => State switch + { + StatusFreshnessState.Fresh => "fresh", + StatusFreshnessState.Stale or StatusFreshnessState.HeadChanged => "stale", + _ => "unknown", + }; +} + +/// +/// Classifies status freshness from either an authoritative workspace check or +/// the bounded metadata available to ordinary status. +/// authoritative な workspace check、または通常 status で利用できる bounded metadata から +/// freshness を分類する。 +/// +internal static class StatusFreshnessEvaluator +{ + public static StatusFreshnessEvaluation Evaluate(StatusResult status, DateTime utcNow) + { + if (status.WorkspaceCheck is not null) + return Evaluate(status.WorkspaceCheck, status.WorktreeHeadChanged); + + if (status.WorktreeHeadChanged == true) + return new(StatusFreshnessState.HeadChanged, "head_changed"); + + if (status.GitIsDirty == true) + return new(StatusFreshnessState.Unknown, "worktree_dirty"); + + if (!status.IndexedAt.HasValue || !status.LatestModified.HasValue) + return new(StatusFreshnessState.Unknown, "timestamps_unavailable"); + + var indexedAt = NormalizeUtc(status.IndexedAt.Value); + var latestModified = NormalizeUtc(status.LatestModified.Value); + var lastWorkspaceFreshenedAt = status.LastWorkspaceFreshenedAt.HasValue + ? NormalizeUtc(status.LastWorkspaceFreshenedAt.Value) + : (DateTime?)null; + var normalizedNow = NormalizeUtc(utcNow); + + if (indexedAt > normalizedNow + || latestModified > normalizedNow + || (lastWorkspaceFreshenedAt.HasValue && lastWorkspaceFreshenedAt.Value > normalizedNow)) + { + return new(StatusFreshnessState.ClockSkew, "timestamp_in_future"); + } + + if (indexedAt >= latestModified) + return new(StatusFreshnessState.Fresh, "indexed_after_latest_modified"); + + if (!lastWorkspaceFreshenedAt.HasValue + || lastWorkspaceFreshenedAt.Value < latestModified) + { + return new(StatusFreshnessState.Stale, "latest_modified_after_freshness_evidence"); + } + + return HasTrustedFresheningContext(status) + ? new(StatusFreshnessState.Fresh, "workspace_freshened_after_latest_modified") + : new(StatusFreshnessState.Unknown, "workspace_freshening_unverified"); + } + + public static StatusFreshnessEvaluation Evaluate(IndexFreshnessCheckResult check) + => Evaluate(check, worktreeHeadChanged: null); + + public static StatusFreshnessEvaluation Evaluate( + IndexFreshnessCheckResult check, + bool? worktreeHeadChanged) + { + if (worktreeHeadChanged == true) + return new(StatusFreshnessState.HeadChanged, "head_changed"); + if (!check.Checked) + return new(StatusFreshnessState.Unknown, "freshness_check_unavailable"); + if (check.MatchesWorkspace) + return new(StatusFreshnessState.Fresh, check.Reason); + if (check.HeadChanged || string.Equals(check.Reason, "head_changed", StringComparison.Ordinal)) + return new(StatusFreshnessState.HeadChanged, check.Reason); + return new(StatusFreshnessState.Stale, check.Reason); + } + + private static bool HasTrustedFresheningContext(StatusResult status) + { + if (status.WorktreeHeadChanged != false + || status.GitIsDirty != false + || status.GitIndexMayHideWorktreeChanges != false) + { + return false; + } + + var runtimeHead = NullIfWhiteSpace(status.GitHead); + var workspaceVerifiedHead = NullIfWhiteSpace(status.WorkspaceVerifiedHeadSha); + var latestIndexHead = NullIfWhiteSpace(status.IndexedHeadSha); + return runtimeHead is not null + && workspaceVerifiedHead is not null + && latestIndexHead is not null + && string.Equals(runtimeHead, workspaceVerifiedHead, StringComparison.OrdinalIgnoreCase) + && string.Equals(workspaceVerifiedHead, latestIndexHead, StringComparison.OrdinalIgnoreCase); + } + + private static DateTime NormalizeUtc(DateTime value) => value.Kind switch + { + DateTimeKind.Utc => value, + DateTimeKind.Local => value.ToUniversalTime(), + _ => DateTime.SpecifyKind(value, DateTimeKind.Utc), + }; + + private static string? NullIfWhiteSpace(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value; +} diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 3ec5181d8..9590bbd62 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -1524,6 +1524,21 @@ public void TryIsWorktreeDirty_DetectsModifiedFiles() Assert.True(GitHelper.TryIsWorktreeDirty(repoDir)); } + [ExternalProcessFact] + public void TryIsWorktreeDirty_ForcesUntrackedVisibilityDespiteGitConfig_Issue5227() + { + var repoDir = CreateGitRepo(); + RunGit(repoDir, "commit", "--allow-empty", "-m", "initial"); + RunGit(repoDir, "config", "status.showUntrackedFiles", "no"); + + var untrackedDirectory = Path.Combine(repoDir, "untracked"); + Directory.CreateDirectory(untrackedDirectory); + File.WriteAllText(Path.Combine(untrackedDirectory, "nested.txt"), "indexed outside git\n"); + + Assert.True(GitHelper.TryIsWorktreeDirty(repoDir)); + Assert.True(GitHelper.TryGetWorktreeStatus(repoDir)?.IsDirty); + } + [ExternalProcessFact] public void TryGetWorktreeStatus_DetectsUnresolvedMergeFiles() { diff --git a/tests/CodeIndex.Tests/GitProcessRunnerTests.cs b/tests/CodeIndex.Tests/GitProcessRunnerTests.cs index ad18732d6..f95931230 100644 --- a/tests/CodeIndex.Tests/GitProcessRunnerTests.cs +++ b/tests/CodeIndex.Tests/GitProcessRunnerTests.cs @@ -49,6 +49,36 @@ public void RunCapturingResult_PreservesExitDiagnosticContract_Issue4179() Assert.DoesNotContain("/Users/example/private", value.Diagnostic!); } + [ExternalProcessFact] + public void RunMatchingStdoutLine_ScansBeyondCaptureLimitWithoutMaterializingOutput_Issue5227() + { + if (OperatingSystem.IsWindows()) + return; + + var repoDir = Path.Combine(tempDir, "streaming-repo"); + Directory.CreateDirectory(repoDir); + var fakeGitDir = Path.Combine(tempDir, "streaming-fake-git"); + Directory.CreateDirectory(fakeGitDir); + var fakeGit = WriteFakeGitWithLargeStdoutAndMatch(fakeGitDir); + var psi = new ProcessStartInfo + { + FileName = fakeGit, + WorkingDirectory = repoDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + var matched = GitProcessRunner.RunMatchingStdoutLine( + psi, + TimeSpan.FromSeconds(5), + line => line.StartsWith("S ", StringComparison.Ordinal), + CancellationToken.None); + + Assert.True(matched); + } + private static string WriteFakeGitThatFailsWithLongSensitiveStderr(string directory) { var script = Path.Combine(directory, "git"); @@ -56,6 +86,18 @@ private static string WriteFakeGitThatFailsWithLongSensitiveStderr(string direct #!/bin/sh perl -e 'print STDERR "/Users/example/private/repo/.git/config " . ("x" x 2000)' exit 23 +"""); + if (!OperatingSystem.IsWindows()) + File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return script; + } + + private static string WriteFakeGitWithLargeStdoutAndMatch(string directory) + { + var script = Path.Combine(directory, "git-streaming"); + File.WriteAllText(script, """ +#!/bin/sh +perl -e 'for ($i = 0; $i < 20000; $i++) { print "H tracked/path/$i/abcdefghijklmnopqrstuvwxyz0123456789.cs\n"; } print "S hidden.cs\n"' """); if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index aca8e3b6a..72b9374fc 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3,6 +3,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Runtime.Versioning; using System.Runtime.InteropServices; @@ -11,6 +12,7 @@ using CodeIndex.Indexer; using CodeIndex.Indexer.Extensibility; using CodeIndex.Indexer.Hooks; +using CodeIndex.Mcp; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -6413,6 +6415,270 @@ public void Run_GitRepo_PersistsIndexedHeadMetadata() } } + [Fact] + public void Run_ChecksumReusedNoOpFresheningMakesOrdinaryCheckedAndMcpStatusAgree_Issue5227() + { + var projectRoot = CreateTempProject(); + var dbPath = CreateTempDbPath("cdidx_status_noop_freshening_5227"); + var sourcePath = Path.Combine(projectRoot, "app.py"); + var initialNow = new DateTimeOffset(2099, 1, 2, 3, 0, 0, TimeSpan.Zero); + var clock = new ManualTimeProvider(initialNow); + IndexCommandRunner.TimeProvider = clock; + QueryCommandRunner.TimeProvider = clock; + try + { + File.WriteAllText(sourcePath, "print('hello')\n"); + File.SetLastWriteTimeUtc(sourcePath, initialNow.AddMinutes(-1).UtcDateTime); + RunGit(projectRoot, "init"); + RunGit(projectRoot, "checkout", "-B", "main"); + RunGit(projectRoot, "add", "app.py"); + RunGit(projectRoot, "commit", "-m", "initial"); + + var (initialExitCode, _) = RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, initialExitCode); + DateTime initialIndexedAt; + using (var initialDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var initialReader = new DbReader(initialDb)) + { + initialIndexedAt = Assert.IsType(initialReader.GetStatus().IndexedAt); + } + + File.SetLastWriteTimeUtc(sourcePath, initialNow.AddMinutes(1).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + + var (refreshExitCode, refreshJson) = RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]); + Assert.Equal(CommandExitCodes.Success, refreshExitCode); + Assert.Equal(1, refreshJson.GetProperty("summary").GetProperty("files_skipped").GetInt32()); + + using (var queryDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var reader = new DbReader(queryDb)) + { + var ordinary = reader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(ordinary, dbPath, dbPathExplicit: true); + Assert.Equal(initialIndexedAt, ordinary.IndexedAt); + Assert.Equal(initialNow.AddMinutes(1).UtcDateTime, ordinary.LatestModified); + Assert.Equal(initialNow.AddMinutes(2).UtcDateTime, ordinary.LastWorkspaceFreshenedAt); + var ordinaryEvaluation = StatusFreshnessEvaluator.Evaluate(ordinary, clock.GetUtcNow().UtcDateTime); + Assert.True( + ordinaryEvaluation.State == StatusFreshnessState.Fresh, + $"Expected fresh ordinary status, but got {ordinaryEvaluation.State} ({ordinaryEvaluation.Reason}); " + + $"gitHead={ordinary.GitHead}, indexedHead={ordinary.IndexedHeadSha}, " + + $"workspaceVerifiedHead={ordinary.WorkspaceVerifiedHeadSha}, gitDirty={ordinary.GitIsDirty}, " + + $"headChanged={ordinary.WorktreeHeadChanged}."); + Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + + ordinary.WorkspaceCheck = IndexFreshnessChecker.Check( + reader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + ordinary.IndexMatchesWorkspace = ordinary.WorkspaceCheck.MatchesWorkspace; + Assert.True(ordinary.WorkspaceCheck.Checked); + Assert.True(ordinary.WorkspaceCheck.MatchesWorkspace); + Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + } + + using var server = new McpServer( + dbPath, + "1.0.0-test", + dbPathExplicit: true, + serializeResponse: null, + authenticator: null, + toolFilter: null, + auditLog: null, + McpServer.DefaultMaxConcurrency, + clock); + var ordinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var checkedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index fresh", + ordinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + Assert.Contains( + "index fresh", + checkedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + + RunGit(projectRoot, "update-index", "--skip-worktree", "app.py"); + File.WriteAllText(sourcePath, "print('changed after freshening')\n"); + clock.Advance(TimeSpan.FromMinutes(1)); + + using (var hiddenChangeDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var hiddenChangeReader = new DbReader(hiddenChangeDb)) + { + var hiddenChange = hiddenChangeReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(hiddenChange, dbPath, dbPathExplicit: true); + Assert.False(hiddenChange.GitIsDirty); + Assert.True(hiddenChange.GitIndexMayHideWorktreeChanges); + Assert.Contains( + "index unknown", + QueryCommandRunner.BuildStatusSummary(hiddenChange, clock.GetUtcNow().UtcDateTime)); + + hiddenChange.WorkspaceCheck = IndexFreshnessChecker.Check( + hiddenChangeReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.False(hiddenChange.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(hiddenChange, clock.GetUtcNow().UtcDateTime)); + } + + var hiddenOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var hiddenCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index unknown", + hiddenOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + Assert.Contains( + "index stale", + hiddenCheckedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + + RunGit(projectRoot, "update-index", "--no-skip-worktree", "app.py"); + RunGit(projectRoot, "checkout", "--", "app.py"); + RunGit(projectRoot, "checkout", "--detach", "HEAD"); + + using (var detachedDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var detachedReader = new DbReader(detachedDb)) + { + var detached = detachedReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(detached, dbPath, dbPathExplicit: true); + Assert.False(detached.GitIsDirty); + Assert.True(detached.WorktreeHeadChanged); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(detached, clock.GetUtcNow().UtcDateTime)); + + detached.WorkspaceCheck = IndexFreshnessChecker.Check( + detachedReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.True(detached.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(detached, clock.GetUtcNow().UtcDateTime)); + } + + var (detachedStatusExitCode, detachedStatusJson) = RunStatusAndCaptureJson( + ["--db", dbPath, "--check", "--json"]); + Assert.Equal(1, detachedStatusExitCode); + Assert.False(detachedStatusJson.GetProperty("index_matches_workspace").GetBoolean()); + Assert.True(detachedStatusJson.GetProperty("workspace_check").GetProperty("matches_workspace").GetBoolean()); + Assert.Contains( + detachedStatusJson.GetProperty("failed_checks").EnumerateArray(), + failure => failure.GetString() == "workspace_stale"); + + var detachedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + var detachedMcpStatus = detachedCheckedMcpResponse["result"]!["structuredContent"]!; + Assert.Contains( + "index stale", + detachedMcpStatus["summary"]!.GetValue()); + Assert.False(detachedMcpStatus["index_matches_workspace"]!.GetValue()); + Assert.Contains( + detachedMcpStatus["failed_checks"]!.AsArray(), + failure => failure!.GetValue() == "workspace_stale"); + + RunGit(projectRoot, "checkout", "main"); + RunGit(projectRoot, "config", "status.showUntrackedFiles", "no"); + var untrackedPath = Path.Combine(projectRoot, "untracked.py"); + File.WriteAllText(untrackedPath, "print('untracked v1')\n"); + File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(3).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + Assert.Equal( + CommandExitCodes.Success, + RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]).ExitCode); + + File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(6).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + Assert.Equal( + CommandExitCodes.Success, + RunAndCaptureJson([projectRoot, "--db", dbPath, "--json"]).ExitCode); + + using (var indexedUntrackedDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var indexedUntrackedReader = new DbReader(indexedUntrackedDb)) + { + var indexedUntracked = indexedUntrackedReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(indexedUntracked, dbPath, dbPathExplicit: true); + Assert.True(indexedUntracked.GitIsDirty); + Assert.Contains( + "index unknown", + QueryCommandRunner.BuildStatusSummary(indexedUntracked, clock.GetUtcNow().UtcDateTime)); + + indexedUntracked.WorkspaceCheck = IndexFreshnessChecker.Check( + indexedUntrackedReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.True(indexedUntracked.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index fresh", + QueryCommandRunner.BuildStatusSummary(indexedUntracked, clock.GetUtcNow().UtcDateTime)); + } + + var (indexedUntrackedStatusExitCode, indexedUntrackedStatusJson) = RunStatusAndCaptureJson( + ["--db", dbPath, "--check", "--json"]); + Assert.Equal(CommandExitCodes.Success, indexedUntrackedStatusExitCode); + Assert.True(indexedUntrackedStatusJson.GetProperty("workspace_check").GetProperty("matches_workspace").GetBoolean()); + Assert.True(indexedUntrackedStatusJson.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Contains("index fresh", indexedUntrackedStatusJson.GetProperty("summary").GetString()); + + var indexedUntrackedOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var indexedUntrackedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index unknown", + indexedUntrackedOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + var indexedUntrackedCheckedMcpStatus = indexedUntrackedCheckedMcpResponse["result"]!["structuredContent"]!; + Assert.Contains("index fresh", indexedUntrackedCheckedMcpStatus["summary"]!.GetValue()); + Assert.True(indexedUntrackedCheckedMcpStatus["index_matches_workspace"]!.GetValue()); + Assert.True(indexedUntrackedCheckedMcpStatus["workspace_check"]!["matches_workspace"]!.GetValue()); + + File.WriteAllText(untrackedPath, "print('untracked v2')\n"); + File.SetLastWriteTimeUtc(untrackedPath, initialNow.AddMinutes(8).UtcDateTime); + clock.Advance(TimeSpan.FromMinutes(2)); + + using (var untrackedDb = new DbContext(DbOpenIntent.QueryOnly, dbPath)) + using (var untrackedReader = new DbReader(untrackedDb)) + { + var ordinary = untrackedReader.GetStatus(); + WorkspaceMetadataEnricher.Enrich(ordinary, dbPath, dbPathExplicit: true); + Assert.True(ordinary.GitIsDirty); + Assert.Contains( + "index unknown", + QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + + ordinary.WorkspaceCheck = IndexFreshnessChecker.Check( + untrackedReader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(dbPath)); + Assert.False(ordinary.WorkspaceCheck.MatchesWorkspace); + Assert.Contains( + "index stale", + QueryCommandRunner.BuildStatusSummary(ordinary, clock.GetUtcNow().UtcDateTime)); + } + + var untrackedOrdinaryMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"status","arguments":{}}}""")!)!; + var untrackedCheckedMcpResponse = server.HandleMessage(JsonNode.Parse( + """{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"status","arguments":{"check":true}}}""")!)!; + Assert.Contains( + "index unknown", + untrackedOrdinaryMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + Assert.Contains( + "index stale", + untrackedCheckedMcpResponse["result"]!["structuredContent"]!["summary"]!.GetValue()); + } + finally + { + IndexCommandRunner.TimeProvider = TimeProvider.System; + QueryCommandRunner.TimeProvider = TimeProvider.System; + DeleteDirectory(projectRoot); + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + } + } + [Fact] public void Run_NonGitRepo_DoesNotPersistIndexedHeadMetadata() { diff --git a/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs b/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs index c88acb409..d8dbf6ddc 100644 --- a/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs +++ b/tests/CodeIndex.Tests/PathCompatibilityMatrixTests.cs @@ -299,6 +299,8 @@ public void GitSkipWorktreeMatrix_ReturnsNormalizedSparsePaths() TestProjectHelper.RunGit(workspace.Root, "add", "src/App.cs", "generated/Skip.cs"); TestProjectHelper.RunGit(workspace.Root, "commit", "-m", "seed"); + Assert.False(GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(workspace.Root)); + TestProjectHelper.RunGit(workspace.Root, "update-index", "--skip-worktree", "generated/Skip.cs"); var skipWorktreePaths = GitHelper.TryGetSkipWorktreePaths(workspace.Root); @@ -306,6 +308,12 @@ public void GitSkipWorktreeMatrix_ReturnsNormalizedSparsePaths() Assert.NotNull(skipWorktreePaths); Assert.Contains("generated/Skip.cs", skipWorktreePaths); Assert.DoesNotContain(skipWorktreePaths!, path => path.Contains('\\', StringComparison.Ordinal)); + Assert.True(GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(workspace.Root)); + + TestProjectHelper.RunGit(workspace.Root, "update-index", "--no-skip-worktree", "generated/Skip.cs"); + TestProjectHelper.RunGit(workspace.Root, "update-index", "--assume-unchanged", "src/App.cs"); + + Assert.True(GitHelper.TryHasWorktreeVisibilityLimitingIndexFlags(workspace.Root)); } private sealed class MatrixWorkspace : IDisposable diff --git a/tests/CodeIndex.Tests/PerformanceTests.cs b/tests/CodeIndex.Tests/PerformanceTests.cs index d046a805e..d6a73f321 100644 --- a/tests/CodeIndex.Tests/PerformanceTests.cs +++ b/tests/CodeIndex.Tests/PerformanceTests.cs @@ -370,11 +370,17 @@ public void CiPerformanceSmoke_IndexAndSearchSmallFixture_StaysWithinBudget() Assert.True(chunks >= 120, $"Expected at least one chunk per file, got {chunks}"); Assert.True(symbols >= 240, $"Expected class and method symbols from the smoke fixture, got {symbols}"); Assert.True(references > 0, "Expected reference rows from the smoke fixture."); - // Hosted Windows runners have wider filesystem and process-scheduling variance. - // Keep a bounded platform budget without weakening other lanes. - // hosted Windows runner は filesystem / process scheduling の変動幅が大きいため、 - // 他 lane の基準は維持したまま platform 別の上限を設定する。 - var indexBudget = OperatingSystem.IsWindows() + // Hosted Windows runners have wider filesystem and process-scheduling variance, + // while coverage instrumentation adds deterministic hot-path overhead. Keep the + // strict budget for every uninstrumented non-Windows run, including CI retries. + // hosted Windows runner は filesystem / process scheduling の変動幅が大きく、coverage + // instrumentation には hot path の追加 overhead がある。CI retry を含む coverage なしの + // 非 Windows 実行では厳しい上限を維持する。 + var coverageInstrumentationActive = string.Equals( + Environment.GetEnvironmentVariable("CODEINDEX_TEST_COVERAGE"), + "true", + StringComparison.OrdinalIgnoreCase); + var indexBudget = OperatingSystem.IsWindows() || coverageInstrumentationActive ? TimeSpan.FromSeconds(45) : TimeSpan.FromSeconds(20); Assert.True( diff --git a/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs new file mode 100644 index 000000000..e1ce8e91a --- /dev/null +++ b/tests/CodeIndex.Tests/StatusFreshnessEvaluatorTests.cs @@ -0,0 +1,141 @@ +using CodeIndex.Cli; +using CodeIndex.Database; + +namespace CodeIndex.Tests; + +public class StatusFreshnessEvaluatorTests +{ + private static readonly DateTime IndexedAt = new(2030, 1, 2, 3, 0, 0, DateTimeKind.Utc); + private static readonly DateTime ModifiedAt = IndexedAt.AddMinutes(1); + private static readonly DateTime FreshenedAt = IndexedAt.AddMinutes(2); + private static readonly DateTime EvaluatedAt = IndexedAt.AddMinutes(3); + + [Fact] + public void Evaluate_OrdinaryStatusUsesOnlyTrustedNoOpFreshening_Issue5227() + { + var status = CreateStatus(); + + var trusted = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Fresh, trusted.State); + Assert.Equal("workspace_freshened_after_latest_modified", trusted.Reason); + Assert.Equal("fresh", trusted.SummaryLabel); + Assert.Contains("index fresh", QueryCommandRunner.BuildStatusSummary(status, EvaluatedAt)); + + status.GitIndexMayHideWorktreeChanges = true; + var hiddenIndexState = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Unknown, hiddenIndexState.State); + Assert.Equal("workspace_freshening_unverified", hiddenIndexState.Reason); + + status.GitIndexMayHideWorktreeChanges = false; + status.WorkspaceVerifiedHeadSha = null; + var unverified = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Unknown, unverified.State); + Assert.Equal("workspace_freshening_unverified", unverified.Reason); + Assert.Equal("unknown", unverified.SummaryLabel); + } + + [Fact] + public void Evaluate_OrdinaryStatusKeepsConservativeTimestampAndWorktreeBoundaries_Issue5227() + { + var status = CreateStatus(); + + status.LatestModified = FreshenedAt.AddSeconds(1); + var changedAfterFreshening = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Stale, changedAfterFreshening.State); + Assert.Equal("latest_modified_after_freshness_evidence", changedAfterFreshening.Reason); + + status.LatestModified = ModifiedAt; + status.LastWorkspaceFreshenedAt = null; + var missingFresheningStamp = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Stale, missingFresheningStamp.State); + + status.LastWorkspaceFreshenedAt = EvaluatedAt.AddSeconds(1); + var clockSkew = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.ClockSkew, clockSkew.State); + Assert.Equal("timestamp_in_future", clockSkew.Reason); + Assert.Equal("unknown", clockSkew.SummaryLabel); + + status.LastWorkspaceFreshenedAt = FreshenedAt; + status.WorktreeHeadChanged = true; + var headChanged = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.HeadChanged, headChanged.State); + Assert.Equal("head_changed", headChanged.Reason); + + status.WorkspaceCheck = new IndexFreshnessCheckResult + { + Checked = true, + MatchesWorkspace = true, + Reason = "matched", + }; + var checkedHeadChanged = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.HeadChanged, checkedHeadChanged.State); + Assert.Equal("head_changed", checkedHeadChanged.Reason); + var headFreshness = status.HeadFreshness; + Assert.NotNull(headFreshness); + Assert.Equal("head_changed", headFreshness.State); + Assert.Equal("head_changed", headFreshness.StateReason); + Assert.False(headFreshness.WorkspaceMatchesIndex); + + status.WorkspaceCheck = null; + status.WorktreeHeadChanged = false; + status.GitIsDirty = true; + var dirty = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Unknown, dirty.State); + Assert.Equal("worktree_dirty", dirty.Reason); + + status.WorkspaceCheck = new IndexFreshnessCheckResult + { + Checked = true, + MatchesWorkspace = true, + Reason = "matched", + }; + var checkedDirty = StatusFreshnessEvaluator.Evaluate(status, EvaluatedAt); + Assert.Equal(StatusFreshnessState.Fresh, checkedDirty.State); + Assert.Equal("matched", checkedDirty.Reason); + Assert.Equal("fresh", status.HeadFreshness?.State); + Assert.True(status.HeadFreshness?.WorkspaceMatchesIndex); + } + + [Theory] + [InlineData(false, false, false, "Unknown", "freshness_check_unavailable")] + [InlineData(true, true, false, "Fresh", "matched")] + [InlineData(true, false, false, "Stale", "changed_files")] + [InlineData(true, false, true, "HeadChanged", "head_changed")] + public void Evaluate_WorkspaceCheckUsesTheSameClassification( + bool checkedWorkspace, + bool matchesWorkspace, + bool headChanged, + string expectedState, + string expectedReason) + { + var check = new IndexFreshnessCheckResult + { + Checked = checkedWorkspace, + MatchesWorkspace = matchesWorkspace, + HeadChanged = headChanged, + Reason = expectedReason == "freshness_check_unavailable" ? "project_root_unavailable" : expectedReason, + }; + + var evaluation = StatusFreshnessEvaluator.Evaluate(check); + + Assert.Equal(expectedState, evaluation.State.ToString()); + Assert.Equal(expectedReason, evaluation.Reason); + } + + private static StatusResult CreateStatus() => new() + { + Files = 1, + Symbols = 1, + References = 0, + Languages = new Dictionary(StringComparer.Ordinal) { ["csharp"] = 1 }, + IndexedAt = IndexedAt, + LatestModified = ModifiedAt, + LastWorkspaceFreshenedAt = FreshenedAt, + GitHead = "0123456789abcdef", + GitIsDirty = false, + GitIndexMayHideWorktreeChanges = false, + IndexedHeadSha = "0123456789abcdef", + WorkspaceVerifiedHeadSha = "0123456789abcdef", + WorktreeHeadChanged = false, + }; +} diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 8429ffaed..6145309e2 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -475,6 +475,49 @@ public void WorkspaceStatus_PropagatesCancellationToMemberHealthScan_Issue4726() } } + [Fact] + public void WorkspaceStatusCheck_ReportsSameCommitBranchDriftAsStale_Issue5227() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_workspace_status_head_drift_5227"); + var root = project.Root; + File.WriteAllText(Path.Combine(root, "App.cs"), "class App {}\n"); + File.WriteAllText( + Path.Combine(root, "cdidx.workspace.json"), + """{ "members": ["."] }"""); + TestProjectHelper.RunGit(root, "init"); + TestProjectHelper.RunGit(root, "config", "user.email", "tests@example.com"); + TestProjectHelper.RunGit(root, "config", "user.name", "CodeIndex Tests"); + TestProjectHelper.RunGit(root, "checkout", "-B", "main"); + TestProjectHelper.RunGit(root, "add", "App.cs", "cdidx.workspace.json"); + TestProjectHelper.RunGit(root, "commit", "--no-gpg-sign", "-m", "initial"); + IndexProject(root); + TestProjectHelper.RunGit(root, "checkout", "--detach", "HEAD"); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.StaleIndex, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var health = document.RootElement.GetProperty("members")[0].GetProperty("index_health"); + Assert.Equal("stale", health.GetProperty("status").GetString()); + Assert.Equal("head_changed", health.GetProperty("reason").GetString()); + Assert.False(health.GetProperty("index_matches_workspace").GetBoolean()); + Assert.Equal("head_changed", health.GetProperty("freshness_reason").GetString()); + var summary = document.RootElement.GetProperty("member_health_summary"); + Assert.Equal("degraded", summary.GetProperty("status").GetString()); + Assert.Equal(CommandExitCodes.StaleIndex, summary.GetProperty("check_exit_code").GetInt32()); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + [Fact] public void CheckedInWorkspaceManifest_ResolvesExistingProjectMembers_Issue4476() { diff --git a/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs b/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs index a7ecb2d6f..2201d5531 100644 --- a/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceMetadataEnricherTests.cs @@ -10,6 +10,41 @@ namespace CodeIndex.Tests; [Collection("SQLite pool sensitive")] public class WorkspaceMetadataEnricherTests { + [Fact] + public void ShouldProbeGitIndexVisibility_RequiresUsableOrdinaryFreshnessContext_Issue5227() + { + var indexedAt = new DateTime(2030, 1, 2, 3, 0, 0, DateTimeKind.Utc); + var status = new StatusResult + { + IndexedAt = indexedAt, + LatestModified = indexedAt.AddMinutes(1), + LastWorkspaceFreshenedAt = indexedAt.AddMinutes(2), + GitHead = "0123456789abcdef", + GitIsDirty = false, + WorktreeHeadChanged = false, + WorkspaceVerifiedHeadSha = "0123456789abcdef", + IndexedHeadSha = "0123456789abcdef", + }; + + Assert.True(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility( + status, + "/repo", + evaluateOrdinaryFreshness: true)); + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility( + status, + "/repo", + evaluateOrdinaryFreshness: false)); + + status.GitIsDirty = true; + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility(status, "/repo", true)); + status.GitIsDirty = false; + status.WorktreeHeadChanged = true; + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility(status, "/repo", true)); + status.WorktreeHeadChanged = false; + status.WorkspaceVerifiedHeadSha = "fedcba9876543210"; + Assert.False(WorkspaceMetadataEnricher.ShouldProbeGitIndexVisibility(status, "/repo", true)); + } + [Fact] public void Enrich_ResultTypes_PopulateProjectRootHeadAndDirty() {