#!/usr/bin/env vpython3 # Copyright (c) 2026 The Brave Authors. All rights reserved. # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at https://mozilla.org/MPL/2.0/. """Integration tests for commit.py. All tests run cmd.py as a subprocess against a FakeChromiumRepo, which stands in as the brave checkout that `repository.brave.root` resolves to. Shared sandbox/runner infrastructure is imported from cmd_test. """ import os import platform import stat import subprocess import sys import unittest import _boot # noqa: F401 from cmd_test import (CMD_SCRIPT, _GIT_ENV_OVERRIDES, _Sandbox) from alias.commit import _ReassignShortcut # --------------------------------------------------------------------------- # Tests: flag pass-through # --------------------------------------------------------------------------- class TestFlagPassthrough(unittest.TestCase): """gc must strip its own flags and forward everything else to git.""" def setUp(self) -> None: self._sandbox = _Sandbox() self._sandbox.__enter__() self._sandbox.install_hook() def tearDown(self) -> None: self._sandbox.__exit__(None, None, None) def test_custom_flags_stripped_before_git(self) -> None: """--tagged, --issue, --culprit are stripped; git never receives them. Each flag is tested in isolation with a staged change and no hook. If any flag leaked through, git would exit non-zero with 'unknown option'. A clean exit proves gc removed it from the argv it forwarded. """ for flag, value in [ ('--tagged', 'WIP'), ('--issue', '42'), ('--culprit', 'deadbeef'), ]: with self.subTest(flag=flag): self._sandbox.stage_change(f'content for {flag}\n') result = self._sandbox.run_gc([ 'commit', flag, value, '--no-verify', '-m', f'test {flag}' ]) self.assertEqual( result.returncode, 0, msg=f'{flag} may have leaked to git: {result.stderr!r}', ) def test_unknown_flag_reaches_git(self) -> None: """gc forwards unknown flags to git, which then reports the error.""" result = self._sandbox.run_gc(['commit', '--tgas', '-m', 'msg']) # The error must come from git, not from gc. self.assertNotEqual(result.returncode, 0) git_error = result.stderr + result.stdout self.assertTrue( 'unknown' in git_error or 'error' in git_error, msg=f'Expected a git error, got: {git_error!r}', ) def test_no_verify_passes_through(self) -> None: """--no-verify reaches git (bypass hook) and the commit succeeds.""" self._sandbox.stage_change() result = self._sandbox.run_gc( ['commit', '--no-verify', '-m', 'bare commit']) self.assertEqual(result.returncode, 0) def test_short_flags_pass_through(self) -> None: """Single-dash flags (-m) are forwarded verbatim.""" self._sandbox.stage_change() result = self._sandbox.run_gc(['commit', '-m', 'short flag test']) self.assertEqual(result.returncode, 0) def test_amend_passes_through(self) -> None: """--amend is forwarded to git without gc interference.""" # We only care that gc does not produce its own error about --amend. result = self._sandbox.run_gc(['commit', '--amend', '--no-edit']) # git may fail (nothing new to amend), but any error is git's, not gc's. git_error = result.stderr + result.stdout self.assertNotIn('git_cr:', git_error) # --------------------------------------------------------------------------- # Tests: commit integration (requires hook) # --------------------------------------------------------------------------- class TestCommitIntegration(unittest.TestCase): """gc must inject env vars that the commit-msg hook reads correctly.""" def setUp(self) -> None: self._sandbox = _Sandbox() self._sandbox.__enter__() self._sandbox.install_hook() def tearDown(self) -> None: self._sandbox.__exit__(None, None, None) def test_commit_without_flags_succeeds(self) -> None: """A plain commit via gc works end-to-end.""" self._sandbox.stage_change() result = self._sandbox.run_gc(['commit', '-m', 'Plain commit']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') self.assertIn('Plain commit', self._sandbox.last_commit_message()) def test_tagged_flag_prepends_tag(self) -> None: """--tagged WIP results in [WIP] prepended to the commit message.""" self._sandbox.stage_change() result = self._sandbox.run_gc( ['commit', '--tagged', 'WIP', '-m', 'My change']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() self.assertIn('[WIP]', msg) self.assertIn('My change', msg) def test_tagged_multiple_tags(self) -> None: """--tagged WIP,CodeHealth adds both [WIP] and [CodeHealth] tags.""" self._sandbox.stage_change() result = self._sandbox.run_gc( ['commit', '--tagged', 'WIP,CodeHealth', '-m', 'Multi-tag']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() self.assertIn('[WIP]', msg) self.assertIn('[CodeHealth]', msg) def test_issue_flag_appends_resolves_link(self) -> None: """--issue 1234 appends a Resolves link to the commit message.""" self._sandbox.stage_change() result = self._sandbox.run_gc( ['commit', '--issue', '1234', '-m', 'Fix something']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() self.assertIn('Resolves', msg) self.assertIn('1234', msg) self.assertIn('brave-browser/issues/1234', msg) def test_issue_multiple_numbers(self) -> None: """--issue 11,22 adds Resolves links for both issues.""" self._sandbox.stage_change() result = self._sandbox.run_gc( ['commit', '--issue', '11,22', '-m', 'Multi-issue fix']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() self.assertIn('issues/11', msg) self.assertIn('issues/22', msg) def test_tagged_and_issue_combined(self) -> None: """--tagged and --issue can be used together.""" self._sandbox.stage_change() result = self._sandbox.run_gc([ 'commit', '--tagged', 'canary', '--issue', '999', '-m', 'Combined' ]) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() self.assertIn('[canary]', msg) self.assertIn('issues/999', msg) def test_fixup_commit_skips_hook_processing(self) -> None: """fixup! commits bypass tag insertion (hook behaviour).""" # First commit to have something to fixup. self._sandbox.stage_change('v1\n') self._sandbox.run_gc(['commit', '-m', 'Base commit']) self._sandbox.stage_change('v2\n') result = self._sandbox.run_gc( ['commit', '--tagged', 'WIP', '-m', 'fixup! Base commit']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() # The hook skips fixup! commits — no tag should be added. self.assertNotIn('[WIP]', msg) self.assertTrue(msg.startswith('fixup!')) def test_empty_message_rejected(self) -> None: """A commit with only whitespace in the message is rejected.""" self._sandbox.stage_change() # Pass the message via a temp file so the hook sees it. result = self._sandbox.run_gc(['commit', '-m', ' ']) # Either git or the hook rejects this; either way exit != 0. self.assertNotEqual(result.returncode, 0) # --------------------------------------------------------------------------- # Tests: cmd_commit hook sanity check # --------------------------------------------------------------------------- class TestCommitSanityCheck(unittest.TestCase): """cmd_commit must refuse early when the hook is absent or misconfigured.""" def setUp(self) -> None: self._sandbox = _Sandbox() self._sandbox.__enter__() def tearDown(self) -> None: self._sandbox.__exit__(None, None, None) def test_missing_hook_is_rejected(self) -> None: """cmd_commit exits non-zero and mentions install-hook when absent.""" # A fresh sandbox has no hook installed. self._sandbox.stage_change() result = self._sandbox.run_gc(['commit', '-m', 'should fail']) self.assertNotEqual(result.returncode, 0) self.assertIn('install-hook', result.stderr) @unittest.skipIf(platform.system() == 'Windows', 'executable bit is POSIX-only') def test_non_executable_hook_is_rejected(self) -> None: """cmd_commit exits non-zero when the hook lacks the executable bit.""" # Install as a copy so flipping the exec bit is scoped to the sandbox. # A symlink would point at the real HOOK_SOURCE and races with other # tests (in this process or in parallel test files) that chmod it +x. self._sandbox.install_hook(as_copy=True) dest = self._sandbox.hook_dest dest.chmod(dest.stat().st_mode & ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) self._sandbox.stage_change() result = self._sandbox.run_gc(['commit', '-m', 'should fail']) self.assertNotEqual(result.returncode, 0) self.assertIn('install-hook', result.stderr) # --------------------------------------------------------------------------- # Tests: --fixup=reassign: shortcut (delegates to brockit reassign) # --------------------------------------------------------------------------- class TestReassignFixup(unittest.TestCase): """git cr commit --fixup=reassign: delegates to brockit reassign.""" def setUp(self) -> None: self._sandbox = _Sandbox() self._sandbox.__enter__() def tearDown(self) -> None: self._sandbox.__exit__(None, None, None) def _commit_count(self) -> int: return int( subprocess.check_output( ['git', 'rev-list', '--count', 'HEAD'], cwd=self._sandbox.root, text=True, env={ **os.environ, **_GIT_ENV_OVERRIDES }, ).strip()) def test_joined_form_creates_reassign_commit(self) -> None: """--fixup=reassign:HEAD adds an empty reassign! commit on top. No hook is installed: the shortcut must work without it because brockit commits with --no-verify. """ before = self._commit_count() result = self._sandbox.run_gc(['commit', '--fixup=reassign:HEAD']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') self.assertEqual(self._commit_count(), before + 1) msg = self._sandbox.last_commit_message() # brockit formats the subject as `reassign!! `. self.assertTrue(msg.startswith('reassign!'), msg=f'unexpected message: {msg!r}') self.assertIn('Add file.txt', msg) def test_split_form_creates_reassign_commit(self) -> None: """The split spelling `--fixup reassign:HEAD` is handled too.""" before = self._commit_count() result = self._sandbox.run_gc(['commit', '--fixup', 'reassign:HEAD']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') self.assertEqual(self._commit_count(), before + 1) self.assertTrue( self._sandbox.last_commit_message().startswith('reassign!')) def test_empty_ref_is_rejected(self) -> None: """--fixup=reassign: with no ref errors out without committing.""" before = self._commit_count() result = self._sandbox.run_gc(['commit', '--fixup=reassign:']) self.assertNotEqual(result.returncode, 0) self.assertIn('reassign', result.stderr) self.assertEqual(self._commit_count(), before) def test_extra_passthrough_arg_is_rejected(self) -> None: """Mixing the shortcut with a git arg (e.g. -m) errors, no commit.""" before = self._commit_count() result = self._sandbox.run_gc( ['commit', '--fixup=reassign:HEAD', '-m', 'nope']) self.assertNotEqual(result.returncode, 0) self.assertIn('cannot be combined', result.stderr) self.assertEqual(self._commit_count(), before) def test_extra_wrapper_flag_is_rejected(self) -> None: """Mixing the shortcut with a wrapper flag (--tagged) errors too.""" before = self._commit_count() result = self._sandbox.run_gc( ['commit', '--tagged', 'WIP', '--fixup=reassign:HEAD']) self.assertNotEqual(result.returncode, 0) self.assertIn('cannot be combined', result.stderr) self.assertIn('--tagged', result.stderr) self.assertEqual(self._commit_count(), before) def test_native_fixup_still_passes_through_to_git(self) -> None: """A plain `--fixup=` is not intercepted and reaches git. git produces a `fixup! Add file.txt` commit, proving the wrapper only claims the `reassign:` mode and leaves git's own modes alone. """ self._sandbox.install_hook() self._sandbox.stage_change('fixup body\n') result = self._sandbox.run_gc( ['commit', '--fixup=HEAD', '--no-verify']) self.assertEqual(result.returncode, 0, msg=f'stderr: {result.stderr}') msg = self._sandbox.last_commit_message() self.assertTrue(msg.startswith('fixup!'), msg=f'unexpected message: {msg!r}') # --------------------------------------------------------------------------- # Tests: _ReassignShortcut.from_args detection and leniency (unit) # --------------------------------------------------------------------------- class TestReassignShortcutParsing(unittest.TestCase): """Unit checks for the lenient arg detection in from_args.""" def test_detects_joined_and_split_forms(self) -> None: """Both `--fixup=reassign:X` and `--fixup reassign:X` are recognised.""" self.assertIsNotNone( _ReassignShortcut.from_args(['--fixup=reassign:HEAD'])) self.assertIsNotNone( _ReassignShortcut.from_args(['--fixup', 'reassign:HEAD'])) def test_absent_when_no_reassign_marker(self) -> None: """Ordinary commits and git's native fixup modes are not claimed.""" self.assertIsNone(_ReassignShortcut.from_args(['--fixup=HEAD'])) self.assertIsNone(_ReassignShortcut.from_args(['-m', 'a message'])) def test_marker_outside_fixup_is_not_the_shortcut(self) -> None: """`reassign:` only in a commit message is not the shortcut.""" self.assertIsNone( _ReassignShortcut.from_args(['-m', 'fix reassign: bug'])) def test_malformed_fixup_does_not_terminate(self) -> None: """A `--fixup` with no value must not exit the process. A strict ArgumentParser would call sys.exit() here; the lenient parser instead reports "not the shortcut" so the caller falls back to the normal commit path. `reassign:` appears in another token so the cheap pre-check passes and the parse is actually attempted. """ self.assertIsNone( _ReassignShortcut.from_args(['reassign:x', '--fixup'])) # --------------------------------------------------------------------------- # Tests: KeyboardInterrupt / graceful exit # --------------------------------------------------------------------------- class TestGracefulExit(unittest.TestCase): """gc must not emit Python tracebacks on Ctrl-C.""" def setUp(self) -> None: self._sandbox = _Sandbox() self._sandbox.__enter__() self._sandbox.install_hook() def tearDown(self) -> None: self._sandbox.__exit__(None, None, None) def test_no_traceback_on_keyboard_interrupt(self) -> None: """Sending SIGINT produces exit code 130 with no traceback.""" import signal proc = subprocess.Popen( [sys.executable, str(CMD_SCRIPT), 'commit', '-m', 'test'], cwd=self._sandbox.root, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env={ **os.environ, **_GIT_ENV_OVERRIDES }, ) try: proc.send_signal(signal.SIGINT) _, stderr = proc.communicate(timeout=5) except subprocess.TimeoutExpired: proc.kill() _, stderr = proc.communicate() self.assertNotIn('Traceback', stderr) # 130 = Python caught KeyboardInterrupt and called sys.exit(130). # -2 = OS killed the process with SIGINT before Python handled it. # Both indicate clean termination; which one occurs is timing-dependent. self.assertIn(proc.returncode, (-2, 130)) if __name__ == '__main__': unittest.main()