Read the force marker as part of the refspec, not of the branch

`git push origin +main` put the destination through the branch policy as
"+main", which matched no entry, so the one spelling that escaped was the
destructive one. `+refs/heads/main` escaped the same way, since the prefix also
stopped the refs/heads/ strip from landing.

One leading `+` now comes off before the destination is read. A `+` on a branch
nothing protects is still an ordinary push, and the test says so - otherwise the
strip could be doing the refusing rather than the policy.
This commit is contained in:
2026-10-07 17:19:20 -07:00
parent 2217d6d3c3
commit af0fce3326
2 changed files with 24 additions and 2 deletions
+6
View File
@@ -227,6 +227,11 @@ dejima_push_verdict() { # <repo-dir> <git argv...>
fi
local refspec
for refspec in "${refspecs[@]}"; do
# One leading `+` is the force marker, and it belongs to the refspec
# rather than to the branch - so `git push origin +main` names main and
# was compared as "+main", which matched no policy entry. The escaping
# form was the destructive one.
refspec="${refspec#+}"
case "$refspec" in
*:*) branch="${refspec##*:}" ;;
HEAD) branch="$("$DEJIMA_GUARD_GIT" -C "$repo_dir" branch --show-current 2>/dev/null)" ;;
@@ -475,6 +480,7 @@ _dejima_forge_path_verdict() {
# value of a field flag - `-f query=mutation{...}` - rather than as
# an argument of its own. Matched before the brace test below,
# which a document would otherwise satisfy.
#
# Both boundaries are anything that cannot continue an
# identifier, rather than whitespace and a brace. GraphQL treats
+18 -2
View File
@@ -3921,13 +3921,28 @@ _guard_repo() {
# Each of these is the same push wearing different clothes. The hook this
# supplements matched a regex over a command string and had to anticipate
# the spelling; argv does not care.
for form in "push origin main" "push origin HEAD:main" "push --repo=origin main" \
"push --repo origin main" "push -o ci.skip origin main"; do
#
# `+` is the force marker and belongs to the refspec, not to the branch, so
# the destination has to be read with it removed - `git push origin +main`
# was compared as "+main" and matched no policy entry, which left the
# force-push spelling as the one that went through.
for form in "push origin main" "push origin HEAD:main" \
"push --repo=origin main" "push --repo origin main" \
"push -o ci.skip origin main" \
"push origin +main" "push origin +refs/heads/main" \
"push origin +feature:main" "push --force origin main" \
"push --force-with-lease origin main" "push origin :main"; do
# shellcheck disable=SC2086
run _git_guard "$repo" $form
[[ "$output" == *"aren't allowed in the sandbox"* ]] \
|| { echo "not blocked: git $form -> $output"; return 1; }
done
# A `+` on a branch that is not protected is still an ordinary push, so the
# strip above cannot be doing the refusing.
run _git_guard "$repo" push origin +feature
[[ "$output" != *"Blocked by dejima"* ]] \
|| { echo "a forced push to an unprotected branch was refused: $output"; return 1; }
}
@test "the git guard leaves everything that is not a blocked push alone" {
@@ -4119,6 +4134,7 @@ _guard_repo() {
"git push" "git push origin" "git push origin HEAD" \
"git push -o ci.skip origin main" "git push --push-option=x origin main" \
"git push --repo origin main" "git push --repo=origin main" \
"git push origin +main" \
"git push --receive-pack /bin/true origin main" \
"git push --all origin" "git push --mirror origin" \
"git push origin feature main" \