You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Plan 220: Harden the git-index writers against a transient index.lock (#440)
* Start plan 220: Make the pre-merge-commit hook the single git-index writer
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* plan(220): reconcile with confirmed decision to harden, not single-write
The maintainer resolved the open decision (2026-05-31) against the
single-writer redesign. Keep MDS048 staging .gitattributes and only
harden both git-index writers against a transient index.lock.
- status 🔲 → 🔳
- title → "Harden the git-index writers against a transient index.lock"
- rewrite Goal/Cause/Design/Tasks/Acceptance Criteria for two hardened
writers; record the decision and drop the "Confirm before implementing"
ask
- refresh PLAN.md catalog via `mdsmith fix PLAN.md`
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* feat(githooks): retry StageGitattributes on a transient index.lock
MDS048 stages .gitattributes via `git add` during `mdsmith fix`. When
.git/index.lock briefly exists (a concurrent git invocation), that add
failed hard and bounced the merge queue. Wrap the add in a bounded
retry with backoff: a lock that clears within the window now stages
successfully, and a persistent lock fails with a clear "index locked"
message instead of a bare exit status. The retry only waits for the
holder to release the lock — it never deletes a lock it did not create.
A package-level git-add seam plus an overridable backoff schedule let
tests drive the transient-clears and persistent-fails cases with a fake
git, deterministically and instantly.
Task 1 of plan 220.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* feat(githooks): retry the hook's git add on a transient index.lock
The pre-merge-commit hook's staging loop ran a bare `git add -- "$f"`.
When .git/index.lock briefly existed, that add failed (exit 128) and,
under set -e, aborted the hook — bouncing the merge queue. Wrap each
add in mdsmith_git_add, a POSIX retry-with-backoff that:
- retries while git reports `index.lock ... File exists`, so a lock
that clears within the window stages successfully;
- backs off with `sleep 0.1 2>/dev/null || sleep 1` (fast on coreutils
that honor fractional sleep, portable elsewhere);
- on a persistent lock prints `index locked` and exits non-zero so the
merge aborts loudly instead of committing a partial tree;
- propagates a non-lock add failure immediately;
- never deletes .git/index.lock — it only waits for the holder.
The staging phase stays under `set +e` so the helper can inspect each
add's exit status, and the pipeline status is re-raised afterward (the
`while` body runs in a subshell, so the helper's exit would otherwise
end only that subshell).
HookMatchesCanonical now requires the mdsmith_git_add call, so a hook
that drifted back to a bare `git add` loop is flagged (new bad fixture
legacy-plain-git-add.sh). Golden file regenerated.
Task 2 of plan 220.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* test(e2e): no-commit merge + hook + commit captures both writers' output
Plan 220's integration acceptance criterion. Drive the merge-queue
invocation model end-to-end with the real binary: `git merge --no-ff
--no-commit`, run the installed pre-merge-commit hook, then `git
commit`. Assert the merge commit captures both the regenerated
.gitattributes (staged by MDS048's kept in-process `git add`) and the
regenerated PLAN.md catalog (staged by the hook's hardened staging
loop), and that the worktree is clean.
The branches are arranged with no PLAN.md/.gitattributes conflict so
the per-file merge driver does not pre-regenerate .gitattributes in the
worktree; the committed-stale managed block then survives to hook time,
giving MDS048 a genuine correction to stage — the realistic shape of
the queue scenario, and proof that keeping MDS048 as a stager (rather
than dropping it) lands the regenerated .gitattributes in the commit.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* docs(pre-merge-commit): document the index.lock retry behavior
Note that the hook retries a transient .git/index.lock with bounded
backoff, never deletes a lock it did not create, and exits non-zero
with an `index locked` message when the lock persists.
Task 3 of plan 220.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* refactor(githooks): extract shell helper + test setup to satisfy funlen
golangci-lint funlen flagged BuildHookScript (62 lines) and the new e2e
test (44 statements). Extract the hook's mdsmith_git_add shell function
into a package-level stagingHelperShellFunc constant, and lift the e2e
repo/branch setup into setupNoConflictMergeRepo. The generated hook
script is byte-identical (golden unchanged); behavior is unchanged.
Complete plan 220: all acceptance criteria verified (go test ./... and
go tool golangci-lint run both clean), status 🔳 → ✅, catalog refreshed
via `mdsmith fix PLAN.md`.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* test(githooks): cover empty-output stage error; drop unreachable lock branch
codecov/patch (target auto, threshold 0%) flagged two new lines in StageGitattributes. The non-lock error path's empty-message branch was never exercised by the existing tests (their fake git always returns non-empty output), so add TestStageGitattributes_NonLockErrorEmptyOutput driving a fake git that fails with no output.
The index-locked path's empty-message branch is unreachable: isIndexLockError matches only output containing index.lock and File exists, so the trimmed message is always non-empty there. Remove the dead branch per the repo's policy of not adding defensive branches that cannot be driven red/green.
go test ./..., go tool golangci-lint run (0 issues), and mdsmith check . (0 failures) all pass.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* fix(merge-driver): stop MDS048's git add from running inside git merge
Root cause of the merge-queue .git/index.lock bounce. git invokes the mdsmith merge driver from inside `git merge`, which holds .git/index.lock for the whole merge. The driver ran fixer.Fix with rule.All(), which includes MDS048 (git-hook-sync), whose Fix does an in-process `git add -- .gitattributes`. So every *.md conflict the driver resolved spawned a git add racing the parent `git merge` for the index lock; with four generated files auto-merging (copilot-instructions.md, AGENTS.md, CLAUDE.md, PLAN.md) that is four races per merge. This is the second index writer that runs DURING the merge, which the hook-side hardening never addressed.
Confirmed from the failing GHA job log (PR #432 batch): a clean `git merge` of four driver-managed files, then a git add failing with 'index.lock: File exists', the lock persisting ~3s through `git merge --abort` — far longer than the hook's ~310ms retry budget could clear.
Fix: the merge driver runs mergeDriverRules() — rule.All() minus the git-hook-sync rule — so it performs zero git-index mutation; a merge driver must be a pure content transform. The pre-merge-commit hook still runs MDS048 afterward, when git no longer holds the lock.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* test+fix(githooks): conflicting-merge e2e + harden hook git diff status
Verifies the merge-driver root-cause fix and addresses the Copilot review on PR #440.
- Add TestE2E_PreMergeCommit_ConflictingMergeResolvesWithHookSync: a real conflicting merge of PLAN.md's catalog with git-hook-sync enabled (the merge driver runs, then the hook). Proves the conflict resolves, commits cleanly, keeps .gitattributes, and leaves no stale .git/index.lock now that MDS048 no longer stages inside the driver. Setup factored into setupConflictingMergeRepo.
- Hook staging loop: capture git diff's own exit status before the loop so a hard git diff failure is not masked by the pipeline (the pipeline status was the while, which exits 0 on empty input). Update HookMatchesCanonical and regenerate the golden hook.
- StageGitattributes: correct the comment so it does not over-state isIndexLockError (it checks for the lock message; a non-empty msg is a consequence).
go test ./..., go tool golangci-lint run (0 issues), and mdsmith check . (0 failures) all pass.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* docs(plan 220): center the merge-driver root cause
The GHA job log confirmed the real second git-index writer is MDS048's in-process git add, run by the merge driver from inside git merge (which holds .git/index.lock) — not the hook's staging loop, which runs after the merge. Rewrite the summary, Goal, Cause, Design, Tasks, and Acceptance Criteria so the plan matches the implementation: the root-cause fix drops MDS048 from the merge driver's rule set, and the hook-side retry is defense-in-depth.
Addresses the Copilot review comments on plan/220.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* test: strengthen lock-retry assertions and conflict-resolution check
Apply /code-review (xhigh) findings on PR #440 — test/check hardening, no production behavior change:
- PersistentLock test asserts the full retry budget (calls == len(stageRetryBackoff)+1), not merely > 1.
- HookMatchesCanonical now requires stage_status=$?, so a drifted hook that keeps mdsmith_git_add but drops the exit-status re-raise (silently swallowing a persistent lock) is flagged as drift.
- The conflicting-merge e2e asserts resolution directly via git ls-files -u (no unmerged paths), instead of a CONFLICT-string check the --no-commit exit code skipped in the passing case.
- The transient-lock hook test asserts git add was retried exactly 3 times (2 failures + 1 success), so the retry path is verified rather than just eventual success.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* test+fix: pass-2 review — harden canonical drift detection and assertions
Second /code-review (xhigh) pass on PR #440, plus the Copilot review:
- HookMatchesCanonical now also requires the mdsmith_git_add() helper definition and the diff_status/stage_status exit guards, not just the captures — so a drifted hook that keeps the captures but drops the exit (silently swallowing a staging failure) or the helper definition (runtime error) is flagged as drift. (Copilot review + pass-2 finding.)
- withStubGitAdd derives the stub backoff length from production (make of len(origBackoff)), so the persistent-lock assertion validates the real retry budget instead of a magic 5.
- The transient-lock hook test derives its expected git-add count from failCount instead of the literal 3.
- NoCommitMergeCapturesBoth now runs mdsmith check . (symmetry with the conflicting-merge e2e) to catch a structurally broken PLAN.md a clean worktree would otherwise hide.
- Separate the NoCommitMergeCapturesBoth doc comment from setupNoConflictMergeRepo so go/doc attributes each correctly.
Both passes found no production correctness bugs. Not applied (noted for follow-up): generalize the merge-driver MDS048 exclusion to a rule capability interface (touches internal/rule + githooksync, beyond this PR); unify the Go/shell retry budgets behind one constant.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
* refactor(merge-driver): exclude index-mutating rules via a capability interface
Replace the hardcoded "MDS048" exclusion in mergeDriverRules with a rule.GitIndexMutator capability interface. MDS048 (git-hook-sync) implements it; the merge driver filters by the interface, so any future rule whose Fix mutates the git index is excluded from the merge-driver pipeline automatically — preventing a recurrence of the index.lock race. The test now asserts no GitIndexMutator survives in the merge-driver rule set, not just that one rule is dropped.
Addresses the altitude finding from the /code-review passes.
https://claude.ai/code/session_01WfEXMpKbVN9JBzn87H6MXQ
---------
Co-authored-by: Claude <noreply@anthropic.com>
0 commit comments