Skip to content

Commit 13e840d

Browse files
jedudenclaude
andauthored
Add pre-merge-commit hook to resolve file ordering race (#183)
* Install pre-merge-commit hook to regenerate sections post-merge The merge driver's per-file `mdsmith fix` could read stale sibling files because git merges paths one at a time: when the driver runs on PLAN.md, plan/*.md may still hold "ours" content, so the regenerated catalog reflects pre-merge sources. CI run 24971661273 hit this — PLAN.md row 94 stayed 🔲 while plan/94 status was ✅. `mdsmith merge-driver install` now writes a pre-merge-commit hook that re-runs `mdsmith fix` on the registered files once every path has reached its final merged state. The merge-queue workflow builds mdsmith from source so the install logic always matches the directive schema of the branch being queued. * Add e2e test reproducing the file-ordering race Sets up two branches that each bump a different plan file's status to ✅ and regenerate PLAN.md, then merges them after running `mdsmith merge-driver install`. Asserts the merged PLAN.md catalog matches the post-merge plan files and that `mdsmith check .` reports no drift. Verified by temporarily disabling the pre-merge-commit hook install: the test then fails with the exact symptom from CI run 24971661273 — a stale row in PLAN.md and MDS019 "generated section is out of date". * Address review: revert workflow, fix hook robustness, add 100% coverage - Revert merge-queue.yml to downloading the pinned v0.5.0 release binary instead of building from source (per jeduden's review request; the build-from-source approach will land in a follow-up PR alongside a new release that includes this hook fix) - Handle core.hooksPath: resolveHooksDir() uses git rev-parse --git-path hooks so install respects repos that redirect hooks to a custom path - Handle non-ENOENT read errors on the existing hook file: treat them as a safety failure rather than silently overwriting an unreadable hook - Split per-file fix commands into separate lines so set -e reliably aborts the hook on a mdsmith fix or git add failure (the && form can suppress errexit in some POSIX shells) - Add tests covering all new code paths: resolveHooksDir (fallback, default git repo, relative and absolute core.hooksPath), unreadable-hook error, MkdirAll failure, WriteFile failure, and e2e install with unmanaged hook https://claude.ai/code/session_012XG9t645SL8z2RW9ik79gw * Fix test robustness per Copilot review - Add runtime.GOOS=="windows" skip to the three POSIX permission tests to match the pattern used elsewhere in the repo - TestResolveHooksDir_DefaultGitRepo: derive expected path via git rev-parse --git-path hooks instead of hard-coding .git/hooks, so the test passes when a developer has core.hooksPath set globally - TestE2E_MergeDriver_Install: use gitHooksDir() helper instead of hard-coded .git/hooks path for the hook assertion - TestE2E_MergeDriver_Install_UnmanagedHook: use gitHooksDir() for hook setup so the pre-existing hook is placed where install looks https://claude.ai/code/session_012XG9t645SL8z2RW9ik79gw * Address Copilot review: fix --, Windows exec-bit guards, comment placement - Add -- separator to mdsmith fix in generated hook so file paths starting with - are not misinterpreted as flags - Guard info.Mode()&0o111 exec-bit assertions with runtime.GOOS != "windows" in both the unit test and e2e test (POSIX permission bits don't apply on Windows) - Move the writeFixture doc comment back to sit directly above writeFixture; gitHooksDir now has only its own comment above it https://claude.ai/code/session_012XG9t645SL8z2RW9ik79gw --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b2cf56 commit 13e840d

3 files changed

Lines changed: 494 additions & 0 deletions

File tree

cmd/mdsmith/e2e_test.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/exec"
99
"path/filepath"
1010
"regexp"
11+
"runtime"
1112
"strconv"
1213
"strings"
1314
"testing"
@@ -192,6 +193,19 @@ func isolateDir(t *testing.T, dir string) {
192193
}
193194
}
194195

196+
// gitHooksDir returns the effective hooks directory for the git repo at dir,
197+
// derived via git itself so it respects core.hooksPath.
198+
func gitHooksDir(t *testing.T, dir string) string {
199+
t.Helper()
200+
out, err := exec.Command("git", "-C", dir, "rev-parse", "--git-path", "hooks").Output()
201+
require.NoError(t, err, "git rev-parse --git-path hooks")
202+
p := strings.TrimSpace(string(out))
203+
if !filepath.IsAbs(p) {
204+
p = filepath.Join(dir, p)
205+
}
206+
return filepath.Clean(p)
207+
}
208+
195209
// writeFixture creates a file with the given content in the given directory.
196210
func writeFixture(t *testing.T, dir, name, content string) string {
197211
t.Helper()
@@ -1236,6 +1250,21 @@ func TestE2E_MergeDriver_Install(t *testing.T) {
12361250
content := string(attrs)
12371251
assert.Contains(t, content, "PLAN.md merge=mdsmith", "expected PLAN.md entry in .gitattributes")
12381252
assert.Contains(t, content, "README.md merge=mdsmith", "expected README.md entry in .gitattributes")
1253+
1254+
// Verify pre-merge-commit hook was installed and is executable.
1255+
// Use gitHooksDir to respect core.hooksPath if set globally.
1256+
hookPath := filepath.Join(gitHooksDir(t, dir), "pre-merge-commit")
1257+
info, err := os.Stat(hookPath)
1258+
require.NoError(t, err, "expected pre-merge-commit hook at %s", hookPath)
1259+
if runtime.GOOS != "windows" {
1260+
assert.NotZero(t, info.Mode()&0o111, "hook must be executable")
1261+
}
1262+
hookData, err := os.ReadFile(hookPath)
1263+
require.NoError(t, err)
1264+
assert.Contains(t, string(hookData), "fix",
1265+
"hook must invoke mdsmith fix; got:\n%s", hookData)
1266+
assert.Contains(t, string(hookData), "PLAN.md")
1267+
assert.Contains(t, string(hookData), "README.md")
12391268
}
12401269

12411270
func TestE2E_MergeDriver_Install_Idempotent(t *testing.T) {
@@ -1255,6 +1284,30 @@ func TestE2E_MergeDriver_Install_Idempotent(t *testing.T) {
12551284
assert.Equal(t, 1, count, "expected 1 PLAN.md entry, got %d; content:\n%s", count, attrs)
12561285
}
12571286

1287+
func TestE2E_MergeDriver_Install_UnmanagedHook(t *testing.T) {
1288+
dir := t.TempDir()
1289+
require.NoError(t, exec.Command("git", "init", dir).Run(), "git init")
1290+
1291+
// Place a user-authored hook (no mdsmith marker) before running install.
1292+
// Use gitHooksDir so setup targets the same path that install will check.
1293+
hooksDir := gitHooksDir(t, dir)
1294+
require.NoError(t, os.MkdirAll(hooksDir, 0o755))
1295+
hookPath := filepath.Join(hooksDir, "pre-merge-commit")
1296+
userHook := "#!/bin/sh\necho user hook\n"
1297+
require.NoError(t, os.WriteFile(hookPath, []byte(userHook), 0o755))
1298+
1299+
_, stderr, exitCode := runBinaryInDir(t, dir, "", "merge-driver", "install")
1300+
assert.Equal(t, 2, exitCode,
1301+
"expected exit 2 when unmanaged pre-merge-commit hook exists; stderr: %s", stderr)
1302+
assert.Contains(t, stderr, "pre-merge-commit",
1303+
"error must reference the hook path; stderr: %s", stderr)
1304+
1305+
// Verify the user hook was not clobbered.
1306+
data, err := os.ReadFile(hookPath)
1307+
require.NoError(t, err)
1308+
assert.Equal(t, userHook, string(data), "user hook content must be preserved")
1309+
}
1310+
12581311
func TestE2E_MergeDriver_Install_CustomFiles(t *testing.T) {
12591312
dir := t.TempDir()
12601313

@@ -1401,6 +1454,146 @@ func TestE2E_MergeDriver_SectionMarkersInsideConflict_Preserved(t *testing.T) {
14011454
assert.Contains(t, content, ">>>>>>>", "expected >>>>>>> marker preserved")
14021455
}
14031456

1457+
// TestE2E_MergeDriver_FileOrderingRace_Resolved reproduces the
1458+
// CI failure from run 24971661273.
1459+
//
1460+
// Two branches each bump the status of a different plan file from
1461+
// 🔲 to ✅ AND each regenerate PLAN.md against their own working
1462+
// tree. When the branches are merged, both sides have modified
1463+
// PLAN.md vs base, so git invokes the merge driver. The driver's
1464+
// own `mdsmith fix` reads sibling plan/*.md files from the
1465+
// working tree at that moment — but git has not yet processed
1466+
// every plan path, so the regenerated catalog is stale relative
1467+
// to the final merged state.
1468+
//
1469+
// With the pre-merge-commit hook installed by `merge-driver
1470+
// install`, mdsmith fix runs again after every per-file merge has
1471+
// settled, so PLAN.md ends up consistent with plan/*.md.
1472+
const planTmpl = `---
1473+
id: %d
1474+
title: Plan %d
1475+
status: "%s"
1476+
---
1477+
# Plan %d
1478+
1479+
Body.
1480+
`
1481+
1482+
const planMdTmpl = "# Plans\n\n" +
1483+
"<?catalog\n" +
1484+
"glob:\n - \"plan/*.md\"\n" +
1485+
"sort: id\n" +
1486+
"header: |\n\n | ID | Status | Title |\n |----|--------|-------|\n" +
1487+
"row: \"| {id} | {status} | [{title}]({filename}) |\"\n" +
1488+
"footer: |\n\n" +
1489+
"?>\n" +
1490+
"<?/catalog?>\n"
1491+
1492+
// completePlanOnBranch creates branch from start, sets plan/<id>.md
1493+
// status to ✅, regenerates PLAN.md, and commits.
1494+
func completePlanOnBranch(t *testing.T, dir, branch, start string, planID int) {
1495+
t.Helper()
1496+
gitInDir(t, dir, "checkout", "-b", branch, start)
1497+
writeFixture(t, dir, fmt.Sprintf("plan/%02d.md", planID),
1498+
fmt.Sprintf(planTmpl, planID, planID, "✅", planID))
1499+
_, stderr, code := runBinaryInDir(t, dir, "", "fix", "PLAN.md")
1500+
require.Equal(t, 0, code, "%s fix failed: %s", branch, stderr)
1501+
gitCommit(t, dir, fmt.Sprintf("complete plan %d", planID))
1502+
}
1503+
1504+
func TestE2E_MergeDriver_FileOrderingRace_Resolved(t *testing.T) {
1505+
dir := t.TempDir()
1506+
gitInit(t, dir)
1507+
1508+
// Catalog over plan/*.md, sorted by id; rule names match the
1509+
// directives the merge driver knows how to regenerate.
1510+
writeFixture(t, dir, ".mdsmith.yml",
1511+
"rules:\n catalog: true\n include: true\n")
1512+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "plan"), 0o755))
1513+
writeFixture(t, dir, "plan/01.md", fmt.Sprintf(planTmpl, 1, 1, "🔲", 1))
1514+
writeFixture(t, dir, "plan/02.md", fmt.Sprintf(planTmpl, 2, 2, "🔲", 2))
1515+
writeFixture(t, dir, "PLAN.md", planMdTmpl)
1516+
1517+
// Populate the catalog body once so the base commit is clean.
1518+
_, stderr, code := runBinaryInDir(t, dir, "", "fix", "PLAN.md")
1519+
require.Equal(t, 0, code, "seed fix failed: %s", stderr)
1520+
gitCommit(t, dir, "seed")
1521+
seedSHA := strings.TrimSpace(gitInDir(t, dir, "rev-parse", "HEAD"))
1522+
1523+
completePlanOnBranch(t, dir, "ours", seedSHA, 1)
1524+
completePlanOnBranch(t, dir, "theirs", seedSHA, 2)
1525+
1526+
// Install the merge driver — registers git config + the
1527+
// pre-merge-commit hook that closes the race.
1528+
gitInDir(t, dir, "checkout", "ours")
1529+
_, stderr, code = runBinaryInDir(t, dir, "", "merge-driver", "install")
1530+
require.Equal(t, 0, code, "install failed: %s", stderr)
1531+
1532+
// Merge theirs into ours. Both sides modified PLAN.md, so the
1533+
// per-file driver runs; the hook then re-fixes once every plan
1534+
// file is in its final merged state.
1535+
out, err := exec.Command("git", "-C", dir,
1536+
"-c", "commit.gpgsign=false",
1537+
"merge", "--no-ff", "-m", "merge theirs", "theirs").CombinedOutput()
1538+
require.NoError(t, err, "git merge failed: %s", out)
1539+
1540+
// PLAN.md catalog must reflect the post-merge plan files.
1541+
plan, err := os.ReadFile(filepath.Join(dir, "PLAN.md"))
1542+
require.NoError(t, err)
1543+
planStr := string(plan)
1544+
assert.Regexp(t, `\| 1 +\| ✅ +\|`, planStr,
1545+
"row 1 must show ✅ in merged PLAN.md, got:\n%s", planStr)
1546+
assert.Regexp(t, `\| 2 +\| ✅ +\|`, planStr,
1547+
"row 2 must show ✅ in merged PLAN.md, got:\n%s", planStr)
1548+
1549+
// Source plan files must agree with the catalog rows.
1550+
for _, path := range []string{"plan/01.md", "plan/02.md"} {
1551+
data, err := os.ReadFile(filepath.Join(dir, path))
1552+
require.NoError(t, err)
1553+
assert.Contains(t, string(data), `status: "✅"`,
1554+
"%s status must be ✅ after merge", path)
1555+
}
1556+
1557+
// Whole-tree consistency: a fresh check must report no issues
1558+
// — exactly what failed in CI run 24971661273.
1559+
_, stderr, code = runBinaryInDir(t, dir, "", "check", ".")
1560+
assert.Equal(t, 0, code,
1561+
"check after merge must pass; stderr:\n%s", stderr)
1562+
}
1563+
1564+
// gitInit initializes a git repo with isolated user/sign config so
1565+
// commits succeed on machines that have global signing turned on.
1566+
func gitInit(t *testing.T, dir string) {
1567+
t.Helper()
1568+
cmds := [][]string{
1569+
{"init", "-q", "-b", "main", dir},
1570+
{"-C", dir, "config", "user.name", "test"},
1571+
{"-C", dir, "config", "user.email", "test@example.com"},
1572+
{"-C", dir, "config", "commit.gpgsign", "false"},
1573+
{"-C", dir, "config", "tag.gpgsign", "false"},
1574+
}
1575+
for _, c := range cmds {
1576+
out, err := exec.Command("git", c...).CombinedOutput()
1577+
require.NoError(t, err, "git %v: %s", c, out)
1578+
}
1579+
}
1580+
1581+
// gitInDir runs git in dir and returns stdout. Test fails on non-zero.
1582+
func gitInDir(t *testing.T, dir string, args ...string) string {
1583+
t.Helper()
1584+
full := append([]string{"-C", dir}, args...)
1585+
out, err := exec.Command("git", full...).CombinedOutput()
1586+
require.NoError(t, err, "git %v: %s", args, out)
1587+
return string(out)
1588+
}
1589+
1590+
// gitCommit stages everything and commits with the given message.
1591+
func gitCommit(t *testing.T, dir, msg string) {
1592+
t.Helper()
1593+
gitInDir(t, dir, "add", "-A")
1594+
gitInDir(t, dir, "commit", "-q", "-m", msg)
1595+
}
1596+
14041597
// ── max-input-size ──────────────────────────────────────────────
14051598

14061599
func TestCheck_MaxInputSize_ExceedingLimit(t *testing.T) {

cmd/mdsmith/mergedriver.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,12 +391,109 @@ func runMergeDriverInstall(args []string) int {
391391
return 2
392392
}
393393

394+
if err := ensurePreMergeCommitHook(repoRoot, files); err != nil {
395+
fmt.Fprintf(os.Stderr,
396+
"mdsmith: installing pre-merge-commit hook: %v\n", err)
397+
return 2
398+
}
399+
400+
hookPath := filepath.Join(resolveHooksDir(repoRoot), "pre-merge-commit")
394401
fmt.Fprintf(os.Stderr, "mdsmith: merge driver 'mdsmith' installed\n")
395402
fmt.Fprintf(os.Stderr, " git config: merge.mdsmith.driver\n")
396403
fmt.Fprintf(os.Stderr, " .gitattributes: %s\n", attrPath)
404+
fmt.Fprintf(os.Stderr, " pre-merge-commit hook: %s\n", hookPath)
397405
return 0
398406
}
399407

408+
// preMergeCommitHookMarker identifies the hook as managed by
409+
// mdsmith so re-running install can safely replace it without
410+
// stomping on a user-authored hook of the same name.
411+
const preMergeCommitHookMarker = "# mdsmith merge-driver pre-merge-commit hook"
412+
413+
// resolveHooksDir returns the directory where git hooks should be
414+
// installed. It respects core.hooksPath if configured so that
415+
// installations work correctly in repos that redirect hooks to a
416+
// custom path (e.g. via git config or a repo management tool).
417+
// Falls back to .git/hooks when git cannot be queried.
418+
func resolveHooksDir(repoRoot string) string {
419+
cmd := exec.Command("git", "-C", repoRoot, "rev-parse", "--git-path", "hooks")
420+
if out, err := cmd.Output(); err == nil {
421+
p := strings.TrimSpace(string(out))
422+
if !filepath.IsAbs(p) {
423+
p = filepath.Join(repoRoot, p)
424+
}
425+
return filepath.Clean(p)
426+
}
427+
return filepath.Join(repoRoot, ".git", "hooks")
428+
}
429+
430+
// ensurePreMergeCommitHook writes the pre-merge-commit hook so
431+
// that after git resolves all per-file merges (including any
432+
// driver-resolved sections) and before the merge commit is
433+
// created, mdsmith fix runs once on the registered files.
434+
//
435+
// The per-file merge driver cannot do this on its own: when it
436+
// runs on PLAN.md, sibling plan/*.md source files may still hold
437+
// "ours" content because git has not merged them yet, so the
438+
// regenerated catalog reflects a stale view of its sources. The
439+
// pre-merge-commit hook re-fixes the same files once every path
440+
// has reached its final merged state.
441+
func ensurePreMergeCommitHook(repoRoot string, files []string) error {
442+
exe, err := resolveInstalledBinary()
443+
if err != nil {
444+
return fmt.Errorf("cannot locate mdsmith binary: %w", err)
445+
}
446+
447+
hooksDir := resolveHooksDir(repoRoot)
448+
hookPath := filepath.Join(hooksDir, "pre-merge-commit")
449+
450+
// Refuse to clobber a hook the user wrote themselves; replace
451+
// only hooks that carry our marker. A non-ENOENT read error is
452+
// treated as a safety failure to avoid silently overwriting an
453+
// unreadable hook.
454+
existing, readErr := os.ReadFile(hookPath)
455+
switch {
456+
case readErr == nil:
457+
if !strings.Contains(string(existing), preMergeCommitHookMarker) {
458+
return fmt.Errorf(
459+
"%s already exists and is not managed by mdsmith; "+
460+
"remove or merge it manually",
461+
hookPath)
462+
}
463+
case os.IsNotExist(readErr):
464+
// Hook doesn't exist; safe to create.
465+
default:
466+
return fmt.Errorf("reading existing hook %s: %w", hookPath, readErr)
467+
}
468+
469+
// Build per-file fix commands as separate lines so that "set -e"
470+
// aborts the hook if mdsmith fix or git add fails. Files that no
471+
// longer exist (e.g. renamed in this branch) are skipped.
472+
var fixCmds strings.Builder
473+
for _, f := range files {
474+
fmt.Fprintf(&fixCmds,
475+
"if [ -e %s ]; then\n %s fix -- %s\n git add -- %s\nfi\n",
476+
shellQuote(f), shellQuote(exe), shellQuote(f), shellQuote(f))
477+
}
478+
479+
content := "#!/bin/sh\n" +
480+
preMergeCommitHookMarker + "\n" +
481+
"# Re-runs mdsmith fix once git has resolved every per-file\n" +
482+
"# merge, so generated sections reflect the final merged\n" +
483+
"# state of every source file. Re-install with:\n" +
484+
"# mdsmith merge-driver install\n" +
485+
"set -e\n" +
486+
fixCmds.String()
487+
488+
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
489+
return fmt.Errorf("creating %s: %w", hooksDir, err)
490+
}
491+
if err := os.WriteFile(hookPath, []byte(content), 0o755); err != nil {
492+
return fmt.Errorf("writing %s: %w", hookPath, err)
493+
}
494+
return nil
495+
}
496+
400497
// registerMergeDriver writes the merge.mdsmith.* keys to local
401498
// git config. It uses the absolute path of the current executable
402499
// so the driver works regardless of whether the install directory

0 commit comments

Comments
 (0)