From b3af356532e7129cec25e08b15911830ab1caa06 Mon Sep 17 00:00:00 2001 From: Deepjyoti-Sarmah Date: Thu, 15 Oct 2026 18:35:13 +0530 Subject: [PATCH] fix(hooks): drop MERGE_HEAD guard from post-merge so real merges rebuild (#4463) git leaves .git/MERGE_HEAD on disk while post-merge runs and deletes it only after the hook returns, so the guard exited on every merge that makes a merge commit. Only fast-forwards rebuilt. The post-commit and post-checkout guards are unchanged. (cherry picked from commit c37d35bd50bebe06e713aaffe1e79282affc3364) --- graphify/hooks.py | 9 ++++----- tests/test_hooks.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/graphify/hooks.py b/graphify/hooks.py index 05d3b28d7..dd0732f60 100644 --- a/graphify/hooks.py +++ b/graphify/hooks.py @@ -583,16 +583,15 @@ if [ ! -d "graphify-out" ]; then exit 0 fi -# This hook only fires after a merge completed cleanly, so MERGE_HEAD is -# already gone by the time it runs -- these checks are belt and braces for -# any git version/workflow quirk that lands here mid rebase or cherry-pick -# anyway, matching the commit and checkout hooks' own defensive posture. +# git leaves MERGE_HEAD on disk while post-merge runs and deletes it only +# after the hook returns, so it must not be checked here: that skipped every +# merge that makes a merge commit (#4463). The checks below are belt and +# braces for any quirk that lands here mid rebase or cherry-pick. # git exports GIT_DIR to hooks; the rev-parse fallback only runs when invoked # by hand (each git exec costs 1s+ on AV-scanned Windows machines). GIT_DIR=${GIT_DIR:-$(git rev-parse --git-dir 2>/dev/null)} [ -d "$GIT_DIR/rebase-merge" ] && exit 0 [ -d "$GIT_DIR/rebase-apply" ] && exit 0 -[ -f "$GIT_DIR/MERGE_HEAD" ] && exit 0 [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] && exit 0 # Honor the same opt out the commit and checkout hooks already respect. diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 0411565f6..b903e6b0c 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1655,6 +1655,46 @@ def test_commit_hook_skips_graph_only_commit_when_graphify_out_is_renamed(tmp_pa ) +@_SH_ONLY +def test_post_merge_hook_runs_after_a_real_merge(tmp_path): + # git writes MERGE_HEAD before running post-merge and deletes it only + # after, so a MERGE_HEAD guard here exited on every non-fast-forward + # merge and the rebuild never launched (#4463). + repo = tmp_path / "repo" + repo.mkdir() + env = { + **os.environ, + "HOME": str(tmp_path), # keeps the rebuild log out of the real ~/.cache + "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t.co", + "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t.co", + } + quiet = {**env, "GRAPHIFY_SKIP_HOOK": "1"} # no rebuilds during setup + + def git(*args, env=env): + return subprocess.run(["git", *args], cwd=repo, env=env, + capture_output=True, text=True, check=True) + + git("init", "-q", "-b", "main") + (repo / "graphify-out").mkdir() + (repo / "graphify-out" / "graph.json").write_text("{}", encoding="utf-8") + (repo / "a.py").write_text("def a(): pass\n", encoding="utf-8") + git("add", ".") + git("commit", "-qm", "init", env=quiet) + install(repo) + git("checkout", "-qb", "feat", env=quiet) + (repo / "b.py").write_text("def b(): pass\n", encoding="utf-8") + git("add", ".") + git("commit", "-qm", "b", env=quiet) + git("checkout", "-q", "main", env=quiet) + + out = git("merge", "--no-ff", "-m", "merge feat", "feat") + + assert _LAUNCH_LINE in out.stdout + out.stderr, ( + "post-merge exited before the launch\n" + f"stdout={out.stdout!r}\nstderr={out.stderr!r}" + ) + + @_SH_ONLY def test_commit_hook_skips_graph_only_commit_under_default_dir(tmp_path): """Control: the default-name case the filter always handled keeps working."""