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:
2026-08-05 23:33:10 +02:00
co-authored by Claude Opus 5
parent 691a7acc77
commit b91707b707
3 changed files with 160 additions and 2 deletions
+26
View File
@@ -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
View File
@@ -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