From 79c170d82f59c24b3d233667c3c2122ff94ce2dc Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:03:14 -0400 Subject: [PATCH 1/3] chore(git-hooks): run the guardrails git hooks from this repository's lefthook remote Add the `remotes:` entry `install.mjs plan --scope remote` returns, so this repository runs commit-msg-convention, pre-commit-content and reference-verify from its own main, the same way every consumer repository does. The header comment now says `lefthook install` also fetches them. Refs: #9426 Co-Authored-By: Claude Opus 5.5 --- lefthook.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index 9a21134e85..2c95983cf3 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,6 +1,7 @@ # Git hooks for this repository (https://lefthook.dev/configuration/). Install once per clone: -# `lefthook install`; until then the .claude/settings.json pre-push row runs the same check on a -# Claude Code session's pushes and says to install. Placement rules: +# `lefthook install`, which also fetches the guardrails git hooks named in `remotes:` below; until +# then the .claude/settings.json pre-push row runs the check-affected check on a Claude Code +# session's pushes and says to install. Placement rules: # docs/conventions/check-placement/README.md. pre-push: jobs: @@ -24,3 +25,12 @@ pre-push: git archive "$mb" -- .claude/hooks/check-affected-pre-push.sh lib/hook-utils.sh 2>/dev/null | tar -x -C "$tmp" 2>/dev/null [ -f "$tmp/.claude/hooks/check-affected-pre-push.sh" ] || exit 0 bash "$tmp/.claude/hooks/check-affected-pre-push.sh" --git-hook Date: Sat, 24 Oct 2026 00:17:41 -0400 Subject: [PATCH 2/3] fix(git-hooks): run only the fetched copy of the guardrails hooks in this repository This repository is its own lefthook remote, so lefthook found each guardrails script both in the work tree's .lefthook/ and in the fetched copy of main, and ran both: every check ran twice, and a checked-out branch ran its own hook code. Each .lefthook/ script now exits 0 unless it runs from lefthook-remotes/. The lefthook.yml comments and the check-placement docs now name .lefthook/ among the paths to read before committing or pushing on an untrusted branch, and say these hooks fire at commit too. Refs: #9426 Co-Authored-By: Claude Opus 5.5 --- .../guardrails/chore-guardrails-self-remote-bc46117e.md | 5 +++++ .lefthook/commit-msg/guardrails-commit-msg-convention.sh | 2 ++ .lefthook/pre-commit/guardrails-pre-commit-content.sh | 2 ++ .lefthook/pre-push/guardrails-reference-verify.sh | 2 ++ docs/conventions/check-placement/README.md | 7 ++++--- docs/conventions/check-placement/adopting-git-hooks.md | 4 ++-- lefthook.yml | 8 +++++--- plugins/guardrails/git-hooks/lefthook-remote.test.sh | 2 ++ 8 files changed, 24 insertions(+), 8 deletions(-) create mode 100644 .changes/guardrails/chore-guardrails-self-remote-bc46117e.md diff --git a/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md b/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md new file mode 100644 index 0000000000..cb45a82d6f --- /dev/null +++ b/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md @@ -0,0 +1,5 @@ +--- +bump: none +--- + +Test-only change: the lefthook-remote test also checks that the work-tree copy of each remote script exits 0 without running. diff --git a/.lefthook/commit-msg/guardrails-commit-msg-convention.sh b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh index 9a81039d98..a5ebfd4abd 100755 --- a/.lefthook/commit-msg/guardrails-commit-msg-convention.sh +++ b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh @@ -2,4 +2,6 @@ # The guardrails commit-msg-convention entry for consumers of the plugin's lefthook remote # (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts # under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. +# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. +[[ ${BASH_SOURCE[0]} == */lefthook-remotes/* ]] || exit 0 exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/commit-msg-convention.sh" "$@" diff --git a/.lefthook/pre-commit/guardrails-pre-commit-content.sh b/.lefthook/pre-commit/guardrails-pre-commit-content.sh index ea4c932c88..51ef9c90f5 100755 --- a/.lefthook/pre-commit/guardrails-pre-commit-content.sh +++ b/.lefthook/pre-commit/guardrails-pre-commit-content.sh @@ -2,4 +2,6 @@ # The guardrails pre-commit-content entry for consumers of the plugin's lefthook remote # (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts # under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. +# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. +[[ ${BASH_SOURCE[0]} == */lefthook-remotes/* ]] || exit 0 exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/pre-commit-content.sh" "$@" diff --git a/.lefthook/pre-push/guardrails-reference-verify.sh b/.lefthook/pre-push/guardrails-reference-verify.sh index a72ad2a516..06d4f8b7c5 100755 --- a/.lefthook/pre-push/guardrails-reference-verify.sh +++ b/.lefthook/pre-push/guardrails-reference-verify.sh @@ -2,4 +2,6 @@ # The guardrails reference-verify entry for consumers of the plugin's lefthook remote # (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts # under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. +# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. +[[ ${BASH_SOURCE[0]} == */lefthook-remotes/* ]] || exit 0 exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/reference-verify.sh" "$@" diff --git a/docs/conventions/check-placement/README.md b/docs/conventions/check-placement/README.md index c20fa7af94..c5469f5eff 100644 --- a/docs/conventions/check-placement/README.md +++ b/docs/conventions/check-placement/README.md @@ -244,9 +244,10 @@ committed config through their own install step. **Committed hook config runs the checked-out branch's code.** Lefthook and husky read their config, and plain shared hooks read `.githooks/`, from the branch that is checked out. Once a clone has installed hooks, a commit or push on a contributor's branch runs whatever that branch puts in -`lefthook.yml`, `.husky/` or `.githooks/`. Pinning the check scripts to the merge-base, as this -repository's pre-push job does, does not reach the config file itself. Before committing or -pushing on a branch you do not trust, read its diff to those paths. +`lefthook.yml`, `.lefthook/`, `.husky/` or `.githooks/`. Pinning the check scripts to the +merge-base, as this repository's pre-push job does, does not reach the config file itself. Before +committing or pushing on a branch you do not trust, read its diff to those paths: pre-commit and +commit-msg hooks run at commit, before any push. ### Drift: the content-hash stamp diff --git a/docs/conventions/check-placement/adopting-git-hooks.md b/docs/conventions/check-placement/adopting-git-hooks.md index 8326f93ecc..fd2088bec3 100644 --- a/docs/conventions/check-placement/adopting-git-hooks.md +++ b/docs/conventions/check-placement/adopting-git-hooks.md @@ -55,8 +55,8 @@ A clone that skips this step gets no hooks, so for a team with authors outside C lefthook or husky over plain hooks. A clone that tried the entry locally moves to shared by running setup with `scope=shared`; setup offers to remove the local copies once the shared entry verifies. Committed hook config runs whatever the checked-out branch holds, so before committing -or pushing on a branch you do not trust, read its diff to `lefthook.yml`, `.husky/` and -`.githooks/` ([details](README.md#where-installed-copies-live)). +or pushing on a branch you do not trust, read its diff to `lefthook.yml`, `.lefthook/`, `.husky/` +and `.githooks/` ([details](README.md#where-installed-copies-live)). ## Decline diff --git a/lefthook.yml b/lefthook.yml index 2c95983cf3..ab44bba12e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,7 +10,8 @@ pre-push: # check tooling the branch changes, so a contributor branch checked out to review or fix # cannot run its own check code. This file itself is read from the checked-out branch, as # lefthook and husky always read their config, so a branch that edits lefthook.yml still - # controls this job: read a branch's lefthook.yml diff before pushing from it. + # controls this job: read a branch's lefthook.yml and .lefthook/ diff before committing or + # pushing on it, since the guardrails hooks below fire at commit too. # Lefthook gives stdin to at most one job per hook, so this job judges HEAD rather than the # pushed refs. Exit 1 is a failing check and blocks the push; anything else passes, and CI # checks the pushed commit either way. @@ -26,8 +27,9 @@ pre-push: [ -f "$tmp/.claude/hooks/check-affected-pre-push.sh" ] || exit 0 bash "$tmp/.claude/hooks/check-affected-pre-push.sh" --git-hook /dev/null)" expect "$name: its script runs an entry script that exists" yes \ "$([[ -n "$target" && -f "$TOP/.lefthook/$event/$target" ]] && echo yes)" + out="$(bash "$wrapper" /nonexistent 2>&1)" + expect "$name: the work-tree copy exits 0 without running" "0:" "$?:$out" entry="${script#guardrails-}" expect "$name: not a shared-copy job name" yes "$([[ "$name" != "guardrails-${entry%.sh}" ]] && echo yes)" done <<<"$jobs" From 1dc44cb16a537407ff2c75ed123877e42fc84f83 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:27:47 -0400 Subject: [PATCH 3/3] fix(git-hooks): match the fetched guardrails copy on Windows paths and test it observably On Windows lefthook passes a backslash script path, so the lefthook-remotes guard never matched and every hook skipped silently. Each .lefthook/ script now turns backslashes into slashes first, matches the narrower info/lefthook-remotes/ segment, and runs its entry script from the normalized path. The lefthook-remote test runs each script beside a stub entry script that reports a run: from a work tree it must not run, from a fetched copy it must, including from a backslash path. Without the guard the work-tree cases fail. The untrusted-branch text also names lefthook-local.yml and .lefthook-local/, which lefthook runs at commit as well. Refs: #9426 Co-Authored-By: Claude Opus 5.5 --- .../chore-guardrails-self-remote-bc46117e.md | 2 +- .../guardrails-commit-msg-convention.sh | 8 ++++--- .../guardrails-pre-commit-content.sh | 8 ++++--- .../pre-push/guardrails-reference-verify.sh | 8 ++++--- docs/conventions/check-placement/README.md | 8 +++---- .../check-placement/adopting-git-hooks.md | 5 ++-- lefthook.yml | 5 ++-- .../git-hooks/lefthook-remote.test.sh | 23 ++++++++++++++++--- 8 files changed, 46 insertions(+), 21 deletions(-) diff --git a/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md b/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md index cb45a82d6f..ca42543792 100644 --- a/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md +++ b/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md @@ -2,4 +2,4 @@ bump: none --- -Test-only change: the lefthook-remote test also checks that the work-tree copy of each remote script exits 0 without running. +No plugin file ships changed: the root `.lefthook/` remote scripts, which consumers fetch from `main`, now run only from lefthook's `info/lefthook-remotes/` cache (on Windows too), and the lefthook-remote test checks it. diff --git a/.lefthook/commit-msg/guardrails-commit-msg-convention.sh b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh index a5ebfd4abd..9438bd3007 100755 --- a/.lefthook/commit-msg/guardrails-commit-msg-convention.sh +++ b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh @@ -2,6 +2,8 @@ # The guardrails commit-msg-convention entry for consumers of the plugin's lefthook remote # (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts # under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. -# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. -[[ ${BASH_SOURCE[0]} == */lefthook-remotes/* ]] || exit 0 -exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/commit-msg-convention.sh" "$@" +# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. Backslashes +# become slashes first: lefthook on Windows passes a backslash path. +src=${BASH_SOURCE[0]//\\//} +[[ $src == */info/lefthook-remotes/* ]] || exit 0 +exec bash "${src%/*}/../../plugins/guardrails/git-hooks/commit-msg-convention.sh" "$@" diff --git a/.lefthook/pre-commit/guardrails-pre-commit-content.sh b/.lefthook/pre-commit/guardrails-pre-commit-content.sh index 51ef9c90f5..509255a9b6 100755 --- a/.lefthook/pre-commit/guardrails-pre-commit-content.sh +++ b/.lefthook/pre-commit/guardrails-pre-commit-content.sh @@ -2,6 +2,8 @@ # The guardrails pre-commit-content entry for consumers of the plugin's lefthook remote # (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts # under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. -# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. -[[ ${BASH_SOURCE[0]} == */lefthook-remotes/* ]] || exit 0 -exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/pre-commit-content.sh" "$@" +# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. Backslashes +# become slashes first: lefthook on Windows passes a backslash path. +src=${BASH_SOURCE[0]//\\//} +[[ $src == */info/lefthook-remotes/* ]] || exit 0 +exec bash "${src%/*}/../../plugins/guardrails/git-hooks/pre-commit-content.sh" "$@" diff --git a/.lefthook/pre-push/guardrails-reference-verify.sh b/.lefthook/pre-push/guardrails-reference-verify.sh index 06d4f8b7c5..b41375d87b 100755 --- a/.lefthook/pre-push/guardrails-reference-verify.sh +++ b/.lefthook/pre-push/guardrails-reference-verify.sh @@ -2,6 +2,8 @@ # The guardrails reference-verify entry for consumers of the plugin's lefthook remote # (plugins/guardrails/git-hooks/lefthook-remote.yml). Lefthook looks up a remote's scripts # under this repository's root .lefthook/, so this runs the plugin's own script, which finds its libs beside it. -# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. -[[ ${BASH_SOURCE[0]} == */lefthook-remotes/* ]] || exit 0 -exec bash "${BASH_SOURCE[0]%/*}/../../plugins/guardrails/git-hooks/reference-verify.sh" "$@" +# Skip the work-tree copy, so only the fetched copy of main in lefthook-remotes/ runs. Backslashes +# become slashes first: lefthook on Windows passes a backslash path. +src=${BASH_SOURCE[0]//\\//} +[[ $src == */info/lefthook-remotes/* ]] || exit 0 +exec bash "${src%/*}/../../plugins/guardrails/git-hooks/reference-verify.sh" "$@" diff --git a/docs/conventions/check-placement/README.md b/docs/conventions/check-placement/README.md index c5469f5eff..7de3930fce 100644 --- a/docs/conventions/check-placement/README.md +++ b/docs/conventions/check-placement/README.md @@ -244,10 +244,10 @@ committed config through their own install step. **Committed hook config runs the checked-out branch's code.** Lefthook and husky read their config, and plain shared hooks read `.githooks/`, from the branch that is checked out. Once a clone has installed hooks, a commit or push on a contributor's branch runs whatever that branch puts in -`lefthook.yml`, `.lefthook/`, `.husky/` or `.githooks/`. Pinning the check scripts to the -merge-base, as this repository's pre-push job does, does not reach the config file itself. Before -committing or pushing on a branch you do not trust, read its diff to those paths: pre-commit and -commit-msg hooks run at commit, before any push. +`lefthook.yml`, `lefthook-local.yml`, `.lefthook/`, `.lefthook-local/`, `.husky/` or `.githooks/`. +Pinning the check scripts to the merge-base, as this repository's pre-push job does, does not +reach the config file itself. Before committing or pushing on a branch you do not trust, read its +diff to those paths: pre-commit and commit-msg hooks run at commit, before any push. ### Drift: the content-hash stamp diff --git a/docs/conventions/check-placement/adopting-git-hooks.md b/docs/conventions/check-placement/adopting-git-hooks.md index fd2088bec3..43ee338673 100644 --- a/docs/conventions/check-placement/adopting-git-hooks.md +++ b/docs/conventions/check-placement/adopting-git-hooks.md @@ -55,8 +55,9 @@ A clone that skips this step gets no hooks, so for a team with authors outside C lefthook or husky over plain hooks. A clone that tried the entry locally moves to shared by running setup with `scope=shared`; setup offers to remove the local copies once the shared entry verifies. Committed hook config runs whatever the checked-out branch holds, so before committing -or pushing on a branch you do not trust, read its diff to `lefthook.yml`, `.lefthook/`, `.husky/` -and `.githooks/` ([details](README.md#where-installed-copies-live)). +or pushing on a branch you do not trust, read its diff to `lefthook.yml`, `lefthook-local.yml`, +`.lefthook/`, `.lefthook-local/`, `.husky/` and `.githooks/` +([details](README.md#where-installed-copies-live)). ## Decline diff --git a/lefthook.yml b/lefthook.yml index ab44bba12e..92ab1b775a 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -10,8 +10,9 @@ pre-push: # check tooling the branch changes, so a contributor branch checked out to review or fix # cannot run its own check code. This file itself is read from the checked-out branch, as # lefthook and husky always read their config, so a branch that edits lefthook.yml still - # controls this job: read a branch's lefthook.yml and .lefthook/ diff before committing or - # pushing on it, since the guardrails hooks below fire at commit too. + # controls this job: read a branch's diff to lefthook.yml, lefthook-local.yml, .lefthook/ and + # .lefthook-local/ before committing or pushing on it, since the guardrails hooks below fire at + # commit too. # Lefthook gives stdin to at most one job per hook, so this job judges HEAD rather than the # pushed refs. Exit 1 is a failing check and blocks the push; anything else passes, and CI # checks the pushed commit either way. diff --git a/plugins/guardrails/git-hooks/lefthook-remote.test.sh b/plugins/guardrails/git-hooks/lefthook-remote.test.sh index f9a598e06f..d188af3213 100755 --- a/plugins/guardrails/git-hooks/lefthook-remote.test.sh +++ b/plugins/guardrails/git-hooks/lefthook-remote.test.sh @@ -38,18 +38,35 @@ jobs="$(awk ' ' "$CONFIG")" expect "the remote config has one job per entry event" "commit-msg pre-commit pre-push" \ "$(cut -d' ' -f1 <<<"$jobs" | sort | xargs)" +GUARD="$(mktemp -d)" +trap 'rm -rf "$GUARD"' EXIT while read -r event name script; do wrapper="$TOP/.lefthook/$event/$script" expect "$name: its script is under .lefthook/$event/" yes "$([[ -f "$wrapper" ]] && echo yes)" # shellcheck disable=SC2016 # matches the wrapper's literal text - target="$(sed -n 's#^exec bash "${BASH_SOURCE\[0\]%/\*}/\(.*\)" "\$@"$#\1#p' "$wrapper" 2>/dev/null)" + target="$(sed -n 's#^exec bash "${src%/\*}/\(.*\)" "\$@"$#\1#p' "$wrapper" 2>/dev/null)" expect "$name: its script runs an entry script that exists" yes \ "$([[ -n "$target" && -f "$TOP/.lefthook/$event/$target" ]] && echo yes)" - out="$(bash "$wrapper" /nonexistent 2>&1)" - expect "$name: the work-tree copy exits 0 without running" "0:" "$?:$out" + # The wrapper beside a stub entry script that reports a run: once in a work tree, once under a + # fetched remote, with a Windows-style backslash path. + for root in "$GUARD/wt" "$GUARD/git/info/lefthook-remotes/r"; do + mkdir -p "$root/.lefthook/$event" "$(dirname "$root/.lefthook/$event/$target")" + cp "$wrapper" "$root/.lefthook/$event/$script" + printf '#!/usr/bin/env bash\necho ran\n' >"$root/.lefthook/$event/$target" + done + out="$(bash "$GUARD/wt/.lefthook/$event/$script" &1)" + expect "$name: the work-tree copy exits 0 without running the entry script" "0:" "$?:$out" + out="$(bash "$GUARD/git/info/lefthook-remotes/r/.lefthook/$event/$script" &1)" + expect "$name: the fetched copy runs the entry script" "ran" "$out" + # A file whose name holds the backslashes stands in for the Windows path of the fetched copy. + win="$GUARD/git\\info\\lefthook-remotes\\r\\.lefthook\\$event\\$script" + cp "$wrapper" "$win" + out="$(bash "$win" &1)" + expect "$name: the fetched copy runs from a backslash path" "ran" "$out" entry="${script#guardrails-}" expect "$name: not a shared-copy job name" yes "$([[ "$name" != "guardrails-${entry%.sh}" ]] && echo yes)" done <<<"$jobs" +rm -rf "$GUARD" expect "entries.json's remote names this config" "plugins/guardrails/git-hooks/lefthook-remote.yml" \ "$(jq -r '.remote.config' "$HERE/entries.json" 2>/dev/null || sed -n 's/.*"config": "\(.*\)".*/\1/p' "$HERE/entries.json")"