mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 14:07:46 +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
|
# gate + commit; refuses a dirty tree unless told the gate
|
||||||
# saw the working copy (narrow staging leaves other files)
|
# saw the working copy (narrow staging leaves other files)
|
||||||
# full run the repo's manual-stage hooks (the old pre-push set)
|
# 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
|
# 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.
|
# tree may leave the machine, not whether it is fully green.
|
||||||
@ -25,9 +37,13 @@ set -euo pipefail
|
|||||||
|
|
||||||
SCRIPT_NAME="$(basename "$0")"
|
SCRIPT_NAME="$(basename "$0")"
|
||||||
readonly SCRIPT_NAME
|
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() {
|
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
|
exit 2
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,6 +51,10 @@ stamp_path() {
|
|||||||
printf '%s/gate-stamp\n' "$(git rev-parse --git-dir)"
|
printf '%s/gate-stamp\n' "$(git rev-parse --git-dir)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
steps_path() {
|
||||||
|
printf '%s/gate-steps\n' "$(git rev-parse --git-dir)"
|
||||||
|
}
|
||||||
|
|
||||||
tree_of() {
|
tree_of() {
|
||||||
git rev-parse --verify --quiet "$1^{tree}"
|
git rev-parse --verify --quiet "$1^{tree}"
|
||||||
}
|
}
|
||||||
@ -70,7 +90,9 @@ cmd_check() {
|
|||||||
echo " no stamp in $(stamp_path)"
|
echo " no stamp in $(stamp_path)"
|
||||||
fi
|
fi
|
||||||
echo " Either: ~/.claude/scripts/finish_auto.sh (gate + commit + push)"
|
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
|
} >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@ -94,12 +116,75 @@ cmd_write() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
manual_ids() {
|
||||||
|
/usr/bin/python3 "$HOOKS_HELPER" .pre-commit-config.yaml
|
||||||
|
}
|
||||||
|
|
||||||
cmd_full() {
|
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)"
|
echo "gate-stamp: running the manual-stage hooks on all files (the old pre-push set)"
|
||||||
pre-commit run --hook-stage manual --all-files
|
pre-commit run --hook-stage manual --all-files
|
||||||
cmd_write
|
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() {
|
main() {
|
||||||
local cmd="${1:-}"
|
local cmd="${1:-}"
|
||||||
[[ -n "$cmd" ]] || usage
|
[[ -n "$cmd" ]] || usage
|
||||||
@ -109,6 +194,7 @@ main() {
|
|||||||
check) cmd_check "$@" ;;
|
check) cmd_check "$@" ;;
|
||||||
write) cmd_write "$@" ;;
|
write) cmd_write "$@" ;;
|
||||||
full) cmd_full "$@" ;;
|
full) cmd_full "$@" ;;
|
||||||
|
step) cmd_step "$@" ;;
|
||||||
*) usage ;;
|
*) usage ;;
|
||||||
esac
|
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