Skip to content

Commit aa1cdfe

Browse files
jdclaude
andauthored
fix(stack): preserve Change-Id when amending commits with -m flag (#903)
When tools like Claude Code amend commits using `git commit --amend -m "msg"`, the Change-Id was being lost because: 1. The -m flag replaces the entire commit message 2. The commit-msg hook only preserves Change-Id if already present in the message This adds a prepare-commit-msg hook that detects amend operations and preserves the original Change-Id. The detection works by comparing GIT_AUTHOR_DATE (which git sets to the original commit's author date during --amend) with HEAD's author date. An additional check ensures the date is at least 2 seconds old to avoid false positives when commits happen in quick succession. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 504fb53 commit aa1cdfe

File tree

3 files changed

+287
-10
lines changed

3 files changed

+287
-10
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/bin/sh
2+
#
3+
# Copyright © 2021-2024 Mergify SAS
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
# This hook preserves Change-Id during amend operations where the message
18+
# is provided via -m or -F flags (which would otherwise lose the Change-Id).
19+
#
20+
# Arguments:
21+
# $1 - Path to the commit message file
22+
# $2 - Source of the commit message: message, template, merge, squash, or commit
23+
# $3 - Commit SHA (only for squash or commit source)
24+
25+
if test "$#" -lt 1; then
26+
exit 0
27+
fi
28+
29+
MSG_FILE="$1"
30+
SOURCE="${2:-}"
31+
32+
# If source is "commit" (from --amend or -c/-C), git already preserves the
33+
# original message content including Change-Id, so we don't need to do anything.
34+
# This hook is specifically for the case where -m or -F is used with --amend,
35+
# which sets source to "message" and loses the original Change-Id.
36+
37+
# Only act if we have a message file
38+
if test ! -f "$MSG_FILE"; then
39+
exit 0
40+
fi
41+
42+
# Check if HEAD exists (not initial commit)
43+
if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
44+
exit 0
45+
fi
46+
47+
# Check if the current message already has a Change-Id
48+
if grep -q "^Change-Id: I[0-9a-f]\{40\}$" "$MSG_FILE"; then
49+
exit 0
50+
fi
51+
52+
# Get Change-Id from HEAD if it exists
53+
HEAD_CHANGEID=$(git log -1 --format=%B HEAD 2>/dev/null | grep "^Change-Id: I[0-9a-f]\{40\}$" | tail -1)
54+
if test -z "$HEAD_CHANGEID"; then
55+
exit 0
56+
fi
57+
58+
# Heuristic to detect amend: During --amend, git sets GIT_AUTHOR_DATE to preserve
59+
# the original author date. This date should exactly match HEAD's author date.
60+
# For a regular commit, GIT_AUTHOR_DATE is not set by git.
61+
# We also check that source is "message" (set by git for both -m and -F flags).
62+
if test "$SOURCE" = "message" && test -n "$GIT_AUTHOR_DATE"; then
63+
# Get HEAD's author date in the same format git uses internally (raw format)
64+
# The raw format is: seconds-since-epoch timezone (e.g., "1234567890 +0000")
65+
HEAD_AUTHOR_DATE_RAW=$(git log -1 --format=%ad --date=raw HEAD 2>/dev/null)
66+
if test -n "$HEAD_AUTHOR_DATE_RAW"; then
67+
# Extract epoch from GIT_AUTHOR_DATE (handles various formats)
68+
# During amend, GIT_AUTHOR_DATE is in format: "@epoch tz" (e.g., "@1234567890 +0000")
69+
# Remove the @ prefix if present
70+
GIT_AUTHOR_EPOCH=$(echo "$GIT_AUTHOR_DATE" | cut -d' ' -f1 | tr -d '@')
71+
HEAD_AUTHOR_EPOCH=$(echo "$HEAD_AUTHOR_DATE_RAW" | cut -d' ' -f1)
72+
73+
# If the epoch timestamps match, this is likely an amend operation.
74+
# Additional check: the author date should be at least 2 seconds in the past.
75+
# This prevents false positives when commits happen in quick succession
76+
# (e.g., in automated tests or scripts) where timestamps might match by coincidence.
77+
if test "$GIT_AUTHOR_EPOCH" = "$HEAD_AUTHOR_EPOCH"; then
78+
CURRENT_EPOCH=$(date +%s)
79+
AGE=$((CURRENT_EPOCH - GIT_AUTHOR_EPOCH))
80+
if test "$AGE" -ge 2; then
81+
# This looks like an amend with -m flag - preserve the Change-Id
82+
echo "" >> "$MSG_FILE"
83+
echo "$HEAD_CHANGEID" >> "$MSG_FILE"
84+
fi
85+
fi
86+
fi
87+
fi

mergify_cli/stack/setup.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,11 @@
2626
from mergify_cli import utils
2727

2828

29-
async def stack_setup() -> None:
30-
hooks_dir = pathlib.Path(await utils.git("rev-parse", "--git-path", "hooks"))
31-
installed_hook_file = hooks_dir / "commit-msg"
29+
async def _install_hook(hooks_dir: pathlib.Path, hook_name: str) -> None:
30+
installed_hook_file = hooks_dir / hook_name
3231

3332
new_hook_file = str(
34-
importlib.resources.files(__package__).joinpath("hooks/commit-msg"),
33+
importlib.resources.files(__package__).joinpath(f"hooks/{hook_name}"),
3534
)
3635

3736
if installed_hook_file.exists():
@@ -40,7 +39,7 @@ async def stack_setup() -> None:
4039
async with aiofiles.open(new_hook_file) as f:
4140
data_new = await f.read()
4241
if data_installed == data_new:
43-
console.log("Git commit-msg hook is up to date")
42+
console.log(f"Git {hook_name} hook is up to date")
4443
else:
4544
console.print(
4645
f"error: {installed_hook_file} differ from mergify_cli hook",
@@ -49,6 +48,12 @@ async def stack_setup() -> None:
4948
sys.exit(1)
5049

5150
else:
52-
console.log("Installation of git commit-msg hook")
51+
console.log(f"Installation of git {hook_name} hook")
5352
shutil.copy(new_hook_file, installed_hook_file)
5453
installed_hook_file.chmod(0o755)
54+
55+
56+
async def stack_setup() -> None:
57+
hooks_dir = pathlib.Path(await utils.git("rev-parse", "--git-path", "hooks"))
58+
await _install_hook(hooks_dir, "commit-msg")
59+
await _install_hook(hooks_dir, "prepare-commit-msg")

mergify_cli/tests/stack/test_setup.py

Lines changed: 189 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
from __future__ import annotations
22

3+
import re
4+
import shutil
5+
import subprocess
36
import typing
47

8+
import pytest
9+
510
from mergify_cli.stack import setup
11+
from mergify_cli.stack.changes import CHANGEID_RE
612

713

814
if typing.TYPE_CHECKING:
915
import pathlib
1016

11-
import pytest
12-
1317
from mergify_cli.tests import utils as test_utils
1418

1519

@@ -22,5 +26,186 @@ async def test_setup(
2226
git_mock.mock("rev-parse", "--git-path", "hooks", output=str(hooks_dir))
2327
await setup.stack_setup()
2428

25-
hook = hooks_dir / "commit-msg"
26-
assert hook.exists()
29+
commit_msg_hook = hooks_dir / "commit-msg"
30+
assert commit_msg_hook.exists()
31+
32+
prepare_commit_msg_hook = hooks_dir / "prepare-commit-msg"
33+
assert prepare_commit_msg_hook.exists()
34+
35+
36+
@pytest.fixture
37+
def git_repo_with_hooks(tmp_path: pathlib.Path) -> pathlib.Path:
38+
"""Create a real git repo with the hooks installed."""
39+
import importlib.resources
40+
41+
subprocess.run(
42+
["git", "init", "--initial-branch=main"],
43+
check=True,
44+
cwd=tmp_path,
45+
)
46+
subprocess.run(
47+
["git", "config", "user.email", "[email protected]"],
48+
check=True,
49+
cwd=tmp_path,
50+
)
51+
subprocess.run(
52+
["git", "config", "user.name", "Test User"],
53+
check=True,
54+
cwd=tmp_path,
55+
)
56+
57+
# Install hooks
58+
hooks_dir = tmp_path / ".git" / "hooks"
59+
for hook_name in ("commit-msg", "prepare-commit-msg"):
60+
hook_source = str(
61+
importlib.resources.files("mergify_cli.stack").joinpath(
62+
f"hooks/{hook_name}",
63+
),
64+
)
65+
hook_dest = hooks_dir / hook_name
66+
shutil.copy(hook_source, hook_dest)
67+
hook_dest.chmod(0o755)
68+
69+
return tmp_path
70+
71+
72+
def get_commit_message(repo_path: pathlib.Path) -> str:
73+
"""Get the current HEAD commit message."""
74+
return subprocess.check_output(
75+
["git", "log", "-1", "--format=%B"],
76+
text=True,
77+
cwd=repo_path,
78+
)
79+
80+
81+
def get_change_id(message: str) -> str | None:
82+
"""Extract Change-Id from a commit message."""
83+
match = CHANGEID_RE.search(message)
84+
return match.group(1) if match else None
85+
86+
87+
def test_commit_gets_change_id(git_repo_with_hooks: pathlib.Path) -> None:
88+
"""Test that a new commit gets a Change-Id from the commit-msg hook."""
89+
# Create a file and commit
90+
(git_repo_with_hooks / "file.txt").write_text("content")
91+
subprocess.run(["git", "add", "file.txt"], check=True, cwd=git_repo_with_hooks)
92+
subprocess.run(
93+
["git", "commit", "-m", "Initial commit"],
94+
check=True,
95+
cwd=git_repo_with_hooks,
96+
)
97+
98+
message = get_commit_message(git_repo_with_hooks)
99+
change_id = get_change_id(message)
100+
101+
assert change_id is not None, f"Expected Change-Id in message:\n{message}"
102+
assert re.match(r"^I[0-9a-f]{40}$", change_id)
103+
104+
105+
def test_amend_with_m_flag_preserves_change_id(
106+
git_repo_with_hooks: pathlib.Path,
107+
) -> None:
108+
"""Test that amending a commit with -m flag preserves the Change-Id.
109+
110+
This is the specific scenario where tools like Claude Code amend commits
111+
by passing the message via -m flag, which would otherwise lose the Change-Id.
112+
"""
113+
import time
114+
115+
# Create initial commit with Change-Id
116+
(git_repo_with_hooks / "file.txt").write_text("content")
117+
subprocess.run(["git", "add", "file.txt"], check=True, cwd=git_repo_with_hooks)
118+
subprocess.run(
119+
["git", "commit", "-m", "Initial commit"],
120+
check=True,
121+
cwd=git_repo_with_hooks,
122+
)
123+
124+
original_message = get_commit_message(git_repo_with_hooks)
125+
original_change_id = get_change_id(original_message)
126+
assert original_change_id is not None
127+
128+
# Wait a bit so the hook can detect this is an amend (author date will be old)
129+
time.sleep(2)
130+
131+
# Amend with -m flag (this is what Claude Code does)
132+
subprocess.run(
133+
["git", "commit", "--amend", "-m", "Amended commit"],
134+
check=True,
135+
cwd=git_repo_with_hooks,
136+
)
137+
138+
amended_message = get_commit_message(git_repo_with_hooks)
139+
amended_change_id = get_change_id(amended_message)
140+
141+
assert amended_change_id is not None, (
142+
f"Expected Change-Id in amended message:\n{amended_message}"
143+
)
144+
assert amended_change_id == original_change_id, (
145+
f"Change-Id should be preserved during amend.\n"
146+
f"Original: {original_change_id}\n"
147+
f"After amend: {amended_change_id}"
148+
)
149+
150+
151+
def test_amend_without_m_flag_preserves_change_id(
152+
git_repo_with_hooks: pathlib.Path,
153+
) -> None:
154+
"""Test that amending without -m flag also preserves the Change-Id."""
155+
# Create initial commit with Change-Id
156+
(git_repo_with_hooks / "file.txt").write_text("content")
157+
subprocess.run(["git", "add", "file.txt"], check=True, cwd=git_repo_with_hooks)
158+
subprocess.run(
159+
["git", "commit", "-m", "Initial commit"],
160+
check=True,
161+
cwd=git_repo_with_hooks,
162+
)
163+
164+
original_message = get_commit_message(git_repo_with_hooks)
165+
original_change_id = get_change_id(original_message)
166+
assert original_change_id is not None
167+
168+
# Amend without changing message
169+
subprocess.run(
170+
["git", "commit", "--amend", "--no-edit"],
171+
check=True,
172+
cwd=git_repo_with_hooks,
173+
)
174+
175+
amended_message = get_commit_message(git_repo_with_hooks)
176+
amended_change_id = get_change_id(amended_message)
177+
178+
assert amended_change_id is not None
179+
assert amended_change_id == original_change_id
180+
181+
182+
def test_new_commit_after_amend_gets_new_change_id(
183+
git_repo_with_hooks: pathlib.Path,
184+
) -> None:
185+
"""Test that a new commit (not an amend) gets a new Change-Id."""
186+
# Create first commit
187+
(git_repo_with_hooks / "file1.txt").write_text("content1")
188+
subprocess.run(["git", "add", "file1.txt"], check=True, cwd=git_repo_with_hooks)
189+
subprocess.run(
190+
["git", "commit", "-m", "First commit"],
191+
check=True,
192+
cwd=git_repo_with_hooks,
193+
)
194+
195+
first_change_id = get_change_id(get_commit_message(git_repo_with_hooks))
196+
assert first_change_id is not None
197+
198+
# Create second commit (should get a different Change-Id)
199+
(git_repo_with_hooks / "file2.txt").write_text("content2")
200+
subprocess.run(["git", "add", "file2.txt"], check=True, cwd=git_repo_with_hooks)
201+
subprocess.run(
202+
["git", "commit", "-m", "Second commit"],
203+
check=True,
204+
cwd=git_repo_with_hooks,
205+
)
206+
207+
second_change_id = get_change_id(get_commit_message(git_repo_with_hooks))
208+
assert second_change_id is not None
209+
assert second_change_id != first_change_id, (
210+
"Each commit should have a unique Change-Id"
211+
)

0 commit comments

Comments
 (0)