Skip to content

perf(prepush): the green-tree cache never hits in a merge loop — key expensive gates on their own inputs #6765

Description

@koala73

Symptom

The pre-push green-tree cache never hits in the workflow that needs it most — the merge / amend / re-push loop.

Evidence from session reflective-squishing-aurora (Canada merge campaign, ~24h, 66 push invocations):

  • 10 Tree cached — an identical re-push skips straight to the end. (cache writes)
  • 0 observed Pre-push gates: this exact tree already passed (cache hits)
  • median push wall-clock 45.6s, worst 216.5s — inconsistent with hits being common

Every merge, conflict re-resolve, git commit --amend, and regenerated-artifact commit produces a new HEAD^{tree}, so the key changes on literally every push in that loop. The cache pays its bookkeeping and returns nothing.

Root cause

.husky/pre-push:250-256 keys the entire cache on one whole-tree hash:

250: GATE_CACHE="$(git rev-parse --git-dir)/wm-prepush-green"
251: TREE_HASH=$(git rev-parse 'HEAD^{tree}' 2>/dev/null || echo "")
252: if bash "$ATTEST" cache-read "$GATE_CACHE" "$TREE_HASH" "$DIFF_RESOLVED"; then

It is all-or-nothing: any byte anywhere in the tree invalidates every gate, including gates whose inputs did not change. A docs-only amend after a red CI run re-pays the full suite.

What the gates actually cost

Measured in curious-frolicking-kite, 2026-08-16, warm:

gate cold warm
pro-test (npm ci + vite build) 54.9s 7.1s
resilience tests 23.8s
make generate (proto freshness) 10.5s
typecheck:api 7.8s
lint:rate-limit-policies 7.9s
test:dom (36 files) 6.7s
check-unicode-safety 5.0s
lint:md 4.4s
typecheck + convex + 6 other lints ~11s

Sum with everything firing ≈ 165s, which matches the observed 149s and 216s pushes.

Proposed fix — key each expensive gate on its own inputs

Add a per-gate cache alongside (not replacing) the whole-tree one. Key = sha256(gate-name + content hash of that gate's declared inputs). Store under $(git rev-parse --git-common-dir)/wm-prepush-gate-cache/.

Starting set, highest payoff first:

gate inputs to hash
pro-test bundle (:660-706) pro-test/src/**, pro-test/package-lock.json, convex/config/productCatalog.ts, scripts/generate-product-config.mjs
proto freshness (:593-640) proto/**, Makefile, scripts/generate-request-validation.mjs
typecheck / typecheck:api the file set each tsconfig includes
lint:md / MDX lint the matched .md / .mdx files

Hash the worktree bytes, not HEAD:<path> — the gates run the worktree, and conflating the two is the exact class of bug the attestation machinery in scripts/prepush-attest.sh exists to prevent. git hash-object over the input list is the cheap primitive.

Hard constraints

  1. Honour the existing attestation rules. A per-gate entry must never be written when the run cannot vouch for what it tested — mirror the ATTESTABLE / DIFF_RESOLVED refusals in scripts/prepush-attest.sh (cache-write). A cache entry is a "bad run that suppresses future runs", which is why those rules exist.
  2. Only cache side-effect-free gates. This is not hypothetical: PR chore(agents): add preflight and PR snapshot gates #6748 briefly added npm run inventory:facts inside the edge-bundle gate. Had that landed, caching-and-skipping that gate would also have skipped a generation later steps depend on. If a gate mutates the worktree, either exclude it or split the mutation out first.
  3. Put the read/write decisions in scripts/prepush-attest.sh, not inline in the hook. That file exists precisely so a test can execute these decisions against real git fixtures instead of grepping the hook's source text.

Acceptance criteria

  • Re-pushing an amended commit that changed only, say, a Markdown file skips the pro-test, proto, and typecheck gates and re-runs only the markdown lint.
  • Changing any declared input of a gate invalidates that gate and no others.
  • A gate is never skipped on a run that would be refused by the existing cache-write rules (unresolved branch diff, worktree not byte-identical to HEAD).
  • No gate with worktree side effects is in the cacheable set.
  • Cache entries are keyed so two worktrees with identical inputs share a hit (163 worktrees exist today; this is most of the value).
  • Measured: a docs-only amend re-push drops from ~150s to under ~15s.

How to verify

Extend tests/prepush-attest.test.mjs with real git fixtures covering: hit, miss on changed input, no-cross-gate-invalidation, and refusal under a dirty worktree. Then measure a real amend-and-repush cycle before/after.

Landmines

  • Never analyse or push from the main checkout — use a worktree or origin/main.
  • Editing .husky/pre-push triggers its contract tests (path_matches at .husky/pre-push:467).
  • Read the comment block at .husky/pre-push:127-141 and :258-296 before touching this. It documents why the ordering and the drift measurement are where they are.
  • Heavy checks in a worktree run sequentially — running them in parallel gets you exit 137.

Non-goals

Do not change the whole-tree cache's semantics or remove it; add the per-gate layer beneath it.


This series

Four issues from one measured diagnosis of pre-push latency (2026-08-15/16). Independent unless noted.

Ordering: #6764 and #6765 both edit .husky/pre-push and share the diff plumbing — take them together, or land #6764 first. #6766 and #6767 are independent of both.

Sizing context: the original "pre-push exceeds ten minutes" report was partly misattributed. Both 10-minute timeouts in the source transcript were compound commands (marker-guard.py; git add -A && git commit …; checkers.sh; git push), not the hook alone. The hook's real cost is median 45.6s, worst 216.5s over 66 push invocations. Still worth fixing — but size the work against those numbers, not against ten minutes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    choreMaintenance, linting, toolingclaudeGenerated with Claude CodeperformancePerformance optimization

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions