Dedupe Git Config Reads Per Hook

Claude Code·Opus 5.5·MuskanPaliwal·1w ago·2hr 59min·1 Checkpoint·12 file changes·+356/-28·27.8K tokens

You are picking up PR 5 of 5 on Trail #2635 (https://entire.com/gh/entireio/-/trails/2635, Linear ENT-3643). This worktree is stacked on PR 4's branch muskan/keep-the-prompt-out-of-commits.

Read first:

  • /Users/muskanpaliwal/Work/entireio/cli/final-block-checkpoint-linking/plan.md (local, uncommitted): the five-PR split. PR 5 = "Perf: the git-dir memo (once per hook), the comment-config snapshot (WithCommentConfig), and their rule of caching only successful reads."
  • Source to carve from: fork worktree ~/Work/open-source/entire/cli/fix-checkpoint-trailer-block, branch fix-checkpoint-trailer-block, head 7f0438fce (backup ref backup/fix-checkpoint-trailer-block-full). Carve by component; its history is too interleaved to cherry-pick.
  • PR 3's code: gitCommentConfig / messageCommentRules / linkedCheckpointsReader in cmd/entire/cli/strategy/commit_trailers.go, and perWorktreeGitRoot callers in manual_commit_provenance.go.

State of the stack (all pushed, 0 open findings as of 2026-10-29):

  • PR 1 cli/1706 (#2923, base main) head 9e06aa1bf3
  • PR 2 cli/1714 (#2938) head d7b1b2af9b
  • PR 3 cli/1717 (#2944) head be6a6f1900
  • PR 4 cli/1727 (#2958, ready for review) head 8b9c05a845

Scope notes:

  • PR 3's description promises "a later change caches it per hook". The resolved PR 3 finding (01M61Q6BD987) explains that each hook is its own process, so this PR dedupes repeat git config / git dir reads within one hook only; it cannot share prepare-commit-msg's read with commit-msg. Measure before and after (count git subprocesses per hook with a logging git shim) and report the numbers.
  • Cache only successful reads; a failed read must be retried, not memoized.

How the user works (follow ~/hub/AGENTS.md and the vault Agent Memory):

  • Brief the user (problem, cause, proposed change) and wait for approval before editing.
  • Write tests first and see them fail before the fix.
  • Prefer lint plus targeted tests; the user objected to running the full CI suite unprompted.
  • Commit bodies one or two lines, no AI attribution trailer.
  • Show the exact text of any commit message, PR/trail description or finding note before writing it.

Publishing (do not use work publish; it would create a new trail): push the branch, run entire trail link 2635 --repo gh/entireio/cli --branch <this branch>, then gh pr edit <n> --base muskan/keep-the-prompt-out-of-commits and set the description ending with "Part of ENT-3643". Check entire trail finding list cli/<n> --freshness any after every push. Force-pushes are blocked by the auto-mode classifier: give the user the command to run. If PR 1-4 change again, restack PR 5 with git rebase --onto.

Start by reading the plan and the fork's perf code, then brief the user.

1w ago·4m

The fork's code and the current stack are both read, and I have baseline numbers. Here's the brief; I'll wait for your approval before editing anything.

Brief: PR 5, cache the git dir and comment config within one hook

Problem. One commit runs each hook as its own process, and inside a single hook the same two git reads repeat:

  • git rev-parse --git-dir runs 4–5 times in prepare-commit-msg and 2–3 times in post-commit. Callers include perWorktreeGitRoot, the sequence-operation checks (manual_commit_hooks.go:363, :390, :688) and manual_commit_provenance.go:370.
  • The comment config (git config --get-regexp, plus git --version when core.commentChar=auto) is read twice in prepare-commit-msg in some cases: a squash that also stamps new work (:724 then :536), and an amend whose checkpoint was deleted (:873 then :932). Redo can add a third read through linkedCheckpointsReader and manual_commit_redo.go:131.

Cause. GetGitDir and gitCommentConfig start a fresh subprocess on every call. Nothing ties a read to the hook that made it.

Baseline. I counted git subprocesses per hook with a logging git shim in a scratch repo, using a real entire enable and simulated Claude turns. Script: /private/tmp/claude-501/-Users-muskanpaliwal-Work-entireio-cli-cache-git-dir-and-comment-config-per-hook/13df2691-337c-48de-9870-b5ac31911d50/scratchpad/measure.sh

  • commit -m / editor: prepare 9 subprocesses (5 git-dir, 1 config); commit-msg 3; post-commit 10 (3 git-dir)
  • --amend --no-edit: prepare 7 (4 git-dir); post-commit 8 (3 git-dir)
  • squash with new work: prepare 11 (5 git-dir, 2 config); post-commit 10 (3 git-dir)
  • core.commentChar=auto, editor: prepare 10 (5 git-dir, 1 config, 1 version)
  • no session: prepare 7 (4 git-dir); post-commit 4 (2 git-dir)

Proposed change. Carve this from the fork (hooks.go:63-100 and manual_commit_hooks.go:3065-3100 at 7f0438fce):

  1. withGitDir(ctx), installed at the start of PrepareCommitMsg and PostCommit. GetGitDir returns the cached dir under it and behaves as today without it.
  2. withCommentConfig(ctx), installed in PrepareCommitMsg. gitCommentConfig reads once and reuses the result.
  3. Cache only successful reads. A failed rev-parse is retried. A config read counts as successful on exit 0, or exit 1, which means nothing is set. Any other failure is retried: a cancelled ctx, a broken config, or a failed git --version under auto. Callers still get git's defaults on failure, as they do now. The fork's version is weaker here: it cached anything not read under a done ctx.

Expected result: prepare-commit-msg drops from 9 to about 5 subprocesses, and post-commit from 10 to about 8. The squash-with-new-work case loses a config read as well. I'll rerun the same script after the change and report before and after.

Scope differences from the fork and plan:

  • No commit-msg change. It reads config once and never reads the git dir, so there is nothing to save.
  • Nothing in attach/explain. On this stack, attach (attach.go:689) and explain --generate (explain.go:2824) already read config at most once. PR 3 made the read-only listings config-free, so the fork's WithCommentConfig calls in the explain listings have nothing to cache. The helper therefore stays unexported, withCommentConfig rather than the WithCommentConfig the plan names.
  • Prepare and post-commit still each read once. Each hook is its own process, so they can't share a read (per finding 01M61Q6BD987).

Tests, written first and shown failing. Unit tests in strategy. They use t.Chdir and testutil.IsolateGitConfigEnv, so they can't run in parallel.

  • Git dir: under withGitDir, a second call returns the first answer after the cwd moves to another repo. A failed read outside any repo isn't cached: the same ctx succeeds after a chdir into a repo.
  • Comment config: under withCommentConfig, a changed core.commentChar isn't picked up within the hook. A read under a cancelled ctx isn't cached. A broken config, such as an invalid core.commentChar include or a corrupt config file, isn't cached.
  • Hook wiring: shown by the shim numbers, not a unit test.

Tradeoff. If a hook changed core.commentChar or moved the git dir mid-run, it would now see the old value. Neither happens inside a hook.

Approve, or adjust?

Can you make me understand this in more simpler terms?

1w ago·13.0s

Sure. Here it is without the jargon.

What happens today

When you run git commit, git runs Entire's hooks: one before the message is saved (prepare-commit-msg), one to check it (commit-msg) and one after the commit is made (post-commit). Each hook is a separate run of the entire program.

During one hook, Entire keeps asking git the same two questions:

  1. "Where is your .git folder?" It asks up to 5 times in the first hook and 3 times in the last, and the answer never changes.
  2. "What do your settings say about comment lines?" That's the # lines git strips from a message. Usually it asks once, but in a few cases (squash, some amends) it asks twice.

Each question starts a new git process. That's cheap, but not free. A process takes a few milliseconds, more on Windows or in big repos, and every commit pays for it.

It's like asking a coworker for the office Wi-Fi password five times in one meeting instead of writing it down the first time.

What the change does

Write the answer down the first time, and reuse it until the hook ends.

  • The first time a hook asks "where is .git?", it remembers the answer. The next four times it uses the note instead of asking git again.
  • Same for the comment settings.
  • The note is thrown away when the hook finishes. The next hook asks again. They're separate programs, so they can't share notes.

The one rule: only remember good answers

If git fails to answer (the request was cancelled, the config file is broken), we don't write that down. Otherwise one hiccup would stick for the rest of the hook. The next call simply asks git again.

What you'd see

For a normal commit, the first hook goes from about 9 git calls to about 5, and the last from 10 to about 8. Commit behavior stays the same. I measured the "before" numbers with a fake git that logs every call, and I'll run the same check after the change to confirm the "after" numbers.

What's left out

The original fork also added this caching to entire attach and entire explain. On this stack, those commands already ask only once, so there's nothing to save there.

Want me to go ahead?

and the answer during the hook working never changes>

1w ago·15.2s

Right. Here's why each answer stays the same while a hook runs, and the one case where it could change.

The .git folder location. Git answers based on which folder the program is running in. A hook runs from start to finish in your repo's folder, and Entire's code never switches folders (I checked: there's no os.Chdir outside tests). So every time the hook asks, git gives the same answer. The only way it could change is if someone moved or deleted the repo while the commit was running, and then the commit would break anyway.

The comment settings. These come from your git config. They could only change if you ran something like git config core.commentChar ";" in another terminal during the half-second the hook runs. Even then, caching is the safer behavior. git commit reads its config once, when it starts, and keeps using that copy for the whole commit, including when it strips # lines from your message. If the hook re-read the config halfway through, it could end up disagreeing with git. Keeping one copy for the whole hook makes Entire behave the way git does.

So the answers can't change during normal use, and in the one unlikely edge case, remembering the first answer is the better choice.

Want me to go ahead with the change?

go ahead

1w ago·13m

The change is in place. prepare-commit-msg and post-commit now ask git for the git dir once, and prepare-commit-msg reads the comment config once. Failed reads aren't kept. It's not committed yet; I need your OK on the message below.

Git subprocesses per hook, before → after, measured with the logging shim against real commits in a scratch repo:

Scenarioprepare-commit-msgpost-commit
commit -m / editor9 → 510 → 8
--amend --no-edit7 → 48 → 6
squash (no new work)9 → 64 → 3
squash with new work11 → 6 (config reads 2 → 1)10 → 8
redo after reset14 → 1010 → 8
core.commentChar=auto10 → 610 → 8
no session7 → 44 → 3

commit-msg is unchanged, since it already read once. Every commit in the scratch repo got the same trailers before and after.

Tests:

  • I wrote the new tests first and ran them against no-op stubs. The two read-once tests failed, as they should.
  • One retry test failed at first because the test itself was wrong: in a git config file, ; starts a comment, so commentChar = ; reads as empty. It now uses %.
  • Mutation check: switching to the fork's weaker rule (keep any read unless the request was cancelled) makes the broken-config test fail. The test catches the case the fork missed.
  • mise run lint reports 0 issues. The full strategy package tests and the docs test pass.

Doc change: docs/architecture/sessions-and-checkpoints.md said prepare-commit-msg "can read it several times". It now describes the single read and the retry-on-failure rule.

Not run: mise run check, which the repo asks for before every commit (integration tests plus the canary). You've asked me not to run the full suite unprompted. I can run just mise run test:integration, since it covers the hooks, or the whole check.

Proposed commit message:

Should I commit with this text, and which tests do you want run first?

okay nice

1w ago·18.3s

I committed it as 1d745d4f45, on top of PR 4 (8b9c05a845), with no attribution trailer. I ran lint and the strategy and docs tests on this code. Afterward I reworded one Go comment and edited the doc paragraph; neither change affects behavior. I didn't run mise run check or the integration tests.

Next is publishing:

  1. Run mise run lint again on the committed tree.
  2. Push the branch.
  3. Link it to the trail with entire trail link 2635 --repo gh/entireio/cli --branch muskan/cache-git-dir-and-comment-config-per-hook.
  4. Set the PR's base with gh pr edit <n> --base muskan/keep-the-prompt-out-of-commits.
  5. Check the findings.

Here's the PR description I'd use. Please approve or edit:

Should I go ahead with these steps and this text?