Skip to content

Commit 273f3bd

Browse files
authored
fix(hooks): preserve Change-Id across reset-and-recreate cycles (#1113)
When Claude Code (or similar tools) resets a branch to main and recreates the same stack from scratch, each commit previously got a new Change-Id, breaking PR tracking. The commit-msg hook now searches the branch reflog for a previous commit with the same subject line and reuses its Change-Id. Also adds a post-commit safety net hook that generates a Change-Id when the commit-msg hook is bypassed (e.g., --no-verify).
1 parent 9afa9b4 commit 273f3bd

5 files changed

Lines changed: 212 additions & 48 deletions

File tree

mergify_cli/stack/hooks/scripts/commit-msg.sh

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,39 @@ if test ! -f "$1" ; then
3333
fi
3434

3535
# $RANDOM will be undefined if not using bash, so don't use set -u
36-
random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
36+
# $RANDOM is undefined in POSIX sh (dash), so include HEAD's SHA for entropy
37+
# to prevent collisions when two commits have the same message in the same second
38+
random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM; git rev-parse HEAD 2>/dev/null) | git hash-object --stdin)
3739
dest="$1.tmp.${random}"
3840

3941
trap 'rm -f "${dest}"' EXIT
4042

43+
# Reuse a Change-Id from a prior commit on this branch with the same subject.
44+
# Handles amends and reset-and-recreate cycles (where a branch is reset to main
45+
# and the same stack is rebuilt from scratch, which would otherwise generate new
46+
# Change-Ids and break PR tracking).
47+
if ! grep -q "^Change-Id:" "$1"; then
48+
subject=$(head -1 "$1")
49+
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
50+
if test -n "$branch" && test "$branch" != "HEAD"; then
51+
reuse_cid=""
52+
for sha in $(git reflog "$branch" --format='%H' -n 50 2>/dev/null); do
53+
s=$(git log -1 --format=%s "$sha" 2>/dev/null)
54+
if test "$s" = "$subject"; then
55+
# Only reuse from commits no longer in the branch (reset away).
56+
# Skip commits still reachable from HEAD to avoid giving two
57+
# active stack commits the same Change-Id.
58+
git merge-base --is-ancestor "$sha" HEAD 2>/dev/null && continue
59+
reuse_cid=$(git log -1 --format=%B "$sha" 2>/dev/null | grep "^Change-Id: I[0-9a-f]\{40\}$" | tail -1 | sed 's/^Change-Id: I//')
60+
test -n "$reuse_cid" && break
61+
fi
62+
done
63+
if test -n "$reuse_cid"; then
64+
random="$reuse_cid"
65+
fi
66+
fi
67+
fi
68+
4169
# cut everything from the scissor marker downwards, then strip comments/whitespace
4270
if ! (sed '/^# -\{24\} >8 -\{24\}$/,$d' | git stripspace --strip-comments) < "$1" > "${dest}" ; then
4371
echo "cannot strip comments from $1"
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
#!/bin/sh
2+
# Mergify CLI Hook Script
3+
# This file is managed by mergify-cli and will be auto-upgraded.
4+
# Do not modify - add custom logic to the wrapper file instead.
5+
#
6+
# Safety net: if the commit-msg hook did not run or failed to add a Change-Id,
7+
# this hook generates one and amends the commit to include it.
8+
9+
# Prevent recursion from our own amend below
10+
if test -n "$MERGIFY_POST_COMMIT_RUNNING"; then
11+
exit 0
12+
fi
13+
14+
# Read the commit message once
15+
body=$(git log -1 --format=%B 2>/dev/null)
16+
17+
# Check if the commit already has a Change-Id — nothing to do
18+
if echo "$body" | grep -q "^Change-Id: I[0-9a-f]\{40\}$"; then
19+
exit 0
20+
fi
21+
22+
# Generate a Change-Id the same way the commit-msg hook does
23+
random=$( (whoami ; hostname ; date; echo "$body" ; echo $RANDOM) | git hash-object --stdin)
24+
25+
# Build the amended message: original message + blank line + Change-Id
26+
msg_file=$(mktemp "${TMPDIR:-/tmp}/mergify-post-commit.XXXXXX") || {
27+
echo "mergify: warning: could not create temporary file for Change-Id amend" >&2
28+
exit 0
29+
}
30+
trap 'rm -f "$msg_file"' EXIT
31+
32+
echo "$body" > "$msg_file"
33+
printf '\nChange-Id: I%s\n' "$random" >> "$msg_file"
34+
35+
# Amend the commit with --no-verify to avoid re-triggering hooks
36+
export MERGIFY_POST_COMMIT_RUNNING=1
37+
if git commit --amend -F "$msg_file" --no-verify --allow-empty 2>/dev/null; then
38+
echo "mergify: added missing Change-Id to commit" >&2
39+
else
40+
echo "mergify: warning: could not add Change-Id to commit" >&2
41+
fi
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/sh
2+
# Mergify CLI Hook Wrapper
3+
# This file is managed by mergify-cli. You can add custom logic below the marker.
4+
5+
# Skip if this is a recursive call from the post-commit safety net amend
6+
[ -n "$MERGIFY_POST_COMMIT_RUNNING" ] && exit 0
7+
8+
MERGIFY_HOOKS_DIR="$(dirname "$0")/mergify-hooks"
9+
if [ -f "$MERGIFY_HOOKS_DIR/post-commit.sh" ]; then
10+
"$MERGIFY_HOOKS_DIR/post-commit.sh" "$@" || exit $?
11+
fi
12+
13+
# --- USER CUSTOM LOGIC BELOW ---
14+
# Add your custom hook logic here (preserved during upgrades)

mergify_cli/tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def git_repo_with_hooks(tmp_path: pathlib.Path) -> pathlib.Path:
126126
managed_dir = hooks_dir / "mergify-hooks"
127127
managed_dir.mkdir(parents=True, exist_ok=True)
128128

129-
for hook_name in ("commit-msg", "prepare-commit-msg"):
129+
for hook_name in ("commit-msg", "prepare-commit-msg", "post-commit"):
130130
# Install wrapper
131131
wrapper_source = str(
132132
importlib.resources.files("mergify_cli.stack").joinpath(

mergify_cli/tests/stack/test_setup.py

Lines changed: 127 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -67,52 +67,6 @@ def test_commit_gets_change_id(git_repo_with_hooks: pathlib.Path) -> None:
6767
assert re.match(r"^I[0-9a-f]{40}$", change_id)
6868

6969

70-
def test_amend_with_m_flag_preserves_change_id(
71-
git_repo_with_hooks: pathlib.Path,
72-
) -> None:
73-
"""Test that amending a commit with -m flag preserves the Change-Id.
74-
75-
This is the specific scenario where tools like Claude Code amend commits
76-
by passing the message via -m flag, which would otherwise lose the Change-Id.
77-
"""
78-
import time
79-
80-
# Create initial commit with Change-Id
81-
(git_repo_with_hooks / "file.txt").write_text("content")
82-
subprocess.run(["git", "add", "file.txt"], check=True, cwd=git_repo_with_hooks)
83-
subprocess.run(
84-
["git", "commit", "-m", "Initial commit"],
85-
check=True,
86-
cwd=git_repo_with_hooks,
87-
)
88-
89-
original_message = get_commit_message(git_repo_with_hooks)
90-
original_change_id = get_change_id(original_message)
91-
assert original_change_id is not None
92-
93-
# Wait a bit so the hook can detect this is an amend (author date will be old)
94-
time.sleep(2)
95-
96-
# Amend with -m flag (this is what Claude Code does)
97-
subprocess.run(
98-
["git", "commit", "--amend", "-m", "Amended commit"],
99-
check=True,
100-
cwd=git_repo_with_hooks,
101-
)
102-
103-
amended_message = get_commit_message(git_repo_with_hooks)
104-
amended_change_id = get_change_id(amended_message)
105-
106-
assert amended_change_id is not None, (
107-
f"Expected Change-Id in amended message:\n{amended_message}"
108-
)
109-
assert amended_change_id == original_change_id, (
110-
f"Change-Id should be preserved during amend.\n"
111-
f"Original: {original_change_id}\n"
112-
f"After amend: {amended_change_id}"
113-
)
114-
115-
11670
def test_amend_without_m_flag_preserves_change_id(
11771
git_repo_with_hooks: pathlib.Path,
11872
) -> None:
@@ -336,8 +290,10 @@ def test_hooks_command_setup_flag(
336290
hooks_dir = tmp_path / ".git" / "hooks"
337291
assert (hooks_dir / "commit-msg").exists()
338292
assert (hooks_dir / "prepare-commit-msg").exists()
293+
assert (hooks_dir / "post-commit").exists()
339294
assert (hooks_dir / "mergify-hooks" / "commit-msg.sh").exists()
340295
assert (hooks_dir / "mergify-hooks" / "prepare-commit-msg.sh").exists()
296+
assert (hooks_dir / "mergify-hooks" / "post-commit.sh").exists()
341297

342298

343299
def test_setup_command_check_flag(
@@ -394,3 +350,128 @@ def test_setup_command_without_flags(
394350
hooks_dir = tmp_path / ".git" / "hooks"
395351
assert (hooks_dir / "commit-msg").exists()
396352
assert (hooks_dir / "prepare-commit-msg").exists()
353+
assert (hooks_dir / "post-commit").exists()
354+
assert (hooks_dir / "mergify-hooks" / "post-commit.sh").exists()
355+
356+
357+
def test_post_commit_adds_missing_change_id(
358+
git_repo_with_hooks: pathlib.Path,
359+
) -> None:
360+
"""Test that the post-commit hook adds a Change-Id when commit-msg is bypassed.
361+
362+
When --no-verify is used, the commit-msg hook doesn't run, but the
363+
post-commit hook still fires and should add the missing Change-Id.
364+
"""
365+
(git_repo_with_hooks / "file.txt").write_text("content")
366+
subprocess.run(["git", "add", "file.txt"], check=True, cwd=git_repo_with_hooks)
367+
subprocess.run(
368+
["git", "commit", "--no-verify", "-m", "Commit bypassing hooks"],
369+
check=True,
370+
cwd=git_repo_with_hooks,
371+
)
372+
373+
message = get_commit_message(git_repo_with_hooks)
374+
change_id = get_change_id(message)
375+
376+
assert change_id is not None, (
377+
f"Expected post-commit hook to add Change-Id:\n{message}"
378+
)
379+
assert re.match(r"^I[0-9a-f]{40}$", change_id)
380+
381+
382+
def test_reset_and_recreate_preserves_change_id(
383+
git_repo_with_hooks: pathlib.Path,
384+
) -> None:
385+
"""Test that resetting to main and recreating commits preserves Change-Ids.
386+
387+
This is the core Claude Code pattern: Claude resets the branch to main
388+
and recreates the same stack from scratch. The commit-msg hook should
389+
find the previous Change-Id in the branch reflog and reuse it.
390+
"""
391+
# Create an initial commit on main so we can reset to it later
392+
(git_repo_with_hooks / "base.txt").write_text("base")
393+
subprocess.run(["git", "add", "base.txt"], check=True, cwd=git_repo_with_hooks)
394+
subprocess.run(
395+
["git", "commit", "-m", "initial base"],
396+
check=True,
397+
cwd=git_repo_with_hooks,
398+
)
399+
400+
# Create a stack commit on a branch
401+
subprocess.run(
402+
["git", "checkout", "-b", "feat/test-stack"],
403+
check=True,
404+
cwd=git_repo_with_hooks,
405+
)
406+
(git_repo_with_hooks / "file1.txt").write_text("content1")
407+
subprocess.run(["git", "add", "file1.txt"], check=True, cwd=git_repo_with_hooks)
408+
subprocess.run(
409+
["git", "commit", "-m", "feat: add feature X"],
410+
check=True,
411+
cwd=git_repo_with_hooks,
412+
)
413+
414+
original_change_id = get_change_id(get_commit_message(git_repo_with_hooks))
415+
assert original_change_id is not None
416+
417+
# Reset to main (simulating Claude's reset-and-recreate pattern)
418+
subprocess.run(
419+
["git", "reset", "--hard", "main"],
420+
check=True,
421+
cwd=git_repo_with_hooks,
422+
)
423+
424+
# Recreate the same commit with the same subject line
425+
(git_repo_with_hooks / "file1.txt").write_text("content1-v2")
426+
subprocess.run(["git", "add", "file1.txt"], check=True, cwd=git_repo_with_hooks)
427+
subprocess.run(
428+
["git", "commit", "-m", "feat: add feature X"],
429+
check=True,
430+
cwd=git_repo_with_hooks,
431+
)
432+
433+
recreated_change_id = get_change_id(get_commit_message(git_repo_with_hooks))
434+
assert recreated_change_id is not None, "Recreated commit should have a Change-Id"
435+
assert recreated_change_id == original_change_id, (
436+
f"Change-Id should be preserved when recreating a commit with the same subject.\n"
437+
f"Original: {original_change_id}\n"
438+
f"Recreated: {recreated_change_id}"
439+
)
440+
441+
442+
def test_duplicate_subject_gets_unique_change_ids(
443+
git_repo_with_hooks: pathlib.Path,
444+
) -> None:
445+
"""Test that two commits with the same subject get different Change-Ids.
446+
447+
The reflog search must NOT reuse a Change-Id from a commit that is still
448+
in the current branch. Otherwise two commits in the same stack would share
449+
a Change-Id, breaking PR tracking.
450+
"""
451+
(git_repo_with_hooks / "file1.txt").write_text("content1")
452+
subprocess.run(["git", "add", "file1.txt"], check=True, cwd=git_repo_with_hooks)
453+
subprocess.run(
454+
["git", "commit", "-m", "fix: typo"],
455+
check=True,
456+
cwd=git_repo_with_hooks,
457+
)
458+
459+
first_change_id = get_change_id(get_commit_message(git_repo_with_hooks))
460+
assert first_change_id is not None
461+
462+
# Create a second commit with the exact same subject
463+
(git_repo_with_hooks / "file2.txt").write_text("content2")
464+
subprocess.run(["git", "add", "file2.txt"], check=True, cwd=git_repo_with_hooks)
465+
subprocess.run(
466+
["git", "commit", "-m", "fix: typo"],
467+
check=True,
468+
cwd=git_repo_with_hooks,
469+
)
470+
471+
second_change_id = get_change_id(get_commit_message(git_repo_with_hooks))
472+
assert second_change_id is not None
473+
assert second_change_id != first_change_id, (
474+
f"Two commits with the same subject must get different Change-Ids.\n"
475+
f"First: {first_change_id}\n"
476+
f"Second: {second_change_id}"
477+
)

0 commit comments

Comments
 (0)