diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1ec6d77..2ae0f49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,7 @@ # Pre-commit hooks — surface lint/format/secret/SAST issues locally before they reach a commit, # mirroring the CI gate (ruff, bandit, gitleaks). One-time setup per clone: # pip install pre-commit && pre-commit install +# pwsh -NoProfile -File scripts/coord/install-git-hooks.ps1 # claim gate (commit-msg) # Run across the whole tree on demand: pre-commit run --all-files # Keep hook versions roughly in step with CI (.github/workflows/security.yml) and pyproject. repos: @@ -8,6 +9,25 @@ repos: # CI/dev ruff on a version (language: system uses the installed ruff from the active venv). - repo: local hooks: + # The LEDGER GATE runs here rather than as a standalone .git/hooks/pre-commit, and that is a + # deliberate reversal — see scripts/coord/install-git-hooks.ps1 for the full reasoning. + # + # Short version: two tools cannot both own .git/hooks/pre-commit. install-git-hooks.ps1 refuses + # to overwrite a foreign hook, and `pre-commit install` responds by moving the existing hook to + # pre-commit.legacy and calling it from its own shim — which WORKS ON POSIX AND FAILS ON + # WINDOWS, because pre-commit invokes the legacy hook from a Python subprocess that cannot + # resolve `#!/bin/sh` (ExecutableNotFoundError). Measured 2026-07-27: every commit in the repo + # was blocked. Owning one file with one tool makes the contention impossible instead of managed. + # + # always_run + pass_filenames: false — the gate inspects the staged TREE (which ADR/BACKLOG + # numbers the commit introduces), not a list of changed files, so it must run even when no file + # it "owns" changed. + - id: ledger-gate + name: ledger gate (ADR/BACKLOG number reuse) + entry: python scripts/hooks/ledger_check.py + language: system + pass_filenames: false + always_run: true - id: ruff-format name: ruff format entry: ruff format diff --git a/scripts/coord/install-git-hooks.ps1 b/scripts/coord/install-git-hooks.ps1 index 89c2a32..d480b3c 100644 --- a/scripts/coord/install-git-hooks.ps1 +++ b/scripts/coord/install-git-hooks.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS - Install the ledger gate as a pre-commit hook in the SHARED .git/hooks — one copy governs every - worktree at once. + Install the CLAIM gate (commit-msg) in the SHARED .git/hooks — one copy governs every worktree at + once. The LEDGER gate is no longer installed here; it runs via .pre-commit-config.yaml. .DESCRIPTION `.git/hooks` lives in the COMMON git directory, which every linked worktree shares. So a single file @@ -17,13 +17,28 @@ the worktree gate inspects tool arguments, so a file written by a shell command is invisible to it. This is that backstop. - The hook shells to `python scripts/hooks/ledger_check.py`, which is stdlib-only and imports nothing - from messagefoundry -- most worktrees have no .venv, and a gate that silently skips is worse than no - gate at all. If python is missing entirely it prints a loud warning and lets the commit through - (fail-open); CI re-runs the same rules with --ci, so nothing reaches main unchecked. + ⭐ WHY THE LEDGER GATE MOVED OUT OF HERE (2026-07-27). It used to be installed as a standalone + .git/hooks/pre-commit. Two tools cannot both own that file: this script refused to overwrite a + foreign hook, and `pre-commit install` responded by renaming ours to pre-commit.legacy and calling + it from its own shim. That works on POSIX and FAILS ON WINDOWS -- pre-commit invokes the legacy hook + from a Python subprocess, which cannot resolve `#!/bin/sh`: - Run from a plain terminal. `git commit --no-verify` bypasses it -- that is a guardrail, not a security - boundary, and the --ci leg is the backstop for exactly that. + ExecutableNotFoundError: Executable `/bin/sh` not found + + Measured: every commit in the repo was blocked until the shim was uninstalled. Note that + pre-commit.legacy EXISTING did not indicate success -- only a real commit did. The ledger gate is + therefore a `local` hook in .pre-commit-config.yaml (id: ledger-gate) and pre-commit owns the file + alone. Running this script now MIGRATES an old install by removing our standalone hook. + + TRADE-OFF, stated because it is real: the ledger gate's availability is now coupled to pre-commit + being installed. This script warns loudly when it is not. The claim gate stays a commit-msg hook -- + only commit-msg receives the message file, so it cannot move. + + ledger_check.py is stdlib-only and imports nothing from messagefoundry, so it still runs in a + worktree with no project .venv. + + Run from a plain terminal. `git commit --no-verify` bypasses both -- that is a guardrail, not a + security boundary, and the --ci leg is the backstop for exactly that. .EXAMPLE pwsh -NoProfile -File scripts\coord\install-git-hooks.ps1 @@ -54,14 +69,25 @@ $marker = "MessageFoundry ledger gate" # commit message, so a claim check bolted onto it would look installed and silently never fire. $commitMsg = Join-Path $hooksDir "commit-msg" $claimMarker = "MessageFoundry claim gate" +$prePush = Join-Path $hooksDir "pre-push" +$pushMarker = "MessageFoundry push guard" if ($Status) { - $installed = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker)) + $stale = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker)) $claimInstalled = (Test-Path $commitMsg) -and ((Get-Content $commitMsg -Raw -EA SilentlyContinue) -match [regex]::Escape($claimMarker)) + # The ledger gate is a pre-commit hook now, so "is it armed" means "did pre-commit install its + # shim" -- reporting on OUR marker would say 'not installed' for a perfectly healthy setup. + $pcShim = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match 'File generated by pre-commit') Write-Host "hooks dir : $hooksDir" - Write-Host "pre-commit : $(if ($installed) { 'INSTALLED (ledger gate)' } elseif (Test-Path $preCommit) { 'present, but NOT ours' } else { 'not installed' })" Write-Host "commit-msg : $(if ($claimInstalled) { 'INSTALLED (claim gate)' } elseif (Test-Path $commitMsg) { 'present, but NOT ours' } else { 'not installed' })" - Write-Host "worktrees : $(@(& git -C $RepoRoot worktree list).Count) share this hook" + Write-Host "pre-commit : $(if ($pcShim) { 'pre-commit framework (carries the ledger gate + leak gate)' } elseif ($stale) { 'STALE standalone ledger hook -- re-run this script to migrate' } elseif (Test-Path $preCommit) { 'present, but NOT ours' } else { 'NOT INSTALLED -- run: pre-commit install' })" + if ($stale) { + Write-Host " ^ a leftover standalone hook will make `pre-commit install` chain to" -ForegroundColor Yellow + Write-Host " pre-commit.legacy, which FAILS on Windows and blocks every commit." -ForegroundColor Yellow + } + $pushInstalled = (Test-Path $prePush) -and ((Get-Content $prePush -Raw -EA SilentlyContinue) -match [regex]::Escape($pushMarker)) + Write-Host "pre-push : $(if ($pushInstalled) { 'INSTALLED (push guard)' } elseif (Test-Path $prePush) { 'present, but NOT ours' } else { 'NOT INSTALLED' })" + Write-Host "worktrees : $(@(& git -C $RepoRoot worktree list).Count) share these hooks" return } @@ -77,76 +103,113 @@ if ($Uninstall) { Write-Host "Claim commit-msg hook REMOVED." -ForegroundColor Yellow $removed = $true } + if ((Test-Path $prePush) -and ((Get-Content $prePush -Raw) -match [regex]::Escape($pushMarker))) { + Remove-Item -LiteralPath $prePush -Force + Write-Host "Push guard pre-push hook REMOVED." -ForegroundColor Yellow + $removed = $true + } if (-not $removed) { Write-Host "Nothing to remove (no MessageFoundry hooks installed)." } return } -if ((Test-Path $preCommit) -and ((Get-Content $preCommit -Raw) -notmatch [regex]::Escape($marker))) { - throw "A pre-commit hook that is not ours already exists at $preCommit. Refusing to overwrite it -- merge them by hand." -} if ((Test-Path $commitMsg) -and ((Get-Content $commitMsg -Raw) -notmatch [regex]::Escape($claimMarker))) { throw "A commit-msg hook that is not ours already exists at $commitMsg. Refusing to overwrite it -- merge them by hand." } +if ((Test-Path $prePush) -and ((Get-Content $prePush -Raw) -notmatch [regex]::Escape($pushMarker))) { + throw "A pre-push hook that is not ours already exists at $prePush. Refusing to overwrite it -- merge them by hand." +} New-Item -ItemType Directory -Force -Path $hooksDir | Out-Null -# Resolve the checker from the COMMON dir, not from a working tree: a worktree can be on any branch (or a -# detached HEAD), and a hook that points into one would break the moment that branch lacks the file. -Copy-Item (Join-Path $RepoRoot "scripts\hooks\ledger_check.py") (Join-Path $hooksDir "ledger_check.py") -Force +# --- MIGRATION: retire our OWN old pre-commit hook ------------------------------------------------- +# The ledger gate used to be installed here as a standalone .git/hooks/pre-commit. It now runs as a +# `local` hook inside .pre-commit-config.yaml instead. Leaving the old copy in place is NOT harmless: +# `pre-commit install` would find a foreign hook, move it to pre-commit.legacy and call it from its own +# shim -- which fails on Windows (see the note below) and blocks EVERY commit. Remove ours; never touch +# a hook that is not ours. +if ((Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match [regex]::Escape($marker))) { + Remove-Item -LiteralPath $preCommit -Force + $legacy = "$preCommit.legacy" + if ((Test-Path $legacy) -and ((Get-Content $legacy -Raw -EA SilentlyContinue) -match [regex]::Escape($marker))) { + Remove-Item -LiteralPath $legacy -Force + } + Write-Host "Migrated: the standalone ledger pre-commit hook was REMOVED." -ForegroundColor Yellow + Write-Host " The ledger gate now runs via .pre-commit-config.yaml (id: ledger-gate)." -ForegroundColor Yellow +} +Remove-Item -LiteralPath (Join-Path $hooksDir "ledger_check.py") -Force -EA SilentlyContinue -# LF endings and no BOM: git runs this through sh, which chokes on CRLF. -$hook = @' +# --- claim gate (BACKLOG #309) ------------------------------------------------------------------- +# A commit-msg hook, because only commit-msg is handed the message file (as $1). The claim gate keys off +# the SUBJECT declaring `BACKLOG #N`, so on pre-commit it would have nothing to read. +Copy-Item (Join-Path $RepoRoot "scripts\hooks\claim_check.py") (Join-Path $hooksDir "claim_check.py") -Force + +$claimHook = @' #!/bin/sh -# MessageFoundry ledger gate -- INSTALLED COPY. Source: scripts/hooks/ledger_check.py -# Lives in the SHARED .git/hooks, so ONE copy governs every worktree, survives any branch switch, and -# fires for EVERY write route (Edit tool, shell redirect, VS Code, a subagent) -- it inspects the staged -# TREE, not a tool call. +# MessageFoundry claim gate -- INSTALLED COPY. Source: scripts/hooks/claim_check.py +# commit-msg (NOT pre-commit): only this hook receives the message file, and the gate reads the subject. # Re-install after changing the source: pwsh -NoProfile -File scripts/coord/install-git-hooks.ps1 HOOK_DIR=$(dirname "$0") PY=python command -v python >/dev/null 2>&1 || PY=python3 if ! command -v "$PY" >/dev/null 2>&1; then - echo "MessageFoundry: python not found -- THE LEDGER GATE IS OFF for this commit." >&2 + echo "MessageFoundry: python not found -- THE CLAIM GATE IS OFF for this commit." >&2 exit 0 fi -exec "$PY" "$HOOK_DIR/ledger_check.py" +exec "$PY" "$HOOK_DIR/claim_check.py" "$1" '@ -replace "`r`n", "`n" -[System.IO.File]::WriteAllText($preCommit, $hook, (New-Object System.Text.UTF8Encoding $false)) +[System.IO.File]::WriteAllText($commitMsg, $claimHook, (New-Object System.Text.UTF8Encoding $false)) -# --- claim gate (BACKLOG #309) ------------------------------------------------------------------- -# A commit-msg hook, because only commit-msg is handed the message file (as $1). The claim gate keys off -# the SUBJECT declaring `BACKLOG #N`, so on pre-commit it would have nothing to read. -Copy-Item (Join-Path $RepoRoot "scripts\hooks\claim_check.py") (Join-Path $hooksDir "claim_check.py") -Force +# --- push guard ------------------------------------------------------------------------------------ +# Since the MEFORORG cutover this repo IS the published artifact -- a push to main is publication, with +# no publish step left to catch anything. Server-side protection requires a PR and 12 checks, but +# enforce_admins is false, so the owner bypasses all of it and VS Code's Sync button does not +# distinguish main from a feature branch. This restores the class of protection the old mirror clone's +# Gate-Provenance pre-push hook provided before it was quarantined at cutover. +Copy-Item (Join-Path $RepoRoot "scripts\hooks\push_guard.py") (Join-Path $hooksDir "push_guard.py") -Force -$claimHook = @' +$pushHook = @' #!/bin/sh -# MessageFoundry claim gate -- INSTALLED COPY. Source: scripts/hooks/claim_check.py -# commit-msg (NOT pre-commit): only this hook receives the message file, and the gate reads the subject. +# MessageFoundry push guard -- INSTALLED COPY. Source: scripts/hooks/push_guard.py +# Refuses a DIRECT push (or delete) of a protected branch. git feeds the refs on stdin. # Re-install after changing the source: pwsh -NoProfile -File scripts/coord/install-git-hooks.ps1 HOOK_DIR=$(dirname "$0") PY=python command -v python >/dev/null 2>&1 || PY=python3 if ! command -v "$PY" >/dev/null 2>&1; then - echo "MessageFoundry: python not found -- THE CLAIM GATE IS OFF for this commit." >&2 + echo "MessageFoundry: python not found -- THE PUSH GUARD IS OFF for this push." >&2 exit 0 fi -exec "$PY" "$HOOK_DIR/claim_check.py" "$1" +exec "$PY" "$HOOK_DIR/push_guard.py" "$@" '@ -replace "`r`n", "`n" -[System.IO.File]::WriteAllText($commitMsg, $claimHook, (New-Object System.Text.UTF8Encoding $false)) +[System.IO.File]::WriteAllText($prePush, $pushHook, (New-Object System.Text.UTF8Encoding $false)) # Git for Windows does not need the exec bit, but a WSL/Linux checkout of the same repo would. -if ($IsLinux -or $IsMacOS) { & chmod +x $preCommit; & chmod +x $commitMsg } +if ($IsLinux -or $IsMacOS) { & chmod +x $commitMsg; & chmod +x $prePush } Write-Host "" -Write-Host "MessageFoundry git hooks INSTALLED." -ForegroundColor Green -Write-Host " pre-commit: $preCommit" -Write-Host " $(Join-Path $hooksDir 'ledger_check.py') (ledger gate)" +Write-Host "MessageFoundry hooks INSTALLED." -ForegroundColor Green Write-Host " commit-msg: $commitMsg" Write-Host " $(Join-Path $hooksDir 'claim_check.py') (claim gate, BACKLOG #309)" +Write-Host " pre-push : $prePush" +Write-Host " $(Join-Path $hooksDir 'push_guard.py') (refuses a direct push to main)" Write-Host " governs : all $(@(& git -C $RepoRoot worktree list).Count) worktree(s) of this repo, immediately" Write-Host "" + +# The ledger gate now rides on pre-commit. If pre-commit is not installed, say so LOUDLY -- a gate that +# is simply absent looks identical to one that passed. +$pcInstalled = (Test-Path $preCommit) -and ((Get-Content $preCommit -Raw -EA SilentlyContinue) -match 'File generated by pre-commit') +if ($pcInstalled) { + Write-Host "Ledger gate: ARMED via .pre-commit-config.yaml (id: ledger-gate)." -ForegroundColor Green +} else { + Write-Host "!! LEDGER GATE IS NOT ARMED." -ForegroundColor Red + Write-Host " It runs as a pre-commit hook now, and pre-commit is not installed in this repo." -ForegroundColor Red + Write-Host " Nothing is stopping two sessions colliding on an ADR/BACKLOG number. Fix with:" -ForegroundColor Red + Write-Host " pip install pre-commit" -ForegroundColor Red + Write-Host " pre-commit install" -ForegroundColor Red + Write-Host "" +} Write-Host "Ledger gate: blocks a commit that reuses an ADR/BACKLOG number, or adds an ADR with no index row." Write-Host "Claim gate : blocks a CODE commit whose subject says 'BACKLOG #N' unless THIS worktree claims N," Write-Host " so two sessions cannot build the same item in parallel. Docs-only commits pass." diff --git a/scripts/hooks/push_guard.py b/scripts/hooks/push_guard.py new file mode 100644 index 0000000..d9da0de --- /dev/null +++ b/scripts/hooks/push_guard.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Push guard — refuse a DIRECT push to a protected branch. + +THE DEFECT THIS EXISTS FOR. Since the MEFORORG cutover this repository IS the published artifact: +there is no publish step left between a push and the public internet. A push to ``main`` is therefore +publication, immediately and irreversibly (deleting a ref later does not un-publish content that was +fetched, mirrored or indexed in between). + +Branch protection on the server requires a PR and 12 status checks -- but ``enforce_admins`` is false, +so the repository owner BYPASSES all of it. The realistic trigger is not malice, it is one click: +VS Code's Sync/Push button does not distinguish "my feature branch" from "main", and the editor is +where most pushes originate. + +This is the guard the old mirror clone's Gate-Provenance pre-push hook used to provide. That clone was +quarantined at cutover, and nothing replaced it. + +WHAT THIS IS NOT. A guardrail, not a security boundary: ``git push --no-verify`` skips it, and it is +local-only, so a different machine is unprotected. It removes the ACCIDENT, not the capability. The +durable server-side fix is ``enforce_admins=true``, which is deliberately NOT in place yet because a +known flaky test (BACKLOG #17) has blocked two consecutive PRs, and removing the override while a +flake can strand a merge trades an accidental-push risk for a cannot-ship risk. + +Stdlib only, like the other gates -- most worktrees have no project .venv. + +git hands a pre-push hook one line per ref on STDIN: + +A deletion has an all-zero local sha; it is refused too, since deleting main is worse than pushing to +it. Exit 0 allows the push, 1 refuses it. +""" + +from __future__ import annotations + +import os +import sys + +#: Refuse a direct push to any of these. `main` is the published branch; `cla-signatures` holds the +#: CLA Assistant's signature store and is written by the action, never by a human. +PROTECTED = frozenset({"refs/heads/main", "refs/heads/cla-signatures"}) + +_ZERO = "0" * 40 + + +def main(argv: list[str]) -> int: + # Escape hatch for the rare legitimate case, distinct from --no-verify so it is greppable in + # history and cannot be set by muscle memory. + if os.environ.get("MEFOR_ALLOW_DIRECT_PUSH") == "1": + print("push_guard: MEFOR_ALLOW_DIRECT_PUSH=1 — direct push ALLOWED.", file=sys.stderr) + return 0 + + offenders: list[tuple[str, bool]] = [] + for line in sys.stdin: + parts = line.split() + if len(parts) != 4: + continue + local_sha, remote_ref = parts[1], parts[2] + if remote_ref in PROTECTED: + offenders.append((remote_ref, local_sha.strip("0") == "")) + + if not offenders: + return 0 + + print("", file=sys.stderr) + print("MessageFoundry push guard — REFUSED", file=sys.stderr) + for ref, is_delete in offenders: + what = "DELETE" if is_delete else "direct push" + print(f" {what} to {ref}", file=sys.stderr) + print("", file=sys.stderr) + print( + " This repo IS the published artifact — a push to main is publication, immediately, and\n" + " cannot be taken back. Server-side branch protection requires a PR and 12 checks, but\n" + " enforce_admins is false, so the owner bypasses it. Nothing else would have stopped this.\n" + "\n" + " Push a branch and open a PR instead:\n" + " git switch -c && git push -u origin \n" + "\n" + " If you genuinely mean it: MEFOR_ALLOW_DIRECT_PUSH=1 git push ...", + file=sys.stderr, + ) + print("", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/test_ledger_check.py b/tests/test_ledger_check.py index 667d127..988d8cc 100644 --- a/tests/test_ledger_check.py +++ b/tests/test_ledger_check.py @@ -370,3 +370,62 @@ def test_ci_mode_skips_the_ownership_rule_but_still_catches_a_reused_number(repo code, out = run_check(repo, "--ci") assert code == 1 assert "ADR 0001 already exists" in out + + +# -------------------------------------------------------------------------------------------------- +# WIRING. Everything above tests the gate's LOGIC against a throwaway repo. None of it notices if the +# gate is never invoked -- and on 2026-07-27 that is exactly what happened: the ledger gate had to move +# out of .git/hooks/pre-commit because `pre-commit install` and install-git-hooks.ps1 both want that +# file, and their chaining fails on Windows. Logic tests stayed green throughout. These assert the gate +# is actually WIRED UP, which is the property that was silently lost. +# -------------------------------------------------------------------------------------------------- + +_CONFIG = Path(__file__).resolve().parents[1] / ".pre-commit-config.yaml" +_INSTALLER = Path(__file__).resolve().parents[1] / "scripts" / "coord" / "install-git-hooks.ps1" + + +def _ledger_hook() -> dict[str, object]: + """The ledger-gate entry from .pre-commit-config.yaml, or fail loudly. + + Plain import, deliberately NOT pytest.importorskip: pyyaml is pinned in requirements.lock and + constraints.lock, so it is always present where CI runs. importorskip would turn a missing + dependency into a silent SKIP — and a wiring test that skips is exactly the failure this test + exists to catch. + """ + import yaml + + cfg = yaml.safe_load(_CONFIG.read_text(encoding="utf-8")) + for repo in cfg["repos"]: + for hook in repo.get("hooks", []): + if hook.get("id") == "ledger-gate": + return hook + raise AssertionError( + "no 'ledger-gate' hook in .pre-commit-config.yaml — the ledger gate is NOT wired up, and every " + "logic test above still passes" + ) + + +def test_the_ledger_gate_is_wired_into_pre_commit() -> None: + hook = _ledger_hook() + assert "ledger_check.py" in str(hook["entry"]), hook["entry"] + # It inspects the staged TREE (which ADR/BACKLOG numbers the commit introduces), not a file list, + # so it must run even when no file it "owns" changed. Without always_run a commit that touches only + # unrelated files would skip the gate entirely. + assert hook.get("always_run") is True, "ledger-gate must be always_run" + assert hook.get("pass_filenames") is False, "ledger-gate must not be given a file list" + + +def test_the_installer_no_longer_writes_a_pre_commit_hook() -> None: + """The contention must stay impossible, not merely resolved once. + + If install-git-hooks.ps1 starts writing .git/hooks/pre-commit again, the next `pre-commit install` + chains to pre-commit.legacy and — on Windows — blocks every commit in the repo. + """ + src = _INSTALLER.read_text(encoding="utf-8") + assert "WriteAllText($preCommit" not in src, ( + "install-git-hooks.ps1 writes a pre-commit hook again — that re-creates the two-owner conflict" + ) + # ...and it must still MIGRATE an old standalone install away, or upgrading users stay broken. + assert "Remove-Item -LiteralPath $preCommit" in src, ( + "the installer must remove a previously-installed standalone ledger hook" + ) diff --git a/tests/test_push_guard.py b/tests/test_push_guard.py new file mode 100644 index 0000000..6a3f254 --- /dev/null +++ b/tests/test_push_guard.py @@ -0,0 +1,109 @@ +"""Tests for the push guard (scripts/hooks/push_guard.py). + +Since the MEFORORG cutover this repository IS the published artifact — a push to ``main`` is +publication, immediately, with no publish step left to catch anything. Server-side branch protection +requires a PR and 12 status checks, but ``enforce_admins`` is false, so the repository owner bypasses +all of it. The realistic trigger is one click on VS Code's Sync button while the current branch happens +to be ``main``. + +Every test feeds the script the EXACT stdin contract git uses for a pre-push hook — +`` ``, one line per ref — so what is asserted is the +interface git will actually invoke, not a convenience wrapper. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +_GUARD = _REPO / "scripts" / "hooks" / "push_guard.py" +_INSTALLER = _REPO / "scripts" / "coord" / "install-git-hooks.ps1" + +_SHA = "a" * 40 +_ZERO = "0" * 40 + + +def _run(refs: str, env_extra: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]: + env = None + if env_extra: + import os + + env = {**os.environ, **env_extra} + return subprocess.run( + [sys.executable, str(_GUARD)], + input=refs, + capture_output=True, + text=True, + env=env, + check=False, + ) + + +def test_a_direct_push_to_main_is_refused() -> None: + r = _run(f"refs/heads/x {_SHA} refs/heads/main {_ZERO}\n") + assert r.returncode == 1, r.stderr + assert "REFUSED" in r.stderr + + +def test_deleting_main_is_refused() -> None: + """A deletion has an all-zero LOCAL sha. Deleting main is worse than pushing to it.""" + r = _run(f"refs/heads/x {_ZERO} refs/heads/main {_SHA}\n") + assert r.returncode == 1, r.stderr + assert "DELETE" in r.stderr + + +def test_the_cla_signature_branch_is_protected() -> None: + """Written by the CLA Assistant action, never by a human — and every PR wedges if it is damaged.""" + r = _run(f"refs/heads/x {_SHA} refs/heads/cla-signatures {_ZERO}\n") + assert r.returncode == 1, r.stderr + + +def test_an_ordinary_branch_push_is_allowed() -> None: + """The control. Without this, a guard that refused EVERYTHING would look identical to a working one.""" + r = _run(f"refs/heads/x {_SHA} refs/heads/feature-y {_ZERO}\n") + assert r.returncode == 0, r.stderr + assert r.stderr.strip() == "" + + +def test_a_mixed_push_is_refused_because_one_ref_is_protected() -> None: + """`git push --all` sends every ref at once; one protected ref must sink the whole push.""" + r = _run( + f"refs/heads/a {_SHA} refs/heads/feature-y {_ZERO}\n" + f"refs/heads/b {_SHA} refs/heads/main {_ZERO}\n" + ) + assert r.returncode == 1, r.stderr + + +def test_the_escape_hatch_works_and_says_so() -> None: + """Deliberately NOT --no-verify: a distinct variable is greppable in shell history, and cannot be + reached by the muscle memory that skips every other hook at once.""" + r = _run( + f"refs/heads/x {_SHA} refs/heads/main {_ZERO}\n", + {"MEFOR_ALLOW_DIRECT_PUSH": "1"}, + ) + assert r.returncode == 0, r.stderr + assert "ALLOWED" in r.stderr + + +def test_malformed_stdin_does_not_crash_the_push() -> None: + """git sends nothing at all for some invocations. Fail OPEN on garbage rather than wedging pushes — + the guard exists to stop an accident, and crashing on unexpected input would BE one.""" + r = _run("not four fields\n\n") + assert r.returncode == 0, r.stderr + + +# -------------------------------------------------------------------------------------------------- +# WIRING. The tests above prove the logic. None of them notices if nothing ever installs the hook — +# the same gap that let the ledger gate sit silently un-invoked (see test_ledger_check.py). +# -------------------------------------------------------------------------------------------------- + + +def test_the_installer_wires_the_push_guard() -> None: + src = _INSTALLER.read_text(encoding="utf-8") + assert "push_guard.py" in src, "installer never copies the guard into .git/hooks" + assert "WriteAllText($prePush" in src, "installer never writes the pre-push hook" + # It must refuse to clobber someone else's pre-push hook, and remove its own on -Uninstall. + assert "Refusing to overwrite it" in src + assert "Remove-Item -LiteralPath $prePush" in src, "-Uninstall must remove the push guard"