mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 17:47:24 +01:00
gate_stamp: split full into <60 s calls with full --list + step <id>
`full` ran every manual-stage hook in one pre-commit call, which takes over 60 s in screen-locker and leetcode-guard. `step <hook-id>` runs one manual hook on --all-files and records "<tree> <id>" 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
parent
7f2443ce48
commit
8b7105e20c
@ -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 <hook-id> run one manual-stage hook on --all-files and record
|
||||
# "<HEAD^{tree}> <id>" 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 <hook-id>"
|
||||
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 <id> 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 <hook-id>'"
|
||||
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
|
||||
}
|
||||
|
||||
76
scripts/gate_stamp_hooks.py
Executable file
76
scripts/gate_stamp_hooks.py
Executable file
@ -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 <id>`) 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 <id>` 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 <path-to-.pre-commit-config.yaml>`."""
|
||||
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:]))
|
||||
201
scripts/tests/test_gate_stamp.py
Normal file
201
scripts/tests/test_gate_stamp.py
Normal file
@ -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 <id>" in res.stderr
|
||||
Loading…
Reference in New Issue
Block a user