Skip to content

refactor(git): RefSnapshot — close ambient ref-keyed cache staleness class#2528

Merged
max-sixty merged 7 commits into
mainfrom
cache-staleness
May 2, 2026
Merged

refactor(git): RefSnapshot — close ambient ref-keyed cache staleness class#2528
max-sixty merged 7 commits into
mainfrom
cache-staleness

Conversation

@max-sixty

Copy link
Copy Markdown
Owner

Summary

Replaces the ambient ref-keyed caches in RepoCache with an explicit, point-in-time RefSnapshot value threaded through read paths. This closes the bug class behind PR #2507 (post-update-ref integration check seeing pre-write SHAs) — and several latent variants — by making the caches that could go stale unrepresentable.

Why

RepoCache cached ref-name → SHA in commit_shas: DashMap<String, String>, plus composite caches keyed off it (integration_reasons, effective_integration_targets, ahead_behind, integration_target OnceCell, tree_shas, resolved_refs). When wt itself moved a ref mid-command — most prominently wt merge running git update-ref refs/heads/<target> to advance the local target — those caches went stale, and any downstream read returned pre-write SHAs.

PR #2507 patched one symptom by adding ref_is_ancestor (a cache-bypass helper) at the integration-check call site. That works locally; every other ref-name read after a write was a latent bug.

The structural fix: there is no longer an ambient ref-name → SHA cache. Callers explicitly capture a RefSnapshot at known points and thread it through the read paths. After ref-mutating operations, callers capture a fresh snapshot — the discipline lives at the small set of write boundaries (currently one site, finish_after_merge), not at every ref-name accessor.

Background analysis: docs/dev/cache-staleness.md (commit ba8b239) walks through six options and the trade-offs.

What changed

  • New src/git/repository/ref_snapshot.rsRefSnapshot value type. One for-each-ref over local + remote refs, optional ahead/behind batch. Cloned by value, no OnceCell/Arc. ~330 lines including unit tests (the freshness contract test mutates a ref and re-captures).
  • Two-tier API_by_sha siblings of cached methods (is_ancestor_by_sha, merge_base_by_sha, branch_diff_stats_by_sha, ahead_behind_by_sha, has_added_changes_by_sha, trees_match_by_sha, merge_integration_probe_by_sha, …). The persistent on-disk sha_cache (already SHA-keyed) sits behind these.
  • Snapshot-taking integration pathcompute_integration_lazy, integration_reason, compute_integration_reason_uncached, integration_targets, delete_branch_if_safe, remove_worktree_with_cleanup all take &RefSnapshot.
  • Post-write fresh-snapshot patternwt merge's finish_after_merge captures a fresh snapshot AFTER handle_no_ff_merge/handle_push runs update-ref. This is the structural fix for PR fix(integration): OR over local + upstream for integration detection #2507. The ref_is_ancestor workaround is deleted.
  • wt list migration — pre-skeleton phase captures one snapshot; TaskContext carries it; tasks call _by_sha siblings via the snapshot. No regression intended (one extra for-each-ref upfront, lose per-task rev-parse fallbacks).
  • Cache deletionscommit_shas, tree_shas, effective_integration_targets, integration_reasons, ahead_behind, integration_target OnceCell, resolved_refs all gone from RepoCache. LocalBranchInventory's priming side-effects (writes to commit_shas/resolved_refs) removed.
  • Uncached primitivesrev_parse_commit, rev_parse_tree, resolve_preferring_branch no longer cache; consumers that need SHA-stable lookups thread a RefSnapshot.
  • Persistent caches retained — on-disk sha_cache::* and the in-memory merge_base / branch_diff_stats caches keep their SHA-keyed contract.

Snapshot test deltas

Three list_*_with_nonexistent_default_branch.snap snapshots no longer show the same-commit _ symbol when the configured default branch is missing — that was a false positive (no ref to compare against). list_warns_when_commit_details_batch_fails.snap now embeds the resolved SHA in the error rather than the literal "main" ref name (strict improvement — the SHA is what was passed to git).

Regression coverage

Out of scope (deferred)

Both flagged by /reviewing-code and explicitly deferred:

  • prepare_worktree_removal re-captures a snapshot per candidate inside step_prune — N+1 for-each-ref calls. Threading an optional &RefSnapshot would make step_prune reuse its entry snapshot. Affects display consistency in unusual hook setups (a post-remove hook that runs git fetch between candidates can desync the displayed integration target from the safety decision); not a safety bug.
  • head_shas cache retained. Different staleness model (per-worktree, mutated by commit/reset rather than update-ref); design doc lists it as a remaining vector. Out of scope for this PR's integration-target focus.

Test plan

  • cargo run -- hook pre-merge --yes (3413 tests pass before the merge of origin/main)
  • 1071 lib + 607 bin + 1588 integration tests pass after merging origin/main
  • cargo check --all-targets clean
  • CI green

max-sixty and others added 7 commits April 30, 2026 19:48
Records the design exploration behind the upcoming RefSnapshot refactor:
inventory of ref-keyed RepoCache fields at risk after PR #2507, six options
considered, and the rationale for landing on RefSnapshot (Option 1) — the
only option that handles composite caches (integration_reasons,
effective_integration_targets, ahead_behind) structurally rather than by
naming-vector deletion alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces a captured, immutable view of repository ref state. RefSnapshot
is the structural answer to ref-name cache staleness — see
docs/dev/cache-staleness.md for the full rationale.

Repository::capture_refs runs one for-each-ref refs/heads/ + one
for-each-ref refs/remotes/ and assembles the result into an immutable value.
capture_refs_with_ahead_behind additionally batches ahead/behind counts
(git ≥ 2.36).

The snapshot is a value, not a cache field — no OnceCell, no Arc, no shared
mutable state. Two captures within a command produce two distinct snapshots;
neither invalidates the other. This is intentional: it removes the
"invisible refresh" surface that ambient ref-keyed caching introduces.

This commit is purely additive. The existing LocalBranchInventory and
RepoCache ref-keyed fields are untouched; both paths coexist temporarily.
Subsequent commits will (a) add SHA-taking sibling methods, (b) migrate
wt list and the integration check to consume snapshots, and (c) delete
the ambient ref caches plus the ref_is_ancestor workaround from #2507.

Tests cover the freshness contract: capture, mutate ref via git commit,
capture again, assert SHAs differ — and the earlier snapshot still
reports the pre-write SHA, by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/git/mod.rs
#	src/git/repository/mod.rs
The ambient `commit_shas: DashMap<String, String>` and the composite
caches derived from it (`integration_reasons`, `effective_integration_targets`,
`ahead_behind`, `integration_target` OnceCell, `tree_shas`, `resolved_refs`)
went stale whenever wt itself moved a ref mid-command — most prominently
in `wt merge`, where `update-ref` advances the local target between the
merge and the post-write integration check (PR #2507).

Replace the ambient cache layer with explicit, point-in-time `RefSnapshot`
values threaded through the read paths:

- `wt list` captures one snapshot in the pre-skeleton phase and threads
  it into every task via `TaskContext`. Tasks call `_by_sha` siblings
  (`is_ancestor_by_sha`, `ahead_behind_by_sha`, …) instead of going
  through ref-keyed lookups.
- `compute_integration_lazy`, `integration_reason`,
  `effective_integration_target`, `integration_target(s)`,
  `compute_integration_reason`, `delete_branch_if_safe`, and
  `remove_worktree_with_cleanup` all take a `&RefSnapshot`.
- `finish_after_merge` captures a **fresh** snapshot AFTER
  `handle_no_ff_merge`/`handle_push` runs `update-ref` — this is the
  structural fix for PR #2507. The `ref_is_ancestor` workaround is
  deleted.

`rev_parse_commit`, `rev_parse_tree`, and `resolve_preferring_branch`
become uncached; the priming side-effect in `scan_local_branches`
(writes to `commit_shas`/`resolved_refs`) is removed. The persistent
SHA-keyed `merge_base` and `branch_diff_stats` caches stay (key
contract tightened: SHAs only).

Snapshot test updates reflect strictly better behavior:
- `list_warns_when_commit_details_batch_fails` now embeds the resolved
  SHA in the error rather than the literal "main" — that's what was
  passed to git.
- The three `list_*_with_nonexistent_default_branch` snapshots no
  longer show the same-commit `_` symbol when the configured default
  branch is missing (a false positive — there was no ref to compare
  against).

PR #2507's regression test (`test_remove_merged_locally_when_upstream_diverged`)
still passes without `ref_is_ancestor`. PR #2513's
`test_list_integrated_when_merged_locally_with_upstream_diverged` passes.
3413/3413 tests under `wt hook pre-merge --yes`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	src/commands/step_commands.rs
#	src/git/repository/integration.rs
#	src/git/repository/mod.rs
…lone

Three small follow-ups surfaced by /reviewing-code over the
RefSnapshot cutover:

- `branches.rs`: `local_branches` doc still claimed the scan primed
  `resolved_refs` and `commit_shas` — both gone. Replace with the
  current contract: `commit_sha` is a snapshot at scan time; current
  SHAs come from a `RefSnapshot`.
- `diff.rs`: `merge_base` doc referenced "the cached `commit_shas` map"
  for resolution. Point at `resolve_to_commit_sha` instead, which is
  the actual path now (hex short-circuit + uncached `rev-parse`).
- `step_commands.rs`: drop a `snapshot.clone()` before moving into an
  `Arc` — `snapshot` isn't read after that point in `step_prune`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier docstring refresh introduced two broken intra-doc links
that `RUSTDOCFLAGS=-Dwarnings cargo doc` rejects:

- `branches.rs:111`: `[`RefSnapshot`]` doesn't resolve in `branches.rs`
  scope (the type lives in a sibling module).
- `diff.rs:152`: `[`Self::resolve_to_commit_sha`]` is a public doc
  linking to a private item.

Both fix the same way: drop the link bracket form, keep the prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@max-sixty max-sixty merged commit 845baeb into main May 2, 2026
28 checks passed
@max-sixty max-sixty deleted the cache-staleness branch May 2, 2026 15:59
max-sixty added a commit that referenced this pull request May 2, 2026
…hot cutover (#2530)

## Summary

Two follow-ups deferred from #2528 (the `RefSnapshot` cutover that
closed the ambient ref-keyed cache staleness class), plus reviewer
cleanups.

- **Thread `RefSnapshot` through `prepare_worktree_removal`** — adds
`snapshot: Option<&RefSnapshot>`; `step_prune` and
`validate_remove_targets` capture once and reuse across candidates
instead of re-running `for-each-ref` per candidate (N+1 → 1).
- **Drop `head_shas` cache** — the per-worktree HEAD SHA cache had no
invalidation. Any flow that moved HEAD mid-command (rebase, hook-emitted
commits) left a stale value that surfaced in `{{ commit }}` template
variables for later hooks. `WorkingTree::head_sha` now always reads
fresh; new `head_sha_tracks_head_movement` test guards against
re-introducing a cache.
- **Reviewer cleanups** — refreshed four stale comments referring to the
deleted cache, collapsed a now-redundant short-circuit in
`command_executor.rs::build_hook_context`, and replaced
`Arc::new(snapshot.clone())` in `step_prune`'s rayon worker with
`Arc::clone` (shares by refcount, no deep copy).

## Test plan

- [x] `cargo run -- hook pre-merge --yes` — full test + lint suite (3425
tests pass; clippy / doc / doctest with `-Dwarnings` clean)
- [x] New unit test `head_sha_tracks_head_movement` exercises the
freshness contract directly
- [x] Manual review of stale-comment fixes vs current behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude <noreply@anthropic.com>
@max-sixty max-sixty mentioned this pull request May 3, 2026
max-sixty added a commit that referenced this pull request May 3, 2026
Cuts v0.47.0. Highlights:

- **Integration detection fix** across `wt list`, `wt remove`, `wt
merge`, `wt step prune` for the local/upstream-diverged case (#2507,
#2513, #2515).
- **Faster shell-command dispatch** (~10ms per invocation, 1.49× on
trivial aliases) via event-driven signal forwarding (#2537, #2538), plus
parallel config load (#2543) and merged cold-start prewarm rev-parse
(#2541).
- **`wt switch <number>` suggests `pr:N` / `mr:N`** instead of just
`--create` (#2516).
- **Library API breaking changes**: `RefSnapshot` cutover (#2528, #2530)
and the `for-each` direct-exec change (#2465). Full list in
`CHANGELOG.md`.

See `CHANGELOG.md` for the complete entry.

> _This was written by Claude Code on behalf of @max-sixty_

---------

Co-authored-by: Claude <noreply@anthropic.com>
max-sixty added a commit that referenced this pull request May 11, 2026
The doc proposed and analyzed six options for closing the ref-keyed
cache staleness class; the codebase adopted Option 5 (`RefSnapshot`) in
#2528 and extended it with the content-addressed `ahead-behind/` SHA
cache in #2704. The structural contract now lives in
`src/git/repository/ref_snapshot.rs` (snapshot semantics, lifetime,
construction) and the cache-kind landscape in
`src/commands/list/collect/mod.rs`'s Caching section, both of which
supersede what was in this file.

What remained in the doc actively misleads: its `RepoCache` inventory
table cites fields that no longer exist (`commit_shas`, `tree_shas`,
`effective_integration_targets`, `integration_reasons`, `ahead_behind`,
`head_shas`) and references a `batch_ahead_behind` helper that's been
replaced. The file was an island — no inbound links from code, tests, or
other docs, and `docs/dev/` is not a Zola content directory so it was
never published — so deletion is clean. Anyone curious about the
original design debate can find it at the merge commit for #2528.

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants