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..ca42543792 --- /dev/null +++ b/.changes/guardrails/chore-guardrails-self-remote-bc46117e.md @@ -0,0 +1,5 @@ +--- +bump: none +--- + +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 9a81039d98..9438bd3007 100755 --- a/.lefthook/commit-msg/guardrails-commit-msg-convention.sh +++ b/.lefthook/commit-msg/guardrails-commit-msg-convention.sh @@ -2,4 +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. -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 ea4c932c88..509255a9b6 100755 --- a/.lefthook/pre-commit/guardrails-pre-commit-content.sh +++ b/.lefthook/pre-commit/guardrails-pre-commit-content.sh @@ -2,4 +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. -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 a72ad2a516..b41375d87b 100755 --- a/.lefthook/pre-push/guardrails-reference-verify.sh +++ b/.lefthook/pre-push/guardrails-reference-verify.sh @@ -2,4 +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. -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 c20fa7af94..7de3930fce 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-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 8326f93ecc..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`, `.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 9a21134e85..92ab1b775a 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: @@ -9,7 +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 diff before pushing from it. + # 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. @@ -24,3 +27,13 @@ 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 /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)" + # 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")"