diff --git a/pre_commit/commands/hook_impl.py b/pre_commit/commands/hook_impl.py index 7b806f3b8..05014ef74 100644 --- a/pre_commit/commands/hook_impl.py +++ b/pre_commit/commands/hook_impl.py @@ -14,6 +14,14 @@ Z40 = '0' * 40 +def _is_zero_oid(oid: str) -> bool: + # git's pre-push protocol uses an all-zero object id to indicate + # branch creation (old) or deletion (new). the length depends on the + # repository's object format: 40 for sha1 (default) and 64 for sha256 + # (`git init --object-format=sha256`). + return len(oid) in (40, 64) and set(oid) == {'0'} + + def _run_legacy( hook_type: str, hook_dir: str | None, @@ -128,9 +136,9 @@ def _pre_push_ns( for line in stdin.decode().splitlines(): parts = line.rsplit(maxsplit=3) local_branch, local_sha, remote_branch, remote_sha = parts - if local_sha == Z40: + if _is_zero_oid(local_sha): continue - elif remote_sha != Z40 and _rev_exists(remote_sha): + elif not _is_zero_oid(remote_sha) and _rev_exists(remote_sha): return _ns( 'pre-push', color, from_ref=remote_sha, to_ref=local_sha, diff --git a/tests/commands/hook_impl_test.py b/tests/commands/hook_impl_test.py index 9aa93af53..e0a542a85 100644 --- a/tests/commands/hook_impl_test.py +++ b/tests/commands/hook_impl_test.py @@ -10,6 +10,7 @@ from pre_commit import git from pre_commit.commands import hook_impl from pre_commit.envcontext import envcontext +from pre_commit.util import CalledProcessError from pre_commit.util import cmd_output from pre_commit.util import make_executable from testing.fixtures import git_dir @@ -347,6 +348,86 @@ def test_run_ns_pre_push_deleting_branch(push_example): assert ns is None +def test_is_zero_oid_sha1(): + assert hook_impl._is_zero_oid('0' * 40) is True + + +def test_is_zero_oid_sha256(): + assert hook_impl._is_zero_oid('0' * 64) is True + + +@pytest.mark.parametrize( + 'oid', + ( + '', + '0' * 39, + '0' * 41, + '0' * 63, + '0' * 65, + '0' * 80, + '1' * 40, + '1' * 64, + '0' * 39 + '1', + 'a' * 40, + 'deadbeef' * 5, + 'deadbeef' * 8, + ), +) +def test_is_zero_oid_rejects_non_zero(oid): + assert hook_impl._is_zero_oid(oid) is False + + +@pytest.fixture +def sha256_push_example(tempdir_factory): + src = tempdir_factory.get() + try: + cmd_output('git', 'init', '--object-format=sha256', '--quiet', src) + except CalledProcessError: + pytest.skip('git does not support --object-format=sha256') + git_commit(cwd=src) + src_head = git.head_rev(src) + + clone = tempdir_factory.get() + cmd_output('git', 'clone', src, clone) + git_commit(cwd=clone) + clone_head = git.head_rev(clone) + return (src, src_head, clone, clone_head) + + +def test_run_ns_pre_push_sha256_new_branch(sha256_push_example): + # regression test for #3664: the hardcoded 40-char Z40 sentinel did not + # match the 64-char zero oid emitted by git on sha256 repos, causing + # branch creation to fall through to a `git diff ..<64-zeros>` + # which produced `fatal: Invalid revision range`. + src, src_head, clone, clone_head = sha256_push_example + zero64 = '0' * 64 + assert len(src_head) == 64 + + with cwd(clone): + args = ('origin', src) + stdin = f'HEAD {clone_head} refs/heads/b {zero64}\n'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is not None + assert ns.from_ref == src_head + assert ns.to_ref == clone_head + + +def test_run_ns_pre_push_sha256_deleting_branch(sha256_push_example): + # regression test for #3664: `git push origin --delete ` on a + # sha256 repo emits a 64-char zero oid for local_sha, which previously + # did not match Z40 and caused a CalledProcessError exit. + src, src_head, clone, _ = sha256_push_example + zero64 = '0' * 64 + + with cwd(clone): + args = ('origin', src) + stdin = f'(delete) {zero64} refs/heads/b {src_head}'.encode() + ns = hook_impl._run_ns('pre-push', False, args, stdin) + + assert ns is None + + def test_hook_impl_main_noop_pre_push(cap_out, store, push_example): src, src_head, clone, _ = push_example