Skip to content

Commit 216a906

Browse files
committed
fix(hooks): scope trunk protection to the project's own repository
block-push-to-main.sh blocked any push whose destination ref resolved to the trunk, without first asking which repository the push targets. Trunk protection exists to route changes to THIS project through a PR, but the guard fired on pushes to unrelated repositories too, where there is no PR flow to route through and nothing to protect. The case that surfaced it: publishing a design doc to a GitLab project wiki. A wiki lives in a separate `<project>.wiki.git` repository whose default — and only — branch is `main`. Every wiki update tripped the guard, so wiki pages could not be pushed at all. The same false positive hits a sibling checkout or a vendored dependency that happens to sit on its trunk. Resolve the target repository with the existing resolve_git_command_cwd helper (it already understands `git -C <path> push` and `cd <path> && git push`), then compare git-common-dir the way block-commit-outside-worktree.sh already does and exit 0 when the push lands in a different repository. No new helper: this reuses what lib.sh exports and mirrors the identity check the sibling guard was already performing, so the two hooks now agree on what "this repo" means. Comparing git-common-dir rather than git-dir keeps a linked worktree of this project counted as this project, so the scoping cannot be used as an escape hatch. Resolution uncertainty falls back to the inherited cwd, leaving an unparsable command checked rather than waved through. Extends tests/unit/test-block-push-regex.sh with 4 cases (2 reproducing the false positive, 2 pinning the escape-hatch boundary): TC-BP-12 push another repo's main via `git -C` -> allow TC-BP-13 push a `<project>.wiki.git` main via `cd &&` -> allow TC-BP-14 push this repo's main via `git -C <self>` -> still block TC-BP-15 push this repo's main from a linked worktree -> still block 15/15 pass; the two new allow-cases fail on the unmodified hook (13 pass / 2 fail), and TC-BP-14/15 already passed before the change, confirming the scoping narrows nothing it should still catch. Bare `git push` was verified separately across all three shapes — same repo on a feature branch (allow), same repo on trunk (block), and a different repo sitting on trunk (allow) — since parse_push_target_refspec reads the current branch and only runs on the same-repo path. test-block-commit-outside-worktree.sh, test-install-git-pre-push.sh and test-chp-commit-file.sh still pass (they share lib.sh); shellcheck -x is clean.
1 parent 3610c2b commit 216a906

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

skills/autonomous-common/hooks/block-push-to-main.sh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,35 @@ if ! is_git_command "push" "$command"; then
2222
exit 0
2323
fi
2424

25+
# Trunk protection guards THIS project's repository. Resolve the repository the
26+
# push actually targets (`git -C <path> push`, `cd <path> && git push`) and bow
27+
# out when it is a different one: a sibling checkout, a vendored dependency, or
28+
# a project's separate `<project>.wiki.git`. A wiki has no PR flow and `main` is
29+
# its only branch, so blocking it made wiki updates impossible rather than
30+
# routing them through review.
31+
#
32+
# Mirrors the identity check block-commit-outside-worktree.sh already performs.
33+
# Comparing git-common-dir (shared by a repo and all its linked worktrees) keeps
34+
# a worktree of this project counted as this project — the scoping must not
35+
# become an escape hatch (see TC-BP-14/15). Any resolution uncertainty falls
36+
# back to the inherited cwd, so an unparsable command is still checked.
37+
hook_common_dir=""
38+
if hook_common_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
39+
hook_common_dir=$(_canonical_existing_directory "$hook_common_dir") || hook_common_dir=""
40+
fi
41+
42+
push_dir="$(pwd -P)"
43+
if resolved_dir=$(resolve_git_command_cwd "push" "$command" "$push_dir"); then
44+
push_dir="$resolved_dir"
45+
fi
46+
47+
if [[ -n "$hook_common_dir" ]] &&
48+
target_common_dir=$(git -C "$push_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) &&
49+
target_common_dir=$(_canonical_existing_directory "$target_common_dir") &&
50+
[[ "$target_common_dir" != "$hook_common_dir" ]]; then
51+
exit 0
52+
fi
53+
2554
# Trunk branch name (issue #478, [INV-131]): BASE_BRANCH (the wrapper
2655
# resolves+exports it once at startup) → TRUNK_BRANCH (this hook's pre-#478
2756
# override, still honored standalone e.g. for a manually-run hook outside the

tests/unit/test-block-push-regex.sh

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,30 @@ assert_exit() {
6161
fi
6262
}
6363

64+
# Set up a SECOND, unrelated repo (with `main` checked out) to stand in for
65+
# another repository the agent legitimately pushes to — most importantly a
66+
# project's separate `<project>.wiki.git`, whose default and only branch is
67+
# `main` and which has no PR flow at all.
68+
setup_other_repo() {
69+
local name="$1"
70+
rm -rf "$TMPDIR/$name"
71+
mkdir -p "$TMPDIR/$name"
72+
git -C "$TMPDIR/$name" init --quiet --initial-branch=main
73+
git -C "$TMPDIR/$name" -c user.email=test@test -c user.name=test commit \
74+
--quiet --allow-empty -m init
75+
}
76+
77+
# Run the hook with cwd = the project repo (what Claude Code actually passes)
78+
# while the command targets a different repo.
79+
run_hook_cwd() {
80+
local cmd="$1" cwd="$2"
81+
local input
82+
input=$(printf '{"tool_input":{"command":%s},"cwd":%s}' \
83+
"$(jq -Rn --arg c "$cmd" '$c')" "$(jq -Rn --arg d "$cwd" '$d')")
84+
(cd "$cwd" && CLAUDE_PROJECT_DIR="$TMPDIR/repo" bash "$HOOK" <<<"$input")
85+
echo $?
86+
}
87+
6488
# ===========================================================================
6589
# TC-BP-01: Bare push from trunk → block
6690
# ===========================================================================
@@ -165,6 +189,59 @@ setup_repo main
165189
out=$(run_hook "git status")
166190
assert_exit "git status (not a push) allowed" "0" "$out"
167191

192+
# ===========================================================================
193+
# TC-BP-12: push to ANOTHER repo's main via `git -C <other>` → allow
194+
# ===========================================================================
195+
# Trunk protection guards THIS project's repo. A push whose destination
196+
# repository is a different one is not this guard's business: the agent may
197+
# legitimately be publishing to a sibling checkout, a vendored dependency, or
198+
# a wiki. Blocking it is a pure false positive — there is no PR flow here to
199+
# route the change through.
200+
echo ""
201+
echo "=== TC-BP-12: push another repo's main via -C → allow ==="
202+
setup_repo main
203+
setup_other_repo other
204+
out=$(run_hook_cwd "git -C $TMPDIR/other push origin main" "$TMPDIR/repo")
205+
assert_exit "push to another repo's main via -C allowed" "0" "$out"
206+
207+
# ===========================================================================
208+
# TC-BP-13: push to a .wiki.git clone's main via `cd && git push` → allow
209+
# ===========================================================================
210+
# The real-world shape that motivated this: publishing a design doc to a
211+
# GitLab project wiki. `<project>.wiki.git` is a separate repository whose
212+
# only branch is `main`, so the guard fired on every wiki update and the docs
213+
# could not be pushed at all.
214+
echo ""
215+
echo "=== TC-BP-13: push to <project>.wiki.git main via cd → allow ==="
216+
setup_repo main
217+
setup_other_repo project.wiki
218+
out=$(run_hook_cwd "cd $TMPDIR/project.wiki && git push origin main" "$TMPDIR/repo")
219+
assert_exit "push to <project>.wiki.git main allowed" "0" "$out"
220+
221+
# ===========================================================================
222+
# TC-BP-14: push THIS repo's main via `git -C <self>` → still block
223+
# ===========================================================================
224+
# The scoping must not become an escape hatch: naming the project's own repo
225+
# explicitly still routes through trunk protection.
226+
echo ""
227+
echo "=== TC-BP-14: push own repo main via -C → still block ==="
228+
setup_repo main
229+
out=$(run_hook_cwd "git -C $TMPDIR/repo push origin main" "$TMPDIR/repo")
230+
assert_exit "push to own repo's main via -C still blocked" "2" "$out"
231+
232+
# ===========================================================================
233+
# TC-BP-15: push main from a linked worktree of THIS repo → still block
234+
# ===========================================================================
235+
# A linked worktree shares its git-common-dir with the project repo, so it
236+
# must still count as "this repo" — otherwise every guard could be sidestepped
237+
# by committing from a worktree.
238+
echo ""
239+
echo "=== TC-BP-15: push own repo main from a linked worktree → still block ==="
240+
setup_repo main
241+
git -C "$TMPDIR/repo" worktree add --quiet -b feat/wt "$TMPDIR/wt" >/dev/null 2>&1
242+
out=$(run_hook_cwd "git push origin HEAD:refs/heads/main" "$TMPDIR/wt")
243+
assert_exit "push to own repo's main from linked worktree still blocked" "2" "$out"
244+
168245
# ===========================================================================
169246
# Summary
170247
# ===========================================================================

0 commit comments

Comments
 (0)