utils/scripts/gate_stamp_hooks.py
Krzysztof kuhy Rudnicki 8b7105e20c 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
2026-10-17 16:45:28 +02:00

77 lines
2.6 KiB
Python
Executable File

#!/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:]))