From 8b7105e20c8e52919e04bf0ddfc9a65371c7c5d9 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 17 Oct 2026 16:45:28 +0200 Subject: [PATCH] gate_stamp: split `full` into <60 s calls with `full --list` + `step ` `full` ran every manual-stage hook in one pre-commit call, which takes over 60 s in screen-locker and leetcode-guard. `step ` runs one manual hook on --all-files and records " " in $GIT_DIR/gate-steps (per-worktree); the step that completes the set writes the stamp exactly as `write` does. Dirty trees and non-manual ids are refused with exit 2. Hook ids come from scripts/gate_stamp_hooks.py (PyYAML, /usr/bin/python3), which treats an absent default_stages as "every stage", matching pre-commit, so the steps cover what `full` runs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp --- scripts/gate_stamp.sh | 92 +++++++++++++- scripts/gate_stamp_hooks.py | 76 ++++++++++++ scripts/tests/test_gate_stamp.py | 201 +++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 3 deletions(-) create mode 100755 scripts/gate_stamp_hooks.py create mode 100644 scripts/tests/test_gate_stamp.py diff --git a/scripts/gate_stamp.sh b/scripts/gate_stamp.sh index 18b384b..aed8f5c 100755 --- a/scripts/gate_stamp.sh +++ b/scripts/gate_stamp.sh @@ -15,7 +15,19 @@ # gate + commit; refuses a dirty tree unless told the gate # saw the working copy (narrow staging leaves other files) # full run the repo's manual-stage hooks (the old pre-push set) -# on --all-files, then write; the on-demand heavy gate +# on --all-files in ONE call, then write; >60 s in the +# bigger repos, so prefer the split form below +# full --list print the manual-stage hook ids, one per line +# step run one manual-stage hook on --all-files and record +# " " in $GIT_DIR/gate-steps; once every +# id from `full --list` is recorded for the current tree, +# write the stamp exactly as `write` does. Each call stays +# under the 60 s per-command cap: +# for id in $(gate_stamp.sh full --list); do +# gate_stamp.sh step "$id"; done # one per call +# +# Manual-stage ids come from scripts/gate_stamp_hooks.py (PyYAML under +# /usr/bin/python3), which parses .pre-commit-config.yaml. # # The heavy suites still run in CI on every push; this only decides whether a # tree may leave the machine, not whether it is fully green. @@ -25,9 +37,13 @@ set -euo pipefail SCRIPT_NAME="$(basename "$0")" readonly SCRIPT_NAME +# Resolved before main() cd's to the toplevel, so a relative $0 still works. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly HOOKS_HELPER="$SCRIPT_DIR/gate_stamp_hooks.py" usage() { - echo "Usage: $SCRIPT_NAME check | write [--dirty-ok] | full" + echo "Usage: $SCRIPT_NAME check | write [--dirty-ok] | full [--list] | step " exit 2 } @@ -35,6 +51,10 @@ stamp_path() { printf '%s/gate-stamp\n' "$(git rev-parse --git-dir)" } +steps_path() { + printf '%s/gate-steps\n' "$(git rev-parse --git-dir)" +} + tree_of() { git rev-parse --verify --quiet "$1^{tree}" } @@ -70,7 +90,9 @@ cmd_check() { echo " no stamp in $(stamp_path)" fi echo " Either: ~/.claude/scripts/finish_auto.sh (gate + commit + push)" - echo " or: scripts/check_gate_stamp.sh full (old pre-push suites, then push again)" + echo " or: scripts/check_gate_stamp.sh full --list (manual-stage hook ids), then" + echo " scripts/check_gate_stamp.sh step once per id, one call each;" + echo " the last passing step stamps the tree, then push again" } >&2 exit 1 } @@ -94,12 +116,75 @@ cmd_write() { fi } +manual_ids() { + /usr/bin/python3 "$HOOKS_HELPER" .pre-commit-config.yaml +} + cmd_full() { + if [[ "${1:-}" == "--list" ]]; then + manual_ids + return 0 + fi + [[ $# -eq 0 ]] || usage + echo "gate-stamp: hint: split this into <60 s calls with 'full --list' + 'step '" echo "gate-stamp: running the manual-stage hooks on all files (the old pre-push set)" pre-commit run --hook-stage manual --all-files cmd_write } +# Refusals exit 2 (usage-class), unlike write's exit 1: nothing was run. +step_refuse() { + echo "$SCRIPT_NAME step: $*" >&2 + exit 2 +} + +cmd_step() { + [[ $# -eq 1 && -n "$1" ]] || usage + local hook="$1" ids + ids="$(manual_ids)" + grep -Fxq -- "$hook" <<<"$ids" \ + || step_refuse "'$hook' is not a manual-stage hook (see: $SCRIPT_NAME full --list)" + [[ -z "$(git status --porcelain --untracked-files=no)" ]] \ + || step_refuse "working tree differs from HEAD — commit first, then run steps." + local tree + tree="$(tree_of HEAD)" + pre-commit run --hook-stage manual "$hook" --all-files + [[ -z "$(git status --porcelain --untracked-files=no)" && "$(tree_of HEAD)" == "$tree" ]] \ + || step_refuse "'$hook' passed but the tree changed while it ran; not recorded." + record_step "$tree" "$hook" + local missing id + missing="$(missing_steps "$tree" "$ids")" + if [[ -z "$missing" ]]; then + echo "gate-stamp: every manual-stage hook passed on tree ${tree:0:12}; stamping" + cmd_write + else + echo "gate-stamp: '$hook' recorded for tree ${tree:0:12}; still missing:" + while IFS= read -r id; do echo " $id"; done <<<"$missing" + fi +} + +# Keep only the current tree's records: older trees can never be stamped +# again from here, and this bounds the file to one line per manual hook. +record_step() { + local tree="$1" hook="$2" path kept + path="$(steps_path)" + kept="$(grep -s -- "^$tree " "$path" || true)" + { + [[ -n "$kept" ]] && printf '%s\n' "$kept" + grep -Fxq -- "$tree $hook" <<<"$kept" || printf '%s %s\n' "$tree" "$hook" + } > "$path.tmp" + mv -f "$path.tmp" "$path" +} + +missing_steps() { + local tree="$1" ids="$2" path id + path="$(steps_path)" + while IFS= read -r id; do + [[ -n "$id" ]] || continue + grep -Fxqs -- "$tree $id" "$path" || printf '%s\n' "$id" + done <<<"$ids" +} + main() { local cmd="${1:-}" [[ -n "$cmd" ]] || usage @@ -109,6 +194,7 @@ main() { check) cmd_check "$@" ;; write) cmd_write "$@" ;; full) cmd_full "$@" ;; + step) cmd_step "$@" ;; *) usage ;; esac } diff --git a/scripts/gate_stamp_hooks.py b/scripts/gate_stamp_hooks.py new file mode 100755 index 0000000..52f9380 --- /dev/null +++ b/scripts/gate_stamp_hooks.py @@ -0,0 +1,76 @@ +#!/usr/bin/python3 +"""Print the manual-stage hook ids of a .pre-commit-config.yaml, one per line. + +Used by gate_stamp.sh (`full --list`, `step `) so the heavy gate can be +split into one hook per call. A hook is manual-stage when its `stages` list +contains `manual`, or when it has no `stages` key and the top-level +`default_stages` includes `manual`. A missing `default_stages` means "every +stage" to pre-commit, so those hooks count too: otherwise `step` could stamp a +tree that `full` (`--hook-stage manual`) would have run more hooks on. + +Ids are de-duplicated in first-seen order: `pre-commit run ` already runs +every hook sharing that id, so one step covers them all. + +Limitation: `stages` declared only in a remote repo's .pre-commit-hooks.yaml +manifest is invisible here; set it in the config to have it counted. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import yaml + +MANUAL = "manual" + + +def _as_list(value: Any) -> list[str]: + """Normalise a YAML `stages`-like value to a list of strings.""" + if value is None: + return [] + if isinstance(value, str): + return [value] + return [str(item) for item in value] + + +def manual_hook_ids(config: dict[str, Any]) -> list[str]: + """Return the ids that `pre-commit run --hook-stage manual` would run.""" + default_has_manual = "default_stages" not in config or MANUAL in _as_list( + config.get("default_stages") + ) + ids: list[str] = [] + for repo in config.get("repos") or []: + for hook in repo.get("hooks") or []: + if "stages" in hook: + is_manual = MANUAL in _as_list(hook["stages"]) + else: + is_manual = default_has_manual + hook_id = str(hook.get("id", "")) + if is_manual and hook_id and hook_id not in ids: + ids.append(hook_id) + return ids + + +def main(argv: list[str]) -> int: + """Entry point: `gate_stamp_hooks.py `.""" + if len(argv) != 1: + sys.stderr.write("usage: gate_stamp_hooks.py <.pre-commit-config.yaml>\n") + return 2 + path = Path(argv[0]) + try: + config = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, yaml.YAMLError) as exc: + sys.stderr.write(f"gate_stamp_hooks: cannot read {path}: {exc}\n") + return 1 + if not isinstance(config, dict): + sys.stderr.write(f"gate_stamp_hooks: {path} is not a mapping\n") + return 1 + for hook_id in manual_hook_ids(config): + sys.stdout.write(f"{hook_id}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/tests/test_gate_stamp.py b/scripts/tests/test_gate_stamp.py new file mode 100644 index 0000000..4f5f08e --- /dev/null +++ b/scripts/tests/test_gate_stamp.py @@ -0,0 +1,201 @@ +"""gate_stamp.sh step / full --list: the heavy gate split into <60 s calls. + +Each test builds a throwaway git repo whose manual-stage hooks are +`language: system` with `entry: true` / `entry: false`, so pre-commit runs +offline in well under a second and the only thing under test is the stamp +bookkeeping. Every hook is `always_run` -- a hook with no matching files is +"Skipped" with exit 0, which would let `entry: false` pass. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "gate_stamp.sh" + +CONFIG = """\ +default_stages: [pre-commit] +repos: + - repo: local + hooks: + - id: ok-a + name: ok a + entry: "true" + language: system + pass_filenames: false + always_run: true + stages: [manual] + - id: ok-b + name: ok b + entry: "true" + language: system + pass_filenames: false + always_run: true + stages: [pre-push, manual] + - id: bad + name: bad + entry: "false" + language: system + pass_filenames: false + always_run: true + stages: [manual] + - id: commit-only + name: commit only + entry: "true" + language: system + pass_filenames: false + always_run: true +""" + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + +def _gate(repo: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(SCRIPT), *args], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + +def _make_repo(path: Path, config: str) -> Path: + path.mkdir() + _git(path, "init", "-q", "-b", "main") + _git(path, "config", "user.name", "t") + _git(path, "config", "user.email", "t@example.invalid") + (path / ".pre-commit-config.yaml").write_text(config, encoding="utf-8") + (path / "f.txt").write_text("x\n", encoding="utf-8") + _git(path, "add", ".") + _git(path, "commit", "-q", "-m", "init") + return path + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + return _make_repo(tmp_path / "r", CONFIG) + + +def _git_dir(repo: Path) -> Path: + return (repo / _git(repo, "rev-parse", "--git-dir")).resolve() + + +def _tree(repo: Path) -> str: + return _git(repo, "rev-parse", "HEAD^{tree}") + + +def test_full_list_prints_manual_ids_only(repo: Path) -> None: + res = _gate(repo, "full", "--list") + assert res.returncode == 0, res.stderr + assert res.stdout.splitlines() == ["ok-a", "ok-b", "bad"] + + +def test_list_counts_unstaged_hooks_when_default_stages_is_absent( + tmp_path: Path, +) -> None: + # pre-commit treats a missing default_stages as "every stage", so a hook + # without `stages` runs under --hook-stage manual and must be a step too. + config = CONFIG.replace("default_stages: [pre-commit]\n", "") + r = _make_repo(tmp_path / "nodefault", config) + res = _gate(r, "full", "--list") + assert res.stdout.splitlines() == ["ok-a", "ok-b", "bad", "commit-only"] + + +def test_list_counts_unstaged_hooks_when_default_stages_has_manual( + tmp_path: Path, +) -> None: + config = CONFIG.replace("[pre-commit]", "[pre-commit, manual]") + r = _make_repo(tmp_path / "withmanual", config) + res = _gate(r, "full", "--list") + assert "commit-only" in res.stdout.splitlines() + + +def test_step_records_and_reports_missing_ids(repo: Path) -> None: + res = _gate(repo, "step", "ok-a") + assert res.returncode == 0, res.stdout + res.stderr + steps = (_git_dir(repo) / "gate-steps").read_text(encoding="utf-8") + assert f"{_tree(repo)} ok-a" in steps.splitlines() + assert "ok-b" in res.stdout + assert "bad" in res.stdout + assert not (_git_dir(repo) / "gate-stamp").exists() + + +def test_rerunning_a_step_does_not_duplicate_its_record(repo: Path) -> None: + _gate(repo, "step", "ok-a") + _gate(repo, "step", "ok-a") + steps = (_git_dir(repo) / "gate-steps").read_text(encoding="utf-8") + assert steps.splitlines().count(f"{_tree(repo)} ok-a") == 1 + + +def test_a_failing_step_records_nothing(repo: Path) -> None: + res = _gate(repo, "step", "bad") + assert res.returncode != 0 + steps = _git_dir(repo) / "gate-steps" + assert not steps.exists() or "bad" not in steps.read_text(encoding="utf-8") + assert not (_git_dir(repo) / "gate-stamp").exists() + + +def test_stamp_appears_only_after_every_manual_id_passes(tmp_path: Path) -> None: + config = CONFIG.replace('entry: "false"', 'entry: "true"') + r = _make_repo(tmp_path / "allgreen", config) + stamp = _git_dir(r) / "gate-stamp" + for hook in ("ok-a", "ok-b"): + assert _gate(r, "step", hook).returncode == 0 + assert not stamp.exists() + res = _gate(r, "step", "bad") + assert res.returncode == 0, res.stdout + res.stderr + assert stamp.read_text(encoding="utf-8").split()[0] == _tree(r) + assert "recorded tree" in res.stdout + assert _gate(r, "check").returncode == 0 + + +def test_step_on_a_dirty_tree_is_refused(repo: Path) -> None: + (repo / "f.txt").write_text("changed\n", encoding="utf-8") + res = _gate(repo, "step", "ok-a") + assert res.returncode == 2 + assert "differs from HEAD" in res.stderr + assert not (_git_dir(repo) / "gate-steps").exists() + + +def test_step_with_an_unknown_id_is_refused(repo: Path) -> None: + for hook in ("nope", "commit-only"): + res = _gate(repo, "step", hook) + assert res.returncode == 2 + assert "not a manual-stage hook" in res.stderr + assert not (_git_dir(repo) / "gate-steps").exists() + + +def test_step_without_an_id_is_refused(repo: Path) -> None: + res = _gate(repo, "step") + assert res.returncode == 2 + + +def test_step_in_a_linked_worktree_uses_its_own_git_dir(repo: Path) -> None: + wt = repo.parent / "wt" + _git(repo, "worktree", "add", "-q", str(wt)) + res = _gate(wt, "step", "ok-a") + assert res.returncode == 0, res.stdout + res.stderr + assert (repo / ".git/worktrees/wt/gate-steps").exists() + assert not (repo / ".git/gate-steps").exists() + + +def test_full_hints_at_step(repo: Path) -> None: + res = _gate(repo, "full") + assert "step" in res.stdout.splitlines()[0] + assert res.returncode != 0 # `bad` fails, so no stamp + assert not (_git_dir(repo) / "gate-stamp").exists() + + +def test_check_failure_mentions_list_and_step(repo: Path) -> None: + res = _gate(repo, "check") + assert res.returncode == 1 + assert "full --list" in res.stderr + assert "step " in res.stderr