@@ -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-
11670def 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
343299def 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