fix(719): exempt tag-only pushes from the H11 branch-freshness check
H11 (.claude/hooks/prepush-rebase-check.sh) refuses to push a branch that is behind origin/main. It fired on tag-only pushes too, breaking every release cut: docs/ci-cd.md's "Cutting a release" flow lands a release-notes commit via PR and then tags that merge commit, so the local branch is always one commit behind origin/main at tag time. A tag push cannot revert anyone's merged work, which is the failure H11 exists to prevent, so skip the freshness check when every ref being pushed is under refs/tags/. .husky/pre-push previously consumed pre-push's stdin ref lines and forwarded them only to prepush-donewhen.sh; prepush-rebase-check.sh got none. Forward the captured $_prepush_refs to it too, or the new logic is dead. Guard against the vacuous-truth case explicitly required by #719: "all pushed refs are tags" is trivially true over zero ref lines (manual run, forgotten forwarding), which would silently disable H11 for every push. Require at least one parsed ref line before granting the exemption. Adds scripts/tests/test_prepush_rebase_check_tag_exemption.py using real local git repos (bare origin + a work tree pushed one commit behind it) to exercise git fetch/merge-base/rev-list against a genuinely-moved origin: tag-only allowed, branch-only still blocked, mixed branch+tag still blocked, and zero ref lines still blocked (the vacuous-truth guard). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,32 @@ set -uo pipefail
|
||||
[ "${ETV_SKIP_REBASE_CHECK:-}" = "1" ] && exit 0
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || exit 0
|
||||
|
||||
# Tag-only push exemption (ersatztv#719): a release cut tags the merge commit of a just-merged
|
||||
# release-notes PR (docs/ci-cd.md -> "Cutting a release"), so the local branch is ALWAYS 1 commit
|
||||
# behind origin/main at tag time -- H11 blocked every release. A tag push cannot revert anyone's
|
||||
# merged work, which is the failure mode H11 exists to prevent, so skip the freshness check when
|
||||
# EVERY ref being pushed is under refs/tags/.
|
||||
#
|
||||
# Read pushed refs from stdin: git feeds pre-push hooks one line per ref, "<local ref> <local sha>
|
||||
# <remote ref> <remote sha>" (.husky/pre-push forwards the lines it already captured). Ignore blank
|
||||
# lines. VACUOUS-TRUTH GUARD: "all refs are tags" is trivially true when there are zero ref lines
|
||||
# (hook run manually, stdin not forwarded, etc.) -- that would silently disable H11 for every push.
|
||||
# Require at least one parsed ref line before granting the exemption; with zero lines, fall through
|
||||
# to the existing branch-freshness check below (current behavior preserved).
|
||||
_h11_refs_seen=0
|
||||
_h11_all_tags=1
|
||||
while IFS=' ' read -r _h11_local_ref _h11_local_sha _h11_remote_ref _h11_remote_sha; do
|
||||
[ -z "${_h11_local_ref:-}" ] && continue
|
||||
_h11_refs_seen=1
|
||||
case "${_h11_remote_ref:-}" in
|
||||
refs/tags/*) ;;
|
||||
*) _h11_all_tags=0 ;;
|
||||
esac
|
||||
done
|
||||
if [ "$_h11_refs_seen" = "1" ] && [ "$_h11_all_tags" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Best-effort fetch of the latest main; offline / no network -> don't block.
|
||||
git fetch origin main --quiet 2>/dev/null || exit 0
|
||||
git rev-parse --verify --quiet origin/main >/dev/null 2>&1 || exit 0
|
||||
|
||||
+3
-2
@@ -12,8 +12,9 @@ unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
|
||||
|
||||
# H11 (ersatztv#311): refuse to push a branch that is BEHIND origin/main — rebase, don't merge
|
||||
# main in (a merge drags in files you never touched, e.g. legacy-BOM .cs, and trips the format
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1.
|
||||
./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
# hook on code that isn't yours). Fail-open; escape with ETV_SKIP_REBASE_CHECK=1. Exempts a
|
||||
# tag-only push (ersatztv#719) — forward the ref lines captured above so it can tell.
|
||||
printf '%s\n' "$_prepush_refs" | ./.claude/hooks/prepush-rebase-check.sh || exit 1
|
||||
|
||||
# H13 (ersatztv#416 session): refuse to push when a file in the pushed diff still has uncommitted
|
||||
# working-tree/index changes — the pushed commit wouldn't match what you built/reviewed (the #416
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for the tag-only-push exemption in `.claude/hooks/prepush-rebase-check.sh` (ersatztv#719).
|
||||
|
||||
H11 refuses to push a branch that is behind `origin/main`, to force a rebase instead of a merge.
|
||||
But the documented release flow (`docs/ci-cd.md` -> "Cutting a release") tags the merge commit of a
|
||||
just-merged release-notes PR, so the local branch is *always* one commit behind `origin/main` at tag
|
||||
time -- H11 blocked every release cut. A tag push cannot revert anyone's merged work (the failure
|
||||
mode H11 exists to prevent), so the fix skips the freshness check when EVERY ref being pushed is
|
||||
under `refs/tags/`.
|
||||
|
||||
These tests use real local git repositories (a bare "origin" plus a work tree pushed one commit
|
||||
behind it) rather than stubbing `git`, because the hook's decision hinges on genuine
|
||||
`git fetch` / `merge-base` / `rev-list` behavior against an origin that has moved.
|
||||
|
||||
`test_zero_ref_lines_does_not_exempt` is the load-bearing negative case from the issue: "all pushed
|
||||
refs are tags" is vacuously true over zero ref lines, so a naive implementation would disable H11
|
||||
entirely whenever stdin is empty (hook run manually, or a caller that forgot to forward it). The fix
|
||||
must require at least one parsed ref line before granting the exemption.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
HOOK = REPO_ROOT / ".claude" / "hooks" / "prepush-rebase-check.sh"
|
||||
|
||||
DUMMY_SHA_A = "a" * 40
|
||||
DUMMY_SHA_B = "b" * 40
|
||||
|
||||
|
||||
def _git(args, cwd):
|
||||
r = subprocess.run(["git", *args], cwd=str(cwd), capture_output=True, text=True)
|
||||
assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}"
|
||||
return r.stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def behind_repo(tmp_path):
|
||||
"""A work tree whose local `main` is exactly one commit behind `origin/main`."""
|
||||
origin = tmp_path / "origin.git"
|
||||
_git(["init", "--bare", "-q", str(origin)], cwd=tmp_path)
|
||||
|
||||
work = tmp_path / "work"
|
||||
_git(["init", "-q", "-b", "main", str(work)], cwd=tmp_path)
|
||||
_git(["config", "user.email", "test@example.com"], cwd=work)
|
||||
_git(["config", "user.name", "Test"], cwd=work)
|
||||
(work / "f.txt").write_text("one\n")
|
||||
_git(["add", "f.txt"], cwd=work)
|
||||
_git(["commit", "-q", "-m", "initial"], cwd=work)
|
||||
_git(["remote", "add", "origin", str(origin)], cwd=work)
|
||||
_git(["push", "-q", "-u", "origin", "main"], cwd=work)
|
||||
# The bare repo's HEAD symref still points at the (nonexistent) default branch until something
|
||||
# sets it explicitly; without this, `git clone` below checks out an unborn HEAD and "main" never
|
||||
# exists as a local branch in `advancer`.
|
||||
_git(["symbolic-ref", "HEAD", "refs/heads/main"], cwd=origin)
|
||||
|
||||
# Advance origin/main independently, via a second clone, so `work`'s local `main` falls behind.
|
||||
advancer = tmp_path / "advancer"
|
||||
_git(["clone", "-q", str(origin), str(advancer)], cwd=tmp_path)
|
||||
_git(["config", "user.email", "test@example.com"], cwd=advancer)
|
||||
_git(["config", "user.name", "Test"], cwd=advancer)
|
||||
(advancer / "f.txt").write_text("two\n")
|
||||
_git(["add", "f.txt"], cwd=advancer)
|
||||
_git(["commit", "-q", "-m", "advance"], cwd=advancer)
|
||||
_git(["push", "-q", "origin", "main"], cwd=advancer)
|
||||
|
||||
return work
|
||||
|
||||
|
||||
def _run_hook(cwd, stdin_text):
|
||||
env = dict(os.environ)
|
||||
for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
|
||||
env.pop(k, None)
|
||||
return subprocess.run(
|
||||
["bash", str(HOOK)],
|
||||
cwd=str(cwd),
|
||||
input=stdin_text,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def test_tag_only_push_from_a_behind_branch_is_allowed(behind_repo):
|
||||
"""The fix: a tag-only push must not be blocked by H11 even though the branch is behind."""
|
||||
stdin = f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 0, f"tag-only push was blocked: {r.stdout}{r.stderr}"
|
||||
|
||||
|
||||
def test_tag_only_push_ignores_blank_lines(behind_repo):
|
||||
stdin = f"\nrefs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n\n"
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 0, f"tag-only push (with blank lines) was blocked: {r.stdout}{r.stderr}"
|
||||
|
||||
|
||||
def test_negative_control_branch_push_from_behind_is_still_blocked(behind_repo):
|
||||
"""Required by #719: the fix must not weaken H11 for ordinary branch pushes."""
|
||||
stdin = f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 1, "a branch push from a behind branch was allowed"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_mixed_branch_and_tag_push_is_still_blocked(behind_repo):
|
||||
stdin = (
|
||||
f"refs/heads/feature {DUMMY_SHA_A} refs/heads/feature {DUMMY_SHA_B}\n"
|
||||
f"refs/tags/v1.0.0 {DUMMY_SHA_A} refs/tags/v1.0.0 {DUMMY_SHA_B}\n"
|
||||
)
|
||||
r = _run_hook(behind_repo, stdin)
|
||||
assert r.returncode == 1, "a mixed branch+tag push was allowed through the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_zero_ref_lines_does_not_exempt(behind_repo):
|
||||
"""Vacuous-truth guard: 'all refs are tags' is trivially true over zero lines. Empty stdin
|
||||
(hook run manually, or a caller that forgot to forward the ref lines) must fall through to the
|
||||
existing behind-origin/main check, not silently disable H11."""
|
||||
r = _run_hook(behind_repo, "")
|
||||
assert r.returncode == 1, "empty stdin vacuously granted the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
|
||||
|
||||
def test_zero_ref_lines_of_only_blank_lines_does_not_exempt(behind_repo):
|
||||
r = _run_hook(behind_repo, "\n\n\n")
|
||||
assert r.returncode == 1, "stdin of only blank lines vacuously granted the tag exemption"
|
||||
assert "H11" in r.stdout
|
||||
Reference in New Issue
Block a user