From 76246ee229bd1bfca4c2430dedcf4e4f9b072dc8 Mon Sep 17 00:00:00 2001 From: huangruiteng Date: Thu, 10 Sep 2026 17:37:14 +0800 Subject: [PATCH] fix(change-window): allow empty Git reference transactions Signed-off-by: huangruiteng --- .../repository_change_window/git_hook.py | 5 +++ .../test_change_window_git_fast_path.py | 42 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/loopx/capabilities/repository_change_window/git_hook.py b/loopx/capabilities/repository_change_window/git_hook.py index 43e786b492..3a9fdd7efd 100644 --- a/loopx/capabilities/repository_change_window/git_hook.py +++ b/loopx/capabilities/repository_change_window/git_hook.py @@ -992,6 +992,11 @@ def _reference_transaction_introduces_commit( ReferenceTransactionPhase.PREPARED, }: return False + # Git can invoke admission hooks for a zero-update transaction (including + # Git 2.55's pull path). No refs means no new commit to guard; provider + # integrity and previous-hook delegation remain owned by the caller. + if not hook_stdin: + return False if not hook_stdin.strip(): raise RepositoryChangeWindowError( f"reference-transaction {phase.value} phase requires at least one ref update" diff --git a/tests/capabilities/test_change_window_git_fast_path.py b/tests/capabilities/test_change_window_git_fast_path.py index 21f0b49aac..f0c6424f50 100644 --- a/tests/capabilities/test_change_window_git_fast_path.py +++ b/tests/capabilities/test_change_window_git_fast_path.py @@ -35,9 +35,12 @@ def installed(tmp_path, monkeypatch): ) def git(repo, *args, check=True): - return subprocess.run( - ["git", "-C", str(repo), *args], check=check, capture_output=True, text=True + result = subprocess.run( + ["git", "-C", str(repo), *args], check=False, capture_output=True, text=True ) + if check: + assert result.returncode == 0, result.stdout + result.stderr + return result git(remote, "config", "user.name", "Synthetic User") git(remote, "config", "user.email", "user@example.invalid") @@ -163,6 +166,41 @@ def test_selected_cli_does_not_import_full_cli(installed): assert result.returncode == 0, result.stderr +@pytest.mark.parametrize("phase", ["preparing", "prepared"]) +def test_empty_transaction_is_noop_but_keeps_hook_guards( + installed, tmp_path, monkeypatch, phase +): + repo, _remote, _git, _policy = installed + + def unexpected(*args, **kwargs): + raise AssertionError("empty transaction evaluated the time policy") + + monkeypatch.setattr(owner, "evaluate_policy", unexpected) + args = { + "repo_path": repo, + "runtime_root": tmp_path / "runtime", + "event": "reference-transaction", + "hook_args": [phase], + "hook_stdin": b"", + } + result = owner.run_git_hook_provider(**args) + assert result["ok"] and result["previous_hook_invoked"] + assert not result["guarded_change"] and not result["policy_evaluated"] + assert result["decision"] is None + assert (tmp_path / "phases").read_text().splitlines() == [phase] + assert (tmp_path / "payloads").read_bytes() == b"" + # An empty batch is legal; a nonempty malformed row is not. + with pytest.raises(ValueError, match="at least one ref update"): + owner.run_git_hook_provider(**{**args, "hook_stdin": b" \n"}) + previous = tmp_path / "previous" / "reference-transaction" + previous.write_text("#!/bin/sh\nexit 7\n") + result = owner.run_git_hook_provider(**args) + assert result["status"] == "previous_hook_failed" and result["exit_code"] == 7 + managed = repo / ".git/loopx/repository-change-window/hooks/reference-transaction" + managed.write_text(managed.read_text() + "# modified\n") + assert owner.run_git_hook_provider(**args)["status"] == "provider_drift" + + def test_old_valid_hook_generation_requires_explicit_refresh(installed): repo, _remote, _git, policy = installed state_path = repo / ".git/loopx/repository-change-window/provider.json"