11from __future__ import annotations
22
3+ import re
4+ import shutil
5+ import subprocess
36import typing
47
8+ import pytest
9+
510from mergify_cli .stack import setup
11+ from mergify_cli .stack .changes import CHANGEID_RE
612
713
814if 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