Skip to content

Commit 9312543

Browse files
jedudenclaude
andauthored
Fix pre-merge-commit hook to stage fixes when diagnostics remain (#232)
* test: reproduce hook bug where fixes aren't staged when fix exits 1 The merge queue's pre-merge-commit hook silently drops files modified by 'mdsmith fix .' whenever fix returns exit code 1 (unfixed diagnostics remain). This was observed on bisect branch merge-queue/batch-bisect-224-1777817057 (SHA b1ade01) where mdsmith fix regenerated PLAN.md to reflect plan/120's new status but the change never reached the merge commit. Root cause: the canonical hook script uses if ! '$exe' fix .; then status=$? if [ "$status" -ne 1 ]; then exit "$status" fi fi POSIX '! cmd' returns the logical NOT of cmd's exit status, so when mdsmith fix exits 1, $? immediately after is 0. The script then captures status=0, the [ 0 -ne 1 ] test is true, and the hook exits 0 BEFORE running the 'git add' staging loop. The new test stands up a tiny git repo with a fake mdsmith that modifies a tracked file and exits 1, runs the canonical hook, and asserts the modified file ends up in the index. The test fails today, demonstrating the exit-code clobbering bug. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU * fix: capture mdsmith fix exit status before negation in hook POSIX 'if ! cmd; then status=$?; ...' clobbers cmd's exit code: $? after '! cmd' is the logical NOT of cmd's exit status, so when mdsmith fix exits 1 (unfixed diagnostics remain), status captures 0. The follow-up '[ "$status" -ne 1 ]' is then true and the hook exits 0 before the 'git add' staging loop ever runs. The visible symptom on the merge queue: PLAN.md (and other catalog files) get regenerated by mdsmith fix in the working tree, but the merge commit ships the stale pre-fix content because nothing staged the fixes. CI on the resulting commit then fails on MDS019, even though the same fix run locally succeeds. The new template wraps the fix invocation in 'set +e' / 'set -e' and captures $? directly, then propagates any exit status that is neither 0 (clean) nor 1 (unfixed remain) so genuine config errors or panics still abort the merge: set +e '$exe' fix . status=$? set -e if [ "$status" -ne 0 ] && [ "$status" -ne 1 ]; then exit "$status" fi HookMatchesCanonical and the cmd/mdsmith install tests are updated to match the new fragments. The previously-failing reproduction test in internal/githooks/githooks_unix_test.go now passes. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU * Replace script-content assertions with golden file + behavior tests Content checks on BuildHookScript's output (Contains calls looking for specific shell fragments) were brittle: any whitespace or wording change broke them. Replace with: - A golden-file test in internal/githooks that byte-compares BuildHookScript("/usr/local/bin/mdsmith") against testdata/pre-merge-commit.golden.sh; run with UPDATE_GOLDEN=1 to regenerate the golden file when the template changes. - Equality checks in the install/ensure tests that compare the written hook content against githooks.BuildHookScript (the canonical output), so they verify behavior (correct file written) without embedding shell fragments inline. - The e2e install test drops content checks entirely; exact rendering is covered by the golden-file test; the e2e test confirms the file exists and is executable. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU * Add e2e behavioral tests covering all pre-merge-commit hook branches The hook has four observable branches: 1. fix exits 0, nothing modified → staging loop runs, nothing staged 2. fix exits 0, files modified → staging loop stages them 3. fix exits 1 (unfixed diagnostics remain) → staging loop still runs and stages the fixable changes (regression for bisect-branch bug) 4. fix exits anything else → hook propagates the code, aborting merge Each branch now has a dedicated e2e test in TestE2E_PreMergeCommitHook_*. Tests 1–3 install the hook via `mdsmith pre-merge-commit install` (real binary) and exercise it in a real git repo with real markdown files. Test 4 installs a fake mdsmith that exits 2 to verify the propagation path. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU * Require set +e in HookMatchesCanonical drift check Without this, a hook that invokes `mdsmith fix .` under active set -e (without the set +e guard) would pass the drift check even though it would abort immediately when fix exits 1 — silently skipping the staging loop that re-stages the fixed files. Add "set +e" to the required fragments so any installed hook missing the errexit-disable guard is flagged as drifted and prompts reinstall. Add TestHookMatchesCanonical_RejectsMissingSetPlusE to pin the contract. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU * Replace fragment-based HookMatchesCanonical tests with golden files and behavioral test Each bad-hook scenario now lives as a complete shell script under testdata/hooks/bad/. A single table-driven test reads every file in that directory and asserts HookMatchesCanonical returns false, replacing nine inline string-concatenation tests. A new behavioral test (TestHookScript_MissingSetPlusE_FailsToStageOnExitOne) runs the missing-set-plus-e golden file in a real git repo and asserts the file is NOT staged when fake mdsmith exits 1 — documenting the original merge-queue bug and proving the drift detection is meaningful. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU * Assert unfixable diagnostics remain after ExitOneStagesFixed hook run The test claimed mdsmith fix exits 1 due to an unfixable diagnostic but never verified that the diagnostic actually remains after the hook. Add a mdsmith check assertion after the hook to confirm a non-zero exit, ensuring the test is not vacuous if the fixture's chosen rule ever becomes fixable. https://claude.ai/code/session_01C4i4wEMhsfuoTDrq54QvaU --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81cc3f5 commit 9312543

16 files changed

Lines changed: 482 additions & 140 deletions

cmd/mdsmith/e2e_test.go

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414
"testing"
1515

16+
"github.com/jeduden/mdsmith/internal/githooks"
1617
"github.com/jeduden/mdsmith/internal/testutil"
1718
"github.com/stretchr/testify/assert"
1819
"github.com/stretchr/testify/require"
@@ -1260,12 +1261,11 @@ func TestE2E_MergeDriver_Install(t *testing.T) {
12601261
if runtime.GOOS != "windows" {
12611262
assert.NotZero(t, info.Mode()&0o111, "hook must be executable")
12621263
}
1264+
// The exact hook content is verified by the golden-file test in
1265+
// internal/githooks; here we confirm that install wrote a non-empty file.
12631266
hookData, err := os.ReadFile(hookPath)
12641267
require.NoError(t, err)
1265-
assert.Contains(t, string(hookData), "fix .; then",
1266-
"hook must invoke mdsmith fix . via the exit-1-tolerant guard; got:\n%s", hookData)
1267-
assert.Contains(t, string(hookData), "git diff --name-only -- '*.md' '*.markdown'",
1268-
"hook must stage modified markdown files via the glob-based diff")
1268+
assert.NotEmpty(t, hookData, "installed hook must not be empty")
12691269
}
12701270

12711271
func TestE2E_MergeDriver_Install_Idempotent(t *testing.T) {
@@ -1771,3 +1771,158 @@ func TestQuery_MaxInputSize_InvalidValue(t *testing.T) {
17711771
assert.Equal(t, 2, exitCode, "expected exit code 2 for invalid size")
17721772
assert.Contains(t, stderr, "invalid max-input-size")
17731773
}
1774+
1775+
// ── pre-merge-commit hook behavior ──────────────────────────────────────────
1776+
//
1777+
// Each test runs the installed hook script directly (as git would after
1778+
// `git merge --no-commit`) and verifies one branch of the hook logic.
1779+
1780+
// runHookScript executes .git/hooks/pre-merge-commit in dir and returns
1781+
// its exit code.
1782+
func runHookScript(t *testing.T, dir string) int {
1783+
t.Helper()
1784+
hookPath := filepath.Join(gitHooksDir(t, dir), "pre-merge-commit")
1785+
cmd := exec.Command(hookPath)
1786+
cmd.Dir = dir
1787+
if err := cmd.Run(); err != nil {
1788+
if exitErr, ok := err.(*exec.ExitError); ok {
1789+
return exitErr.ExitCode()
1790+
}
1791+
t.Fatalf("unexpected error running hook: %v", err)
1792+
}
1793+
return 0
1794+
}
1795+
1796+
// gitDiffNames returns the set of files shown by `git diff --name-only`
1797+
// (working tree vs index) in dir — the same query the hook uses to decide
1798+
// what to stage.
1799+
func gitDiffNames(t *testing.T, dir string) string {
1800+
t.Helper()
1801+
out, err := exec.Command("git", "-C", dir, "diff", "--name-only").Output()
1802+
require.NoError(t, err)
1803+
return strings.TrimSpace(string(out))
1804+
}
1805+
1806+
// TestE2E_PreMergeCommitHook_ExitZeroNothingStaged covers the branch
1807+
// where mdsmith fix exits 0 and the working tree is already clean: the
1808+
// staging loop runs but `git diff` finds nothing, so nothing is staged
1809+
// and the hook exits 0.
1810+
func TestE2E_PreMergeCommitHook_ExitZeroNothingStaged(t *testing.T) {
1811+
if runtime.GOOS == "windows" {
1812+
t.Skip("pre-merge-commit is a POSIX shell script")
1813+
}
1814+
dir := t.TempDir()
1815+
gitInit(t, dir)
1816+
writeFixture(t, dir, ".mdsmith.yml", "rules: {}\n")
1817+
writeFixture(t, dir, "README.md", "# Title\n\nHello\n")
1818+
gitCommit(t, dir, "init")
1819+
1820+
_, _, code := runBinaryInDir(t, dir, "", "pre-merge-commit", "install")
1821+
require.Equal(t, 0, code, "pre-merge-commit install must succeed")
1822+
1823+
assert.Equal(t, 0, runHookScript(t, dir),
1824+
"hook must exit 0 when there is nothing to fix")
1825+
assert.Empty(t, gitDiffNames(t, dir),
1826+
"nothing should be staged when the working tree is already clean")
1827+
}
1828+
1829+
// TestE2E_PreMergeCommitHook_ExitZeroStagesFixed covers the branch
1830+
// where mdsmith fix modifies files and exits 0 (all issues resolved):
1831+
// the staging loop must detect the changed files and stage them.
1832+
func TestE2E_PreMergeCommitHook_ExitZeroStagesFixed(t *testing.T) {
1833+
if runtime.GOOS == "windows" {
1834+
t.Skip("pre-merge-commit is a POSIX shell script")
1835+
}
1836+
dir := t.TempDir()
1837+
gitInit(t, dir)
1838+
writeFixture(t, dir, ".mdsmith.yml", "rules: {}\n")
1839+
writeFixture(t, dir, "README.md", "# Title\n\nHello\n")
1840+
gitCommit(t, dir, "init")
1841+
1842+
// Simulate post-merge index state: the merged file has trailing spaces.
1843+
writeFixture(t, dir, "README.md", "# Title\n\nHello \n")
1844+
gitInDir(t, dir, "add", "README.md")
1845+
1846+
_, _, code := runBinaryInDir(t, dir, "", "pre-merge-commit", "install")
1847+
require.Equal(t, 0, code)
1848+
1849+
assert.Equal(t, 0, runHookScript(t, dir),
1850+
"hook must exit 0 when all issues are fixed")
1851+
assert.Empty(t, gitDiffNames(t, dir),
1852+
"hook must stage the fix so working tree and index match")
1853+
1854+
data, err := os.ReadFile(filepath.Join(dir, "README.md"))
1855+
require.NoError(t, err)
1856+
assert.Equal(t, "# Title\n\nHello\n", string(data),
1857+
"mdsmith fix must have removed the trailing spaces")
1858+
}
1859+
1860+
// TestE2E_PreMergeCommitHook_ExitOneStagesFixed is the regression test
1861+
// for the merge-queue bisect failure: when mdsmith exits 1 (unfixable
1862+
// diagnostics remain), the staging loop must still run and stage the
1863+
// files that fix did manage to clean up.
1864+
func TestE2E_PreMergeCommitHook_ExitOneStagesFixed(t *testing.T) {
1865+
if runtime.GOOS == "windows" {
1866+
t.Skip("pre-merge-commit is a POSIX shell script")
1867+
}
1868+
dir := t.TempDir()
1869+
gitInit(t, dir)
1870+
writeFixture(t, dir, ".mdsmith.yml", "rules: {}\n")
1871+
writeFixture(t, dir, "README.md", "# Title\n\nHello\n")
1872+
gitCommit(t, dir, "init")
1873+
1874+
// File with a fixable issue (trailing space) AND an unfixable one
1875+
// (heading contains `!`, caught by a default rule), so fix exits 1.
1876+
writeFixture(t, dir, "README.md", "# Title!\n\nHello \n")
1877+
gitInDir(t, dir, "add", "README.md")
1878+
1879+
_, _, code := runBinaryInDir(t, dir, "", "pre-merge-commit", "install")
1880+
require.Equal(t, 0, code)
1881+
1882+
assert.Equal(t, 0, runHookScript(t, dir),
1883+
"hook must exit 0 even when mdsmith fix exits 1 (unfixed diagnostics remain)")
1884+
assert.Empty(t, gitDiffNames(t, dir),
1885+
"hook must stage the fixable changes even when mdsmith fix exits 1")
1886+
1887+
data, err := os.ReadFile(filepath.Join(dir, "README.md"))
1888+
require.NoError(t, err)
1889+
assert.NotContains(t, string(data), "Hello ",
1890+
"trailing spaces must have been removed and staged")
1891+
1892+
// Confirm that unfixable diagnostics genuinely remain after the hook ran,
1893+
// which is what drives `mdsmith fix` to exit 1. If check exits 0 here the
1894+
// fixture no longer exercises the exit-1 branch and the test is vacuous.
1895+
_, _, checkCode := runBinaryInDir(t, dir, "", "check", ".")
1896+
assert.NotEqual(t, 0, checkCode,
1897+
"mdsmith check must still report diagnostics after the hook: "+
1898+
"the unfixable issue (heading punctuation) must remain so the "+
1899+
"test actually exercises the fix-exits-1 branch")
1900+
}
1901+
1902+
// TestE2E_PreMergeCommitHook_PropagatesHardError covers the branch
1903+
// where mdsmith exits with a code other than 0 or 1 (fatal error). The
1904+
// hook must propagate that code so the merge commit aborts.
1905+
func TestE2E_PreMergeCommitHook_PropagatesHardError(t *testing.T) {
1906+
if runtime.GOOS == "windows" {
1907+
t.Skip("pre-merge-commit is a POSIX shell script")
1908+
}
1909+
dir := t.TempDir()
1910+
gitInit(t, dir)
1911+
1912+
// Fake mdsmith that exits 2, simulating a fatal error (config parse
1913+
// failure, crash, etc.).
1914+
fakeMdsmith := filepath.Join(dir, "fake-mdsmith")
1915+
require.NoError(t, os.WriteFile(fakeMdsmith,
1916+
[]byte("#!/bin/sh\nexit 2\n"), 0o755))
1917+
1918+
hooksDir := gitHooksDir(t, dir)
1919+
require.NoError(t, os.MkdirAll(hooksDir, 0o755))
1920+
require.NoError(t, os.WriteFile(
1921+
filepath.Join(hooksDir, "pre-merge-commit"),
1922+
[]byte(githooks.BuildHookScript(fakeMdsmith)),
1923+
0o755,
1924+
))
1925+
1926+
assert.Equal(t, 2, runHookScript(t, dir),
1927+
"hook must propagate fatal exit codes (anything other than 0 or 1) to abort the merge")
1928+
}

cmd/mdsmith/mergedriver_test.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"testing"
1111

12+
"github.com/jeduden/mdsmith/internal/githooks"
1213
"github.com/stretchr/testify/assert"
1314
"github.com/stretchr/testify/require"
1415
)
@@ -478,14 +479,8 @@ func TestEnsurePreMergeCommitHook_CreatesExecutableHook(t *testing.T) {
478479

479480
data, err := os.ReadFile(hookPath)
480481
require.NoError(t, err)
481-
content := string(data)
482-
assert.Contains(t, content, preMergeCommitHookMarker)
483-
assert.Contains(t, content, "if ! '/usr/local/bin/mdsmith' fix .; then",
484-
"hook must invoke the resolved mdsmith binary inside the exit-1-tolerant guard")
485-
assert.Contains(t, content, `if [ "$status" -ne 1 ]; then`,
486-
"hook must propagate exit codes other than 1 (unfixed diagnostics)")
487-
assert.Contains(t, content, "git diff --name-only -- '*.md' '*.markdown'",
488-
"hook must stage modified markdown files via the glob-based diff")
482+
assert.Equal(t, githooks.BuildHookScript("/usr/local/bin/mdsmith"), string(data),
483+
"installed hook must match the canonical template")
489484
}
490485

491486
func TestEnsurePreMergeCommitHook_OverwritesManagedHook(t *testing.T) {
@@ -507,8 +502,8 @@ func TestEnsurePreMergeCommitHook_OverwritesManagedHook(t *testing.T) {
507502
require.NoError(t, err)
508503
assert.NotContains(t, string(data), "stale content",
509504
"managed hook must be replaced, not preserved")
510-
assert.Contains(t, string(data), "fix .; then",
511-
"replaced hook must use the glob-based template")
505+
assert.Equal(t, githooks.BuildHookScript("/usr/local/bin/mdsmith"), string(data),
506+
"replaced hook must match the canonical template")
512507
}
513508

514509
func TestEnsurePreMergeCommitHook_SetsExecutableBitOnExistingHook(t *testing.T) {

cmd/mdsmith/premergecommit_test.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ func TestPreMergeCommitInstall_NoArgsInstallsCanonicalHook(t *testing.T) {
253253
assert.Contains(t, got, "pre-merge-commit hook installed")
254254
hookData, err := os.ReadFile(filepath.Join(resolveHooksDir(dir), "pre-merge-commit"))
255255
require.NoError(t, err)
256-
assert.Contains(t, string(hookData), "fix .; then",
257-
"installed hook must use the glob-based template")
256+
assert.Equal(t, githooks.BuildHookScript("/usr/local/bin/mdsmith"), string(hookData),
257+
"installed hook must match the canonical template")
258258
}
259259

260260
func TestPreMergeCommitStatus_UnmanagedHook(t *testing.T) {
@@ -358,10 +358,8 @@ func TestPreMergeCommitInstall_CreatesHook(t *testing.T) {
358358

359359
data, err := os.ReadFile(hookPath)
360360
require.NoError(t, err)
361-
content := string(data)
362-
assert.Contains(t, content, preMergeCommitHookMarker)
363-
assert.Contains(t, content, "if ! '/usr/local/bin/mdsmith' fix .; then")
364-
assert.Contains(t, content, "git diff --name-only -- '*.md' '*.markdown'")
361+
assert.Equal(t, githooks.BuildHookScript("/usr/local/bin/mdsmith"), string(data),
362+
"installed hook must match the canonical template")
365363
}
366364

367365
func TestPreMergeCommitUninstall_RemovesHook(t *testing.T) {

internal/githooks/githooks.go

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -937,11 +937,18 @@ func BuildHookScript(exe string) string {
937937
"# marked with merge=mdsmith in .gitattributes.\n" +
938938
"set -e\n" +
939939
"cd \"$(git rev-parse --show-toplevel)\"\n" +
940-
"if ! " + shellQuote(exe) + " fix .; then\n" +
941-
" status=$?\n" +
942-
" if [ \"$status\" -ne 1 ]; then\n" +
943-
" exit \"$status\"\n" +
944-
" fi\n" +
940+
"# `set +e` around the fix invocation so we can capture its\n" +
941+
"# raw exit code. `if ! cmd; then status=$?; ...` looks\n" +
942+
"# tempting, but POSIX `! cmd` returns the logical NOT of\n" +
943+
"# cmd's exit status, so `$?` immediately after is 0 when\n" +
944+
"# cmd exited 1 — and the `[ \"$status\" -ne 1 ]` guard\n" +
945+
"# would then exit before the staging loop ever runs.\n" +
946+
"set +e\n" +
947+
shellQuote(exe) + " fix .\n" +
948+
"status=$?\n" +
949+
"set -e\n" +
950+
"if [ \"$status\" -ne 0 ] && [ \"$status\" -ne 1 ]; then\n" +
951+
" exit \"$status\"\n" +
945952
"fi\n" +
946953
"git diff --name-only -- '*.md' '*.markdown' | " +
947954
"while IFS= read -r f; do\n" +
@@ -966,8 +973,10 @@ func BuildHookScript(exe string) string {
966973
func HookMatchesCanonical(hook string) bool {
967974
required := []string{
968975
`cd "$(git rev-parse --show-toplevel)"`,
969-
"fix .; then",
970-
`if [ "$status" -ne 1 ]; then`,
976+
"set +e",
977+
" fix .",
978+
"status=$?",
979+
`if [ "$status" -ne 0 ] && [ "$status" -ne 1 ]; then`,
971980
"git diff --name-only -- '*.md' '*.markdown' |",
972981
`while IFS= read -r f; do`,
973982
}

0 commit comments

Comments
 (0)