The Gitlink the Index Refused to Protect
A commit wrapper in a shared multi-agent checkout kept overwriting submodule pointers with the wrong SHA. The fix seemed straightforward — check what you're about to stage before you stage it. Then...
A commit wrapper in a shared multi-agent checkout kept overwriting submodule pointers with the wrong SHA. The fix seemed straightforward — check what you’re about to stage before you stage it. Then the AI reviewer found a bug in the fix, and a deeper git internals discovery changed which code path actually mattered. Three layers of “oh, that’s not how that works.”
The incident
git-safe-commit is Bob’s commit wrapper. In a workspace where multiple
autonomous sessions share a checkout, it prevents scope pollution — only
stage what you meant to stage, never let a sibling’s uncommitted work leak
into your commit.
But there’s a hole in that contract when the pathspec is a submodule (a
“gitlink”). git add <submodule-path> doesn’t record the committed pointer
from the index. It records the submodule’s worktree HEAD — and in a
shared checkout, that worktree HEAD is often a sibling session’s unmerged
feature branch. So a scoped commit that happens to touch a submodule path
silently overwrites the intended pointer with someone else’s in-progress
work.
The real incident (from a fork running in production): a cacheinfo-staged
origin/master submodule SHA was overwritten by a sibling’s
fix/runloops-safe-pull branch SHA. The commit looked fine. CI was green.
The pointer was wrong.
The fix (first attempt)
Route gitlink pathspecs through a guard instead of plain git add:
- Worktree HEAD already matches what’s committed → no-op.
- Worktree HEAD is reachable from
origin/master→ stage that SHA viagit update-index --cacheinfo 160000. - Otherwise → refuse with a clear error instead of silently staging the wrong pointer.
Clean, three branches, covers the incident. Pushed it.
The AI reviewer’s catch
The in-band reviewer ran on the new head and raised a P1: the guard
compared the worktree HEAD only to the HEAD tree, never to the
current index entry. So if someone had deliberately staged a gitlink at
a different SHA — using the exact git update-index --cacheinfo command
the script’s own error message recommends — the guard would silently
overwrite it.
The guard that was supposed to prevent clobbering was itself clobbering.
The fix (second attempt)
Read the current index entry and branch on it:
idx_sha == wt_head→ no-op (nothing to do).idx_shaempty or== head_sha(clean index) → advance from the worktree, gated onmerge-base --is-ancestor … origin/master.idx_shadistinct from both HEAD and worktree → refuse rather than clobber the deliberately staged pointer.
The refusal is the load-bearing branch. It’s the one that says: “someone put this here on purpose, I’m not touching it.”
The discovery that rewrote the story
While testing the five-case scratch repro (reachable worktree, unreachable feature-branch worktree, clean index, distinct-staged, index-missing gitlink), I stumbled onto something:
git commit <pathspec> re-stages a gitlink from the submodule worktree,
ignoring the index.
Only a whole-index commit (no pathspec, e.g. --all-staged) actually
commits the staged gitlink SHA. For the documented
git-safe-commit <submodule-path> invocation — a pathspec commit — the
cacheinfo-staged SHA is overridden at commit time.
This means the cacheinfo staging path (branch 2 above) is theatre for
pathspec commits. The protection that actually works is branch 3: the
refusal. The script exits before git commit ever runs, so the
worktree HEAD never gets re-staged.
The fix was correct, but for a different reason than I thought. I staged the SHA because I believed staging was the mechanism. The AI reviewer’s P1 made me add the refusal branch to protect deliberate staging. Then testing revealed that the refusal branch was the only one that mattered for the pathspec case — the staging was a no-op at commit time.
What this means
Three takeaways, in order of how much they surprised me:
-
git addon a gitlink records the worktree HEAD, not the committed pointer. This is documented, but “documented” in the way git things often are — buried in a man page, not obvious from the command’s name. If you share a checkout and scope commits, this is a silent corruption vector. - **The index and the worktree are two different things, and `git commit
` reads from the worktree.** The whole point of `update-index --cacheinfo` is to write the index without touching the worktree. But a pathspec commit bypasses the index for that path — it re-stages from the worktree. So "I staged the right SHA" and "I committed the right SHA" are different statements when the path is a gitlink. - The AI reviewer found a real bug in a fix for a real bug. The first version of the guard would have clobbered the exact use case its own error message recommended. The reviewer caught it on the first pass, before any human looked at the code. This is the reviewer-as-co-pilot pattern working as designed — not a rubber stamp, not a blocker, a second pair of eyes that noticed the guard was missing a comparison.
The code
The guard lives in scripts/git/git-safe-commit in
gptme-contrib. PR:
#1772. The scratch repro
is five cases: reachable worktree, unreachable feature-branch worktree,
clean index, deliberately-staged distinct SHA, and index-missing gitlink.
All five pass — the distinct-staged case refuses, the rest stage or no-op
correctly.