Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
bump: patch
---

### Changed

- reference-verify judges a stacked branch only on what it adds to the pushed branch below it

The pre-push reference-verify entry now moves each range's base up to the nearest of the remote's tracking refs between that base and the tip. A new branch stacked on a pushed branch no longer re-verifies every file the branches below it changed, so each push in a stack of branches verifies only its own files. The README row states that those tracking refs are trusted as verified, another author's included. The README's reference-verify row also documents the ssh `ServerAliveInterval` setting. It keeps a long push's idle connection open; a dropped connection leaves a push that reports success but creates no ref.
2 changes: 1 addition & 1 deletion plugins/guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ bash beside git's sh.
|-------|-------|----------------|
| `commit-msg-convention` | commit-msg | The subject against the team-tracked `subject_pattern` in `docs/conventions/source-control.yaml` (for one release, else `.claude/source-control.md`), the pattern `block-convention-violation` reads. No tracked pattern, no enforcement. |
| `pre-commit-content` | pre-commit | Secrets and machine-specific paths on the lines the commit adds, with the same pattern libraries and path exemptions as `secret-pattern-detection` and `hardcoded-path-check`, so a file a shell command or script wrote meets the same checks as a Write. |
| `reference-verify` | pre-push | `cli-flag-verify`, `skill-reference-verify` and `stale-path-verify` on the lines the pushed commits add, through `hooks/run-guards.sh`. It skips files in a `samples/` folder below an `evals/` folder, which hold recorded model output; the rest of an eval suite stays checked. It runs at push rather than commit because it costs about 10 s on a 44-file change, over the commit budget (the check-placement convention's measured rows). Needs `jq`; the scanned binaries follow `CLAUDE_PLUGIN_OPTION_CLI_FLAG_VERIFY_BINS` and `CLAUDE_PLUGIN_OPTION_CLI_FLAG_VERIFY_SKIP_BINS` when set in the pushing environment. A cli-flag finding is unverified, not proven wrong, so its failure names the way out: decline the entry. The entry reads that decline itself, so it also stops on the lefthook remote, where no registration is removed. Each finding names the line holding the flag, path or reference. `git -c checks.skipmissing=jq` skips it only while `jq` is missing. |
| `reference-verify` | pre-push | `cli-flag-verify`, `skill-reference-verify` and `stale-path-verify` on the lines the pushed commits add, through `hooks/run-guards.sh`. A new branch is judged from its merge-base with the default branch. Any pushed range then starts instead at the nearest of the remote's tracking refs between its base and its tip, so a stacked branch's push does not re-verify the pushed branches below it. Those tracking refs are trusted as verified without a check, including another author's branch, fetched into this clone, that was pushed with `--no-verify`. It skips files in a `samples/` folder below an `evals/` folder, which hold recorded model output; the rest of an eval suite stays checked. It runs at push rather than commit because it costs about 10 s on a 44-file change, over the commit budget (the check-placement convention's measured rows). Needs `jq`; the scanned binaries follow `CLAUDE_PLUGIN_OPTION_CLI_FLAG_VERIFY_BINS` and `CLAUDE_PLUGIN_OPTION_CLI_FLAG_VERIFY_SKIP_BINS` when set in the pushing environment. A cli-flag finding is unverified, not proven wrong, so its failure names the way out: decline the entry. The entry reads that decline itself, so it also stops on the lefthook remote, where no registration is removed. Each finding names the line holding the flag, path or reference. `git -c checks.skipmissing=jq` skips it only while `jq` is missing. Over ssh, the connection to the remote stays idle while the hook runs. A remote that drops an idle connection leaves a push that reports success but creates no ref. To keep the connection open, set `ServerAliveInterval` (for example `15`) for that host in the ssh config, or set it for one push: `git -c core.sshCommand="ssh -o ServerAliveInterval=15" push`. |

### Enforceability tiers

Expand Down
22 changes: 21 additions & 1 deletion plugins/guardrails/git-hooks/reference-verify.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@
# The ranges come from the check lib: one per pushed ref, a new branch from its
# merge-base with the remote's default branch, a deletion skipped. A hook
# manager that keeps stdin from this script leaves the lib's fallback: the
# current branch against its upstream. A range whose tip merged the default
# current branch against its upstream. Whichever way it came, a range's base
# then moves up to the nearest of the remote's tracking refs between it and
# the tip (nearest_pushed_base), so a new branch stacked on a pushed branch is
# judged only on what it adds to that branch. A range whose tip merged the default
# branch is narrowed to the lines the branch itself adds (narrow_to_branch).
# Each range is checked on its own, in
# that range's tip: the checkout when HEAD is the tip and the tree is clean,
Expand Down Expand Up @@ -147,6 +150,22 @@ narrow_to_branch() {
"$CHECKS_TMP/ranges.main" "$CHECKS_TMP/ranges.push" >"$CHECKS_TMP/ranges"
}

# nearest_pushed_base: moves BASE to the commit of a tracking ref of $remote
# that the tip holds and that holds BASE, the one fewest commits behind the tip.
# Those commits are on the remote already, so they are not judged again.
nearest_pushed_base() {
local r n best="" min=0
while IFS= read -r r; do
[[ "$r" != "$BASE" ]] || continue
n="$(git rev-list --count "$r..$TIP" 2>/dev/null)" || continue
if [[ -z "$best" ]] || ((n < min)); then
best="$r"
min="$n"
fi
done < <(git for-each-ref --merged="$TIP" --contains="$BASE" --format='%(objectname)' "refs/remotes/$remote/" 2>/dev/null)
Comment thread
kyle-sexton marked this conversation as resolved.
Resolved
[[ -z "$best" ]] || BASE="$best"
}

# main_base_for_range: sets MAIN_BASE for the current BASE..TIP, empty unless
# the default branch's merge-base is not already behind BASE.
main_base_for_range() {
Expand Down Expand Up @@ -291,6 +310,7 @@ else
remote="${CHECKS_ARGS%% *}"
[[ -n "$remote" ]] || remote=origin
while read -r BASE TIP <&3; do
nearest_pushed_base
printf '%s %s\n' "$BASE" "$TIP" >"$CHECKS_TMP/push-ranges"
main_base_for_range
if tip_is_checkout; then
Expand Down
29 changes: 25 additions & 4 deletions plugins/guardrails/git-hooks/reference-verify.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
# lefthook-remote wrapper; the same text on an
# unchanged line, in a file type the verifiers do not read, or in an eval
# suite's samples/ folder (recorded model output), does not; a new
# branch ranges from its merge-base with the remote's default branch, a pushed
# branch that merged main is judged only on its own lines, and a
# deletion checks nothing; the failure names the way out (decline the entry;
# the skip key covers only a missing jq); --worktree covers uncommitted files;
# branch ranges from its merge-base with the remote's default branch, or from a
# pushed branch it is stacked on, a pushed branch that merged main is judged
# only on its own lines, and a deletion checks nothing; the failure names the
# way out (decline the entry; the skip key covers only a missing jq);
# --worktree covers uncommitted files;
# and the plugin's vendored installer wires the entry as a pre-push hook, so
# `git commit` does not run it and `git push` does.
# test-scope: plugins/guardrails/git-hooks/entries.json plugins/guardrails/hooks/run-guards.sh plugins/guardrails/hooks/cli-flag-verify.sh plugins/guardrails/hooks/stale-path-verify.sh plugins/guardrails/hooks/skill-reference-verify.sh plugins/guardrails/lib/git-hooks/install.mjs plugins/guardrails/lib/git-hooks/check-lib.sh plugins/guardrails/.claude-plugin/plugin.json plugins/guardrails/git-hooks/commit-msg-convention.sh plugins/guardrails/git-hooks/pre-commit-content.sh .lefthook/pre-push/guardrails-reference-verify.sh
Expand Down Expand Up @@ -376,6 +377,26 @@ expect "a new branch ranges from its merge-base with origin's default branch" "1
"$(push_check refs/heads/topic "$(git -C "$REPO" rev-parse HEAD)" "$ZERO")"
expect "a branch deletion checks nothing" "0|" "$(push_check refs/heads/topic "$ZERO" "$BASE")"

# A new branch stacked on a pushed branch is judged only on what it adds to that
# branch: the pushed branch's files are not verified again.
new_repo
git -C "$REPO" checkout -q -b phase1
printf '#!/usr/bin/env bash\nfaketool --bogus\n' >"$REPO/phase1.sh"
commit_all phase1
git -C "$REPO" checkout -q -b phase2
printf '#!/usr/bin/env bash\nfaketool --real\n' >"$REPO/phase2.sh"
commit_all phase2
expect "a new branch on an unpushed branch verifies that branch's files too" "1|phase1.sh:2: ${FLAG_FINDING/in run.sh/in phase1.sh}" \
"$(push_check refs/heads/phase2 "$(git -C "$REPO" rev-parse HEAD)" "$ZERO")"
git -C "$REPO" push -q --no-verify origin phase1 2>/dev/null
expect "a new branch stacked on a pushed branch does not verify that branch's files" "0|" \
"$(push_check refs/heads/phase2 "$(git -C "$REPO" rev-parse HEAD)" "$ZERO")"
printf '#!/usr/bin/env bash\nfaketool --real\nfaketool --bogus\n' >"$REPO/phase2.sh"
commit_all phase2-bogus
expect "a new stacked branch is judged on the lines it adds" "1|phase2.sh:3: ${FLAG_FINDING/in run.sh/in phase2.sh}" \
"$(push_check refs/heads/phase2 "$(git -C "$REPO" rev-parse HEAD)" "$ZERO")"
git -C "$REPO" checkout -q main

# A pushed branch that merged main: only the lines the branch itself adds are
# judged, never the ones the merge brought in from the default branch.
new_repo
Expand Down
Loading