From 62c5b552d32a3eda8f53ea674a4110090c41a0af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 00:47:01 +0200 Subject: [PATCH 1/7] feat(remotes): block a plain git push of the monorepo to a subtree remote A subtree remote is an ordinary Git remote, so any push to it that isn't a `git subtree split` publishes the whole monorepo there, including folders never meant to leave it. That doesn't take a typo: with `push.autoSetupRemote` and no `origin`, or once a branch tracks a subtree remote, a plain `git push` or an IDE's sync button goes there. Deleting the branch afterwards doesn't unpublish the commits. `init` now sets the remote's push URL to one Git can't push to, so every push by the remote's name fails before anything is sent, and Git's error message names the command to use instead. Unlike a pre-push hook, it can't be skipped with --no-verify and doesn't compete with hook managers. `git subtrees push` rewrites exactly that URL to the fetch URL for its own push, via url..insteadOf. The push still goes through the remote's name, so the tracking refs stay current without a fetch, and a remote with a push URL of its own keeps pushing there. An empty remote..pushurl override would read simpler, but Git only resets the URL list that way since 2.46. `status` marks each subtree [push-protected] or [NOT push-protected] and prints the command that protects an unprotected one, so existing clones get there too. The remote URLs it used to print are gone; for consistency, unmapped remotes now read [no mapping]. The push-protection scenario walks through the problem and the protection, for anyone wondering about the odd push URL. Closes #50 --- README.md | 15 +- lib/common.sh | 41 ++++- lib/init.sh | 8 + lib/push.sh | 5 +- lib/status.sh | 61 +++++-- test/cli.bats | 2 +- test/common.bats | 69 ++++++-- test/fetch.bats | 16 +- test/helpers/fixtures.bash | 19 ++- test/init.bats | 50 +++++- test/merge.bats | 2 +- test/prune.bats | 8 +- test/pull.bats | 2 +- test/push.bats | 48 +++++- .../diverged-common-ancestor/README.md | 2 +- test/scenarios/diverged-then-pulled/README.md | 4 +- .../diverged-unrelated-history/README.md | 5 +- .../feature-branch-changed/README.md | 4 +- .../feature-branch-unchanged/README.md | 2 +- test/scenarios/init-copied-content/README.md | 3 +- .../init-on-feature-branch/README.md | 5 +- .../init-unrelated-content/README.md | 1 + test/scenarios/init-without-commits/README.md | 1 + test/scenarios/not-connected/README.md | 2 +- test/scenarios/not-connected/setup.bash | 2 +- test/scenarios/pull-ahead/README.md | 4 +- test/scenarios/push-ahead/README.md | 4 +- test/scenarios/push-protection/README.md | 160 ++++++++++++++++++ test/scenarios/push-protection/setup.bash | 17 ++ test/scenarios/pushed-then-changed/README.md | 4 +- test/scenarios/pushed-then-changed/setup.bash | 2 +- test/scenarios/shared-remote-url/README.md | 12 +- test/scenarios/up-to-date/README.md | 2 +- test/status.bats | 53 +++++- walkthrough/README.md | 15 +- walkthrough/diverged.md | 6 +- walkthrough/feature-branches.md | 30 ++-- walkthrough/setup.sh | 2 + 38 files changed, 582 insertions(+), 106 deletions(-) create mode 100644 test/scenarios/push-protection/README.md create mode 100644 test/scenarios/push-protection/setup.bash diff --git a/README.md b/README.md index e5f29d6..424f5f1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,10 @@ repo: git remote add vendor/foo # vendor/foo is now a subtree That's all the configuration there is. `git subtrees status` lists what it -found. Everything follows from two rules: +found, and warns about each subtree remote that isn't push-protected: a +plain `git push vendor/foo` sends the whole monorepo there, not just the +folder ([why and how](test/scenarios/push-protection/README.md)). `git +subtrees init` protects the remotes it sets up. Everything follows from two rules: 1. **The remote name is the folder path.** If you move the folder, also run `git remote rename `. @@ -79,8 +82,11 @@ starting a feature branch doesn't create empty branches on every remote. If none of these work, `push` asks for `--base` instead of guessing. **Setting up a subtree.** `git subtrees init ` adds the remote -if it's missing. It never changes the URL of an existing remote. If -`` doesn't exist yet, it runs `git subtree add` to bring in the +if it's missing. It never changes the URL of an existing remote, but if +the remote has no push URL of its own, `init` sets one that Git can't push +to, so only `git subtrees push` can push there +([why](test/scenarios/push-protection/README.md)). If `` doesn't +exist yet, it runs `git subtree add` to bring in the remote's content. On a branch the remote doesn't have yet, it adds the remote's base branch instead (found like for `push`), and your first `push` creates your branch on top of it. If the remote has neither (e.g. it's @@ -121,6 +127,9 @@ More scenarios: `init` in a brand-new monorepo that has no commits yet. - [`nested-subtrees`](test/scenarios/nested-subtrees/README.md): why one subtree inside another is refused. +- [`push-protection`](test/scenarios/push-protection/README.md): why a + plain `git push` to a subtree remote is blocked, and how + `git subtrees push` still gets through. - [`shared-remote-url`](test/scenarios/shared-remote-url/README.md): two subtrees with the same remote URL act like two clones of one repo. diff --git a/lib/common.sh b/lib/common.sh index 7bc975b..012e704 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -166,6 +166,35 @@ target_ref_for() { printf 'refs/remotes/%s/%s\n' "$1" "$2" } +# The push URL of a push-protected subtree remote. Any push to the remote +# that isn't a `git subtree split` sends the whole monorepo there, so a plain +# `git push ` must fail: Git can't find a repository at this URL and +# prints it. It must not contain ':', or Git reads it as an ssh host. +# See test/scenarios/push-protection/README.md. +PUSH_PROTECTED_URL="push with git subtrees push, not git push" + +# Succeeds if remote $1's only push URL is PUSH_PROTECTED_URL. A remote +# with a push URL of its own is left alone, and isn't protected. +is_push_protected() { + [[ "$(git config --get-all "remote.$1.pushurl" 2>/dev/null)" == "$PUSH_PROTECTED_URL" ]] +} + +# Runs "$@" with PUSH_PROTECTED_URL rewritten to remote $1's fetch URL, so a +# push to the remote by name gets through and updates its tracking refs. +# The rewrite only touches that one URL: a remote with a push URL of its own +# keeps pushing there. Passed through the environment rather than `git -c`, +# which splits at the first '=' a URL may contain. +with_push_allowed() { + local remote="$1" url n="${GIT_CONFIG_COUNT:-0}" + shift + url="$(git remote get-url -- "$remote")" || return + ( + export GIT_CONFIG_COUNT=$((n + 1)) + export "GIT_CONFIG_KEY_$n=url.$url.insteadOf" "GIT_CONFIG_VALUE_$n=$PUSH_PROTECTED_URL" + "$@" + ) +} + # False for a name starting with '-'. git-subtree's own OPTS_SPEC parsing # (git rev-parse --parseopt) matches a handful of dash-prefixed values as # its own flags no matter where they appear (-h/--help, -q/--quiet, ...), @@ -322,7 +351,7 @@ touched_since_sync() { } # Classifies subtree 's sync state against remote 's . -# Sets SUBTREE_STATE, SUBTREE_TARGET_REF, SUBTREE_URL, SUBTREE_SPLIT_SHA as +# Sets SUBTREE_STATE, SUBTREE_TARGET_REF, SUBTREE_SPLIT_SHA as # globals rather than returning a value, since callers (status, push, pull) # need them. # @@ -352,7 +381,6 @@ classify_subtree() { SUBTREE_STATE="" SUBTREE_TARGET_REF="" SUBTREE_SPLIT_SHA="" - SUBTREE_URL="$(git remote get-url -- "$remote" 2>/dev/null || true)" if [[ -z "$(git for-each-ref "refs/remotes/$remote/")" ]]; then SUBTREE_STATE="not-connected" @@ -459,6 +487,13 @@ print_unrelated_history_guidance() { q_tmp="$(shell_quote "$tmp_branch")" q_msg="$(shell_quote "remove $path before re-adopting it from its remote")" q_refspec="$(shell_quote "$tmp_branch:$branch")" + # A push-protected remote refuses the force push by name, so it goes to + # the fetch URL, and a fetch updates the tracking ref the push didn't. + local push_cmd="git push --force $q_path $q_refspec" + if is_push_protected "$path"; then + push_cmd="git push --force $(shell_quote "$(git remote get-url -- "$path")") $q_refspec" + push_cmd+=$'\n'" git fetch $q_path" + fi log_warn "$path: remote and local share no history -- pick one side manually:" cat >&2 <' can't +send the whole monorepo there. 'git subtrees push' still works. EOF } @@ -117,6 +121,10 @@ cmd_init() { log_step "$path: registering remote -> $url" git remote add -- "$path" "$url" fi + if [[ -z "$(git config --get-all "remote.$path.pushurl" 2>/dev/null)" ]]; then + git remote set-url --push -- "$path" "$PUSH_PROTECTED_URL" + log_step "$path: push-protected -- a plain 'git push $(shell_quote "$path")' fails, 'git subtrees push' works" + fi log_step "$path: fetching" local rc=0 diff --git a/lib/push.sh b/lib/push.sh index ee6bff3..f156156 100644 --- a/lib/push.sh +++ b/lib/push.sh @@ -19,6 +19,9 @@ creates the missing branch. On an unrelated-history divergence (no shared ancestor at all), push does not attempt to push -- it prints manual recovery commands instead. + +A push-protected remote (see 'git subtrees status') is pushed to at its +fetch URL. A remote with a push URL of its own is pushed to there. EOF } @@ -81,7 +84,7 @@ push_one() { push | diverged) ;; esac - if ! git subtree push --prefix="$path" "$path" "$branch"; then + if ! with_push_allowed "$path" git subtree push --prefix="$path" "$path" "$branch"; then log_err "$path: push failed" return 1 fi diff --git a/lib/status.sh b/lib/status.sh index 33eebe5..7c07cac 100644 --- a/lib/status.sh +++ b/lib/status.sh @@ -5,18 +5,52 @@ usage_status() { usage: git subtrees status [--base ] [path...] Shows the sync state of every subtree, plus every registered remote that -has no matching directory ("no mapping"). Purely local -- run 'git subtrees +has no matching directory ([no mapping]). Purely local -- run 'git subtrees fetch' first for up-to-date results. Defaults to every discovered subtree when no paths are given. For a subtree whose remote has no branch named like the current one, the state is whether the subtree changed on this branch compared with the monorepo's base branch (--base, else origin/HEAD, else init.defaultBranch). + +Each subtree is marked [push-protected] or [NOT push-protected]. A plain +'git push' to a remote that isn't protected sends the whole monorepo there; +status ends with the command that protects it. EOF } status_warn() { printf '?? %s\n' "$*"; } +# Prints "$1$2$3", with ANSI codes $1 and $3 only if Git would color +# `git status` here (color.status, else color.ui; default: on a terminal). +colorize() { + local tty=false + [[ -t 1 ]] && tty=true + if [[ "$(git config --get-colorbool color.status "$tty")" == true ]]; then + printf '%s%s%s' "$1" "$2" "$3" + else + printf '%s' "$2" + fi +} + +# Prints subtree path $1 followed by whether its remote is push-protected. +subtree_label() { + if is_push_protected "$1"; then + printf '%s [push-protected]' "$1" + else + printf '%s %s' "$1" "$(colorize $'\e[31m' '[NOT push-protected]' $'\e[m')" + fi +} + +# Warns about subtree path $1's remote not being push-protected, with the +# command that protects it. +format_unprotected_warning() { + local q_path + q_path="$(shell_quote "$1")" + colorize $'\e[31m' "!! $1: a plain 'git push $q_path' sends the whole monorepo there -- push-protect it with:" $'\e[m' + printf '\n\n git remote set-url --push %s %s\n\n' "$q_path" "$(shell_quote "$PUSH_PROTECTED_URL")" +} + # Prints the status of a subtree whose remote has no branch like the current # one: what changed on this branch compared with the monorepo's base branch. format_missing_branch_line() { @@ -24,23 +58,23 @@ format_missing_branch_line() { changes_vs_base "$path" "$base" case "$SUBTREE_CHANGES_VS_BASE" in no) - log_ok "$path -> $SUBTREE_URL (no '$branch' branch on remote; unchanged since '$SUBTREE_BASE_BRANCH')" + log_ok "$(subtree_label "$path") (no '$branch' branch on remote; unchanged since '$SUBTREE_BASE_BRANCH')" ;; yes) - log_ok "$path -> $SUBTREE_URL (no '$branch' branch on remote; changed since '$SUBTREE_BASE_BRANCH' -- push would create it)" + log_ok "$(subtree_label "$path") (no '$branch' branch on remote; changed since '$SUBTREE_BASE_BRANCH' -- push would create it)" git --no-pager diff --stat "$SUBTREE_BASE_MERGE_BASE" HEAD -- "$path" 2>/dev/null || true ;; self) - status_warn "$path -> $SUBTREE_URL (remote has no '$branch' branch)" + status_warn "$(subtree_label "$path") (remote has no '$branch' branch)" ;; error) - status_warn "$path -> $SUBTREE_URL (no '$branch' branch on remote; could not compare with base branch '$SUBTREE_BASE_BRANCH')" + status_warn "$(subtree_label "$path") (no '$branch' branch on remote; could not compare with base branch '$SUBTREE_BASE_BRANCH')" ;; unresolved) if [[ -n "$base" ]]; then - status_warn "$path -> $SUBTREE_URL (no '$branch' branch on remote; base branch '$base' not found, or it shares no history)" + status_warn "$(subtree_label "$path") (no '$branch' branch on remote; base branch '$base' not found, or it shares no history)" else - status_warn "$path -> $SUBTREE_URL (no '$branch' branch on remote; monorepo base branch unknown -- pass --base )" + status_warn "$(subtree_label "$path") (no '$branch' branch on remote; monorepo base branch unknown -- pass --base )" fi ;; esac @@ -53,16 +87,16 @@ format_status_line() { case "$SUBTREE_STATE" in not-connected) - status_warn "$path -> $SUBTREE_URL (never fetched -- run 'git subtrees fetch $path')" + status_warn "$(subtree_label "$path") (never fetched -- run 'git subtrees fetch $path')" ;; missing-at-head) format_missing_branch_line "$path" "$branch" "$base" ;; up-to-date) - log_ok "$path -> $SUBTREE_URL (up to date)" + log_ok "$(subtree_label "$path") (up to date)" ;; push | pull | diverged) - log_ok "$path -> $SUBTREE_URL ($SUBTREE_STATE)" + log_ok "$(subtree_label "$path") ($SUBTREE_STATE)" local local_tree local_tree="$(git rev-parse "HEAD:$path" 2>/dev/null || true)" # Diff order follows what the pending operation would apply, so @@ -76,14 +110,14 @@ format_status_line() { esac ;; unrelated-history) - status_warn "$path -> $SUBTREE_URL (unrelated history -- see 'git subtrees pull $path' for options)" + status_warn "$(subtree_label "$path") (unrelated history -- see 'git subtrees pull $path' for options)" ;; esac } format_unmapped_remote_line() { local remote="$1" - printf '?? %s -> (no mapping)\n' "$remote" + printf '?? %s [no mapping]\n' "$remote" } cmd_status() { @@ -118,4 +152,7 @@ cmd_status() { for path in "${paths[@]}"; do format_status_line "$path" "$branch" "$base" done + for path in "${paths[@]}"; do + is_push_protected "$path" || format_unprotected_warning "$path" + done } diff --git a/test/cli.bats b/test/cli.bats index d9965d6..8d79215 100644 --- a/test/cli.bats +++ b/test/cli.bats @@ -132,7 +132,7 @@ setup() { [ -z "$(git for-each-ref refs/remotes/vendor/pkg/extra/)" ] run "$entrypoint" status [ "$status" -eq 0 ] - [[ "$output" == *"vendor/pkg -> $upstream"* ]] + [[ "$output" == *"vendor/pkg [push-protected] ("* ]] } @test "cli: the printed nested-subtree fix is safe to run for a name with shell metacharacters" { diff --git a/test/common.bats b/test/common.bats index 5e22175..cf650ea 100644 --- a/test/common.bats +++ b/test/common.bats @@ -20,7 +20,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git remote add ghost "$upstream" # no matching dir discover_subtrees @@ -36,9 +36,9 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p packages/alpha packages/bravo packages/charlie - git remote add packages/alpha "$upstream" - git remote add packages/bravo "$upstream" - git remote add packages/charlie "$upstream" + add_subtree_remote packages/alpha "$upstream" + add_subtree_remote packages/bravo "$upstream" + add_subtree_remote packages/charlie "$upstream" discover_subtrees @@ -123,7 +123,7 @@ setup() { local theirs theirs="$(GIT_AUTHOR_DATE='2001-01-01T00:00:00' GIT_COMMITTER_DATE='2001-01-01T00:00:00' \ git commit-tree 'vendor/a/main^{tree}' -p 'vendor/a/main~1' -m "their change")" - git push -q --force vendor/a "$theirs:refs/heads/main" + git push -q --force "$upstream" "$theirs:refs/heads/main" git fetch -q vendor/a classify_subtree "vendor/a" "main" [ "$SUBTREE_STATE" = "diverged" ] @@ -203,7 +203,7 @@ setup() { mkdir -p vendor/a echo "pre-existing" >vendor/a/other.txt git add vendor/a && git commit -q -m "pre-existing" - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a classify_subtree "vendor/a" "main" [ "$SUBTREE_STATE" = "unrelated-history" ] @@ -216,17 +216,50 @@ setup() { [ "$SUBTREE_STATE" = "unrelated-history" ] } -@test "classify_subtree: resolves the URL of a remote named like a flag" { +@test "with_push_allowed: lets a protected remote named like a flag be pushed to at its fetch URL" { make_bare_repo "$upstream" - seed_bare_repo "$upstream" "seed" init_monorepo "$monorepo" cd "$monorepo" - mkdir -p -- -n git remote add -- -n "$upstream" - git fetch -q -- -n + git remote set-url --push -- -n "$PUSH_PROTECTED_URL" + + is_push_protected -n + [ "$(with_push_allowed -n git remote get-url --push -- -n)" = "$upstream" ] + [ "$(git remote get-url --push -- -n)" = "$PUSH_PROTECTED_URL" ] +} + +@test "with_push_allowed: keeps a remote's own push URL" { + make_bare_repo "$upstream" + init_monorepo "$monorepo" + cd "$monorepo" + git remote add vendor/a "$upstream" + git remote set-url --push vendor/a "$BATS_TEST_TMPDIR/elsewhere.git" + + ! is_push_protected vendor/a + [ "$(with_push_allowed vendor/a git remote get-url --push vendor/a)" = "$BATS_TEST_TMPDIR/elsewhere.git" ] +} + +@test "with_push_allowed: keeps config passed in the environment by the caller" { + make_bare_repo "$upstream" + init_monorepo "$monorepo" + cd "$monorepo" + git remote add vendor/a "$upstream" + git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" - classify_subtree "-n" "main" - [ "$SUBTREE_URL" = "$upstream" ] + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=test.key GIT_CONFIG_VALUE_0=kept \ + run with_push_allowed vendor/a bash -c 'git config test.key && git remote get-url --push vendor/a' + [ "$status" -eq 0 ] + [ "$output" = "kept"$'\n'"$upstream" ] +} + +@test "is_push_protected: not for a remote with another push URL besides the protected one" { + init_monorepo "$monorepo" + cd "$monorepo" + git remote add vendor/a "$upstream" + git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" + git remote set-url --add --push vendor/a "$upstream" + + ! is_push_protected vendor/a } @test "classify_subtree: not-connected" { @@ -241,7 +274,7 @@ setup() { seed_bare_repo "$upstream" "seed" init_monorepo "$monorepo" cd "$monorepo" - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a git checkout -q -b feature mkdir -p vendor/a @@ -456,3 +489,13 @@ add_monorepo_origin() { [[ "$output" == *"git rm -r 'vendor/x;id'"* ]] [[ "$output" == *"git push --force 'vendor/x;id' 'tmp-split-x;id:main'"* ]] } + +@test "print_unrelated_history_guidance: force-pushes a protected remote at its fetch URL, then fetches" { + init_monorepo "$monorepo" + cd "$monorepo" + git remote add vendor/a "$BATS_TEST_TMPDIR/up stream.git" + git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" + + run print_unrelated_history_guidance vendor/a main + [[ "$output" == *"git push --force '$BATS_TEST_TMPDIR/up stream.git' tmp-split-a:main"$'\n'" git fetch vendor/a"$'\n'* ]] +} diff --git a/test/fetch.bats b/test/fetch.bats index b5eeff8..1a90b49 100644 --- a/test/fetch.bats +++ b/test/fetch.bats @@ -11,7 +11,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" run cmd_fetch [ "$status" -eq 0 ] @@ -29,8 +29,8 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p changed unchanged - git remote add changed "$changed" - git remote add unchanged "$unchanged" + add_subtree_remote changed "$changed" + add_subtree_remote unchanged "$unchanged" git fetch -q changed git fetch -q unchanged git tag main @@ -55,7 +55,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p -- -n - git remote add -- -n "$upstream" + add_subtree_remote -n "$upstream" run cmd_fetch -- -n [ "$status" -eq 0 ] @@ -71,7 +71,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a git tag local-only git config --add remote.vendor/a.fetch "+refs/tags/*:refs/tags/*" @@ -93,8 +93,8 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a vendor/b - git remote add vendor/a "$up_a" - git remote add vendor/b "$up_b" # never created -- fetch will fail + add_subtree_remote vendor/a "$up_a" + add_subtree_remote vendor/b "$up_b" # never created -- fetch will fail run cmd_fetch [ "$status" -eq 1 ] @@ -107,7 +107,7 @@ setup() { @test "fetch_one preserves diagnostics for branch fetch failures" { init_monorepo "$monorepo" cd "$monorepo" - git remote add vendor/a "$BATS_TEST_TMPDIR/missing.git" + add_subtree_remote vendor/a "$BATS_TEST_TMPDIR/missing.git" run fetch_one vendor/a main diff --git a/test/helpers/fixtures.bash b/test/helpers/fixtures.bash index 786bb06..65f3a00 100644 --- a/test/helpers/fixtures.bash +++ b/test/helpers/fixtures.bash @@ -50,12 +50,29 @@ add_subtree() { local monorepo="$1" remote_url="$2" path="$3" branch="${4:-main}" ( cd "$monorepo" - git remote add "$path" "$remote_url" + add_subtree_remote "$path" "$remote_url" git fetch -q "$path" git subtree add -q --prefix="$path" "$path" "$branch" --squash ) } +# Adds remote $1 with URL $2, push-protected as `git subtrees init` leaves a +# subtree remote. +add_subtree_remote() { + git remote add -- "$1" "$2" + git remote set-url --push -- "$1" "$(push_protected_url)" +} + +# Prints lib/common.sh's PUSH_PROTECTED_URL, for scripts that don't source +# lib/ (the scenario READMEs only source this file). +push_protected_url() { + ( + # shellcheck disable=SC1091 + source "$(dirname "${BASH_SOURCE[0]}")/../../lib/common.sh" + printf '%s\n' "$PUSH_PROTECTED_URL" + ) +} + # Ignores the developer's own git config (e.g. a global init.defaultBranch), # so tests about how the base branch is resolved behave the same everywhere. # Bats runs each test in its own subshell, so this never leaks. diff --git a/test/init.bats b/test/init.bats index e71f71e..b59333a 100644 --- a/test/init.bats +++ b/test/init.bats @@ -108,7 +108,7 @@ setup() { seed_bare_repo "$upstream" "seed" init_monorepo "$monorepo" cd "$monorepo" - git remote add vendor/a "/some/other/url" + add_subtree_remote vendor/a "/some/other/url" run cmd_init "vendor/a" "$upstream" [ "$status" -eq 1 ] @@ -295,7 +295,7 @@ setup() { scenario_init_on_feature_branch "$monorepo" "$upstream" cd "$monorepo" # Left over from an earlier fetch of a remote 'feature' that is gone now. - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a git update-ref refs/remotes/vendor/a/feature refs/remotes/vendor/a/main @@ -404,3 +404,49 @@ setup() { [ "$status" -ne 0 ] [ "$(git -C "$upstream" log --format=%s feature)" = $'feature change\nseed' ] } + +@test "init: push-protects the remote it registers, so a plain git push fails" { + make_bare_repo "$upstream" + seed_bare_repo "$upstream" "seed" + init_monorepo "$monorepo" + cd "$monorepo" + + run cmd_init "vendor/a" "$upstream" + [ "$status" -eq 0 ] + [[ "$output" == *"vendor/a: push-protected"* ]] + is_push_protected vendor/a + + run git push vendor/a HEAD:refs/heads/oops + [ "$status" -ne 0 ] + [[ "$output" == *"'$PUSH_PROTECTED_URL' does not appear to be a git repository"* ]] + run git -C "$upstream" rev-parse --verify --quiet refs/heads/oops + [ "$status" -ne 0 ] +} + +@test "init: push-protects an already initialized subtree's remote" { + make_bare_repo "$upstream" + seed_bare_repo "$upstream" "seed" + init_monorepo "$monorepo" + add_subtree "$monorepo" "$upstream" "vendor/a" + cd "$monorepo" + ! is_push_protected vendor/a + + run cmd_init "vendor/a" "$upstream" + [ "$status" -eq 0 ] + [[ "$output" == *"already initialized"* ]] + is_push_protected vendor/a +} + +@test "init: keeps a remote's own push URL" { + make_bare_repo "$upstream" + seed_bare_repo "$upstream" "seed" + init_monorepo "$monorepo" + cd "$monorepo" + git remote add vendor/a "$upstream" + git remote set-url --push vendor/a "$BATS_TEST_TMPDIR/elsewhere.git" + + run cmd_init "vendor/a" "$upstream" + [ "$status" -eq 0 ] + [[ "$output" != *"push-protected"* ]] + [ "$(git config --get-all remote.vendor/a.pushurl)" = "$BATS_TEST_TMPDIR/elsewhere.git" ] +} diff --git a/test/merge.bats b/test/merge.bats index 57d12db..4a9c5f1 100644 --- a/test/merge.bats +++ b/test/merge.bats @@ -113,7 +113,7 @@ setup() { cd "$fresh_monorepo" git config user.name "Test" git config user.email "test@example.com" - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a "+refs/heads/main:refs/remotes/vendor/a/main" # The remote is gone: any attempt to reach it would fail loudly. git remote set-url vendor/a "$BATS_TEST_TMPDIR/does-not-exist.git" diff --git a/test/prune.bats b/test/prune.bats index 228af81..2deaa9c 100644 --- a/test/prune.bats +++ b/test/prune.bats @@ -12,7 +12,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a git -C "$upstream" update-ref -d refs/heads/temporary @@ -31,7 +31,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a git -C "$upstream" update-ref -d refs/heads/temporary cd vendor/a @@ -49,7 +49,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p -- -n - git remote add -- -n "$upstream" + add_subtree_remote -n "$upstream" git fetch -q -- -n git -C "$upstream" update-ref -d refs/heads/temporary @@ -67,7 +67,7 @@ setup() { init_monorepo "$monorepo" cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a git tag local-only git config --add remote.vendor/a.fetch "+refs/tags/*:refs/tags/*" diff --git a/test/pull.bats b/test/pull.bats index fa27d7a..7f43f5c 100644 --- a/test/pull.bats +++ b/test/pull.bats @@ -206,7 +206,7 @@ setup() { cd "$fresh_monorepo" git config user.name "Test" git config user.email "test@example.com" - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git config --add remote.vendor/a.fetch "+refs/tags/*:refs/tags/*" run cmd_pull vendor/a diff --git a/test/push.bats b/test/push.bats index 71ad93a..200b223 100644 --- a/test/push.bats +++ b/test/push.bats @@ -58,7 +58,7 @@ setup() { after="$(git -C "$upstream" rev-parse main)" [ "$before" = "$after" ] [[ "$output" == *"share no history"* ]] - [[ "$output" == *"git push --force vendor/a"* ]] + [[ "$output" == *"git push --force $upstream tmp-split-a:main"* ]] } @test "push_one: refuses a branch git-subtree cannot use, without classifying" { @@ -158,7 +158,7 @@ remote_has_branch() { mkdir -p vendor/a echo "content" >vendor/a/file.txt git add vendor/a && git commit -q -m "add vendor/a" - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" git fetch -q vendor/a run push_one "vendor/a" "main" "main" [ "$status" -eq 0 ] @@ -304,3 +304,47 @@ remote_has_branch() { [ "$status" -eq 0 ] git -C "$upstream" merge-base --is-ancestor "$before" feature-1 } + +@test "push: pushes a push-protected remote at its fetch URL and updates its tracking ref" { + scenario_push_ahead "$monorepo" "$upstream" + cd "$monorepo" + git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" + + run push_one "vendor/a" "main" + [ "$status" -eq 0 ] + [ "$(git rev-parse refs/remotes/vendor/a/main)" = "$(git -C "$upstream" rev-parse main)" ] + classify_subtree "vendor/a" "main" + [ "$SUBTREE_STATE" = "up-to-date" ] + is_push_protected vendor/a +} + +@test "push: a remote with its own push URL is pushed there" { + scenario_push_ahead "$monorepo" "$upstream" + local mirror="$BATS_TEST_TMPDIR/mirror.git" + git clone -q --bare "$upstream" "$mirror" + cd "$monorepo" + git remote set-url --push vendor/a "$mirror" + local before + before="$(git -C "$upstream" rev-parse main)" + + run push_one "vendor/a" "main" + [ "$status" -eq 0 ] + [ "$(git -C "$upstream" rev-parse main)" = "$before" ] + [ "$(git -C "$mirror" rev-parse main)" != "$before" ] +} + +@test "push: the printed commands that keep the local side work on a push-protected remote" { + scenario_diverged_unrelated_history "$monorepo" "$upstream" + cd "$monorepo" + is_push_protected vendor/a + + run push_one "vendor/a" "main" + [ "$status" -eq 1 ] + local keep_local + keep_local="$(sed -n '/OR: accept the local/,/git branch -D/p' <<<"$output" | grep -v '^ *#')" + eval "$keep_local" + + [ "$(git -C "$upstream" rev-parse 'main^{tree}')" = "$(git rev-parse HEAD:vendor/a)" ] + classify_subtree "vendor/a" "main" + [ "$SUBTREE_STATE" = "up-to-date" ] +} diff --git a/test/scenarios/diverged-common-ancestor/README.md b/test/scenarios/diverged-common-ancestor/README.md index ce6f6fc..0506eee 100644 --- a/test/scenarios/diverged-common-ancestor/README.md +++ b/test/scenarios/diverged-common-ancestor/README.md @@ -26,7 +26,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_diverged_commo ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (diverged) +ok vendor/a [push-protected] (diverged) file.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) ``` diff --git a/test/scenarios/diverged-then-pulled/README.md b/test/scenarios/diverged-then-pulled/README.md index 62c3daa..1874647 100644 --- a/test/scenarios/diverged-then-pulled/README.md +++ b/test/scenarios/diverged-then-pulled/README.md @@ -30,7 +30,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_diverged_then_ ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (push) +ok vendor/a [push-protected] (push) local.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -57,5 +57,5 @@ ok vendor/a: pushed ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/diverged-unrelated-history/README.md b/test/scenarios/diverged-unrelated-history/README.md index 7674d24..9bab750 100644 --- a/test/scenarios/diverged-unrelated-history/README.md +++ b/test/scenarios/diverged-unrelated-history/README.md @@ -34,7 +34,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_diverged_unrel ```scrut $ git subtrees status -?? vendor/a -> $UPSTREAM (unrelated history -- see 'git subtrees pull vendor/a' for options) +?? vendor/a [push-protected] (unrelated history -- see 'git subtrees pull vendor/a' for options) ``` ```scrut @@ -49,7 +49,8 @@ ok vendor/a fetched # OR: accept the local (monorepo) version, overwriting vendor/a's history: git subtree split --prefix=vendor/a -b tmp-split-a - git push --force vendor/a tmp-split-a:main + git push --force $UPSTREAM tmp-split-a:main + git fetch vendor/a git branch -D tmp-split-a !! Failed: vendor/a diff --git a/test/scenarios/feature-branch-changed/README.md b/test/scenarios/feature-branch-changed/README.md index 4a7170e..6f3361e 100644 --- a/test/scenarios/feature-branch-changed/README.md +++ b/test/scenarios/feature-branch-changed/README.md @@ -22,7 +22,7 @@ The scenario sets no base branch, so the commands pass `--base main`: ```scrut $ git subtrees status --base main -ok vendor/a -> $UPSTREAM (no 'feature' branch on remote; changed since 'main' -- push would create it) +ok vendor/a [push-protected] (no 'feature' branch on remote; changed since 'main' -- push would create it) vendor/a/file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -50,5 +50,5 @@ ok vendor/a: pushed ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/feature-branch-unchanged/README.md b/test/scenarios/feature-branch-unchanged/README.md index 1bface7..94ee308 100644 --- a/test/scenarios/feature-branch-unchanged/README.md +++ b/test/scenarios/feature-branch-unchanged/README.md @@ -23,7 +23,7 @@ The scenario sets no base branch, so the commands pass `--base main`: ```scrut $ git subtrees status --base main -ok vendor/a -> $UPSTREAM (no 'feature' branch on remote; unchanged since 'main') +ok vendor/a [push-protected] (no 'feature' branch on remote; unchanged since 'main') ``` ```scrut diff --git a/test/scenarios/init-copied-content/README.md b/test/scenarios/init-copied-content/README.md index d415fb5..288a53b 100644 --- a/test/scenarios/init-copied-content/README.md +++ b/test/scenarios/init-copied-content/README.md @@ -46,6 +46,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_init_copied_co ```scrut $ git subtrees init vendor/a "$UPSTREAM" === vendor/a: registering remote -> $UPSTREAM +=== vendor/a: push-protected -- a plain 'git push vendor/a' fails, 'git subtrees push' works === vendor/a: fetching ok vendor/a fetched ok vendor/a: content matches 'main' on the remote -- recorded it as the last sync @@ -68,5 +69,5 @@ $ git log --oneline --graph ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/init-on-feature-branch/README.md b/test/scenarios/init-on-feature-branch/README.md index a23b68f..3750151 100644 --- a/test/scenarios/init-on-feature-branch/README.md +++ b/test/scenarios/init-on-feature-branch/README.md @@ -27,6 +27,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_init_on_featur ```scrut $ git subtrees init vendor/a "$UPSTREAM" === vendor/a: registering remote -> $UPSTREAM +=== vendor/a: push-protected -- a plain 'git push vendor/a' fails, 'git subtrees push' works === vendor/a: fetching ok vendor/a fetched === vendor/a: remote has no 'feature' branch yet -- using its 'main' branch; your first push creates 'feature' @@ -40,7 +41,7 @@ ok vendor/a: added ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (no 'feature' branch on remote; changed since 'main' -- push would create it) +ok vendor/a [push-protected] (no 'feature' branch on remote; changed since 'main' -- push would create it) vendor/a/file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -56,5 +57,5 @@ ok vendor/a: pushed ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/init-unrelated-content/README.md b/test/scenarios/init-unrelated-content/README.md index 13af757..1c85491 100644 --- a/test/scenarios/init-unrelated-content/README.md +++ b/test/scenarios/init-unrelated-content/README.md @@ -24,6 +24,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_init_unrelated ```scrut $ git subtrees init vendor/a "$UPSTREAM" === vendor/a: registering remote -> $UPSTREAM +=== vendor/a: push-protected -- a plain 'git push vendor/a' fails, 'git subtrees push' works === vendor/a: fetching ok vendor/a fetched !! vendor/a: directory exists with content unrelated to $UPSTREAM diff --git a/test/scenarios/init-without-commits/README.md b/test/scenarios/init-without-commits/README.md index e3b6425..093b4fb 100644 --- a/test/scenarios/init-without-commits/README.md +++ b/test/scenarios/init-without-commits/README.md @@ -42,6 +42,7 @@ After an initial commit, the same command adds the subtree: ```scrut $ git commit -q --allow-empty -m 'initial commit' && git subtrees init vendor/a "$UPSTREAM" === vendor/a: registering remote -> $UPSTREAM +=== vendor/a: push-protected -- a plain 'git push vendor/a' fails, 'git subtrees push' works === vendor/a: fetching ok vendor/a fetched === vendor/a: adding subtree from $UPSTREAM diff --git a/test/scenarios/not-connected/README.md b/test/scenarios/not-connected/README.md index 4193b7b..142fae5 100644 --- a/test/scenarios/not-connected/README.md +++ b/test/scenarios/not-connected/README.md @@ -21,5 +21,5 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_not_connected ```scrut $ git subtrees status -?? vendor/a -> $UPSTREAM (never fetched -- run 'git subtrees fetch vendor/a') +?? vendor/a [push-protected] (never fetched -- run 'git subtrees fetch vendor/a') ``` diff --git a/test/scenarios/not-connected/setup.bash b/test/scenarios/not-connected/setup.bash index b55c5a2..ea6fc04 100644 --- a/test/scenarios/not-connected/setup.bash +++ b/test/scenarios/not-connected/setup.bash @@ -7,7 +7,7 @@ scenario_not_connected() { ( cd "$monorepo" mkdir -p vendor/a - git remote add vendor/a "$upstream" + add_subtree_remote vendor/a "$upstream" ) # Deliberately never fetched. } diff --git a/test/scenarios/pull-ahead/README.md b/test/scenarios/pull-ahead/README.md index d8a3bdf..8ba1472 100644 --- a/test/scenarios/pull-ahead/README.md +++ b/test/scenarios/pull-ahead/README.md @@ -24,7 +24,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_pull_ahead ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (pull) +ok vendor/a [push-protected] (pull) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -46,5 +46,5 @@ ok vendor/a: pulled ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/push-ahead/README.md b/test/scenarios/push-ahead/README.md index b3bbcc5..b2f5fd1 100644 --- a/test/scenarios/push-ahead/README.md +++ b/test/scenarios/push-ahead/README.md @@ -21,7 +21,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_push_ahead ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (push) +ok vendor/a [push-protected] (push) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -48,5 +48,5 @@ ok vendor/a: pushed ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/push-protection/README.md b/test/scenarios/push-protection/README.md new file mode 100644 index 0000000..cb2e8bb --- /dev/null +++ b/test/scenarios/push-protection/README.md @@ -0,0 +1,160 @@ +# Scenario: push-protection + +Why `git subtrees init` sets a push URL that Git can't push to, and what +that changes. + +- **Monorepo**: a subtree `vendor/a`, plus a folder `internal/` that must + never leave the monorepo. The remote `vendor/a` was added with a plain + `git remote add`, so it isn't push-protected yet. +- **Remote**: one commit (`seed`), the same as `vendor/a`. + +## The problem + +A subtree remote is an ordinary Git remote. `git subtree push` sends it +the output of `git subtree split`: a history with only the subtree's +files. Any other push to it sends the monorepo's own commits, with **every +folder** in them. That's a slip of the fingers, `git push vendor/a main` +instead of `git push origin main`, but it doesn't have to be one: + +- With `push.autoSetupRemote` and no `origin`, a plain `git push` on a new + branch picks the only remote there is. +- Once a branch's upstream is a subtree remote, every plain `git push`, an + IDE's "Sync" button, or a Git GUI's push goes there. +- A reverse search for `push vendor/a` in your shell history finds + `git push vendor/a main` as well as `git subtree push ... vendor/a main`. + +When the remote already has the branch, a plain push is usually rejected +as non-fast-forward. New branches and `--force` go through. Deleting the +branch afterwards doesn't unpublish anything: hosts like GitHub keep the +commits reachable by their hash, and anyone who fetched in the meantime has +a copy. [Issue #50](https://github.com/roschaefer/git-subtrees/issues/50) +has the details and the other options that were considered. + +## The protection + +A remote can have a push URL that differs from its fetch URL. +`git subtrees init` sets it to `push with git subtrees push, not git push`. +Fetching is unaffected. Any push to the remote by its name fails, since Git +finds no repository at that URL, and the error message says what to do. +`git push --no-verify` doesn't get around it, and no hook is involved. + +`git subtrees push` rewrites exactly that URL to the fetch URL for its own +push, and only for that push. It still pushes by the remote's name, so Git +updates the remote's tracking refs as before: no `fetch` is needed after a +push, protected or not. + +The protection is part of the local repository's config, like the remote +itself, so every clone of the monorepo needs it again. `git subtrees init` +sets it for a remote that has no push URL yet. A remote with a push URL of +its own keeps it, and `git subtrees push` pushes there; it just doesn't +count as protected. + +## Output + +`scenario_push_protection` in [`setup.bash`](setup.bash) +builds this state. [How scenarios work](../README.md). + + + +`status` warns about the remote that isn't protected: + +```scrut +$ git subtrees status +ok vendor/a [NOT push-protected] (up to date) +!! vendor/a: a plain 'git push vendor/a' sends the whole monorepo there -- push-protect it with: + + git remote set-url --push vendor/a 'push with git subtrees push, not git push' + +``` + +Unprotected, `git subtrees push` works as you'd expect: + +```scrut +$ echo "first change" >>vendor/a/file.txt && git commit -qam "change vendor/a" +``` + +```scrut +$ git subtrees push +git push using: vendor/a main +To $UPSTREAM + bde4164..88c45ad 88c45ad56d81d8a01be6d6dd6a51e910048361f8 -> main +ok vendor/a: pushed +``` + +But so does the slip. A plain push to a new branch publishes the whole +monorepo, `internal/` included: + +```scrut +$ git push vendor/a main:oops +To $UPSTREAM + * [new branch] main -> oops +``` + +```scrut +$ git -C "$UPSTREAM" ls-tree -r --name-only oops +internal/notes.txt +vendor/a/file.txt +``` + +Protect the remote with the command `status` printed (or by running +`git subtrees init vendor/a ` again): + +```scrut +$ git remote set-url --push vendor/a 'push with git subtrees push, not git push' +``` + +```scrut +$ git subtrees status +ok vendor/a [push-protected] (up to date) +``` + +Now the same slip fails, before anything is sent: + +```scrut +$ git push vendor/a main:oops-again +fatal: 'push with git subtrees push, not git push' does not appear to be a git repository +fatal: Could not read from remote repository. + +Please make sure you have the correct access rights +and the repository exists. +[128] +``` + +So does a plain `git subtree push`, which goes through the remote's name +too. Use `git subtrees push`: + +```scrut +$ git subtree push --prefix=vendor/a vendor/a main +git push using: vendor/a main +fatal: 'push with git subtrees push, not git push' does not appear to be a git repository +fatal: Could not read from remote repository. + +Please make sure you have the correct access rights +and the repository exists. +[128] +``` + +```scrut +$ echo "second change" >>vendor/a/file.txt && git commit -qam "change vendor/a again" +``` + +```scrut +$ git subtrees push +git push using: vendor/a main +To $UPSTREAM + 88c45ad..d4528c1 d4528c1d6dd7e914809f9cf9d4bd1e3061dcbba2 -> main +ok vendor/a: pushed +``` + +The output is the same as without the protection, and so is the result: +the tracking ref moved with the push, so `status` is up to date without a +`fetch`: + +```scrut +$ git subtrees status +ok vendor/a [push-protected] (up to date) +``` diff --git a/test/scenarios/push-protection/setup.bash b/test/scenarios/push-protection/setup.bash new file mode 100644 index 0000000..e882510 --- /dev/null +++ b/test/scenarios/push-protection/setup.bash @@ -0,0 +1,17 @@ +# See README.md in this directory. +scenario_push_protection() { + local monorepo="$1" upstream="$2" + make_bare_repo "$upstream" + seed_bare_repo "$upstream" "seed" + init_monorepo "$monorepo" + add_subtree "$monorepo" "$upstream" "vendor/a" + ( + cd "$monorepo" + # As if added with a plain `git remote add`, not `git subtrees init`. + git config --unset remote.vendor/a.pushurl + mkdir internal + echo "not for the public" >internal/notes.txt + git add internal + git commit -q -m "add internal notes" + ) +} diff --git a/test/scenarios/pushed-then-changed/README.md b/test/scenarios/pushed-then-changed/README.md index 8f42b4a..ae61291 100644 --- a/test/scenarios/pushed-then-changed/README.md +++ b/test/scenarios/pushed-then-changed/README.md @@ -29,7 +29,7 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_pushed_then_ch ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (push) +ok vendor/a [push-protected] (push) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -44,5 +44,5 @@ ok vendor/a: pushed ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` diff --git a/test/scenarios/pushed-then-changed/setup.bash b/test/scenarios/pushed-then-changed/setup.bash index f4ccb71..8a78721 100644 --- a/test/scenarios/pushed-then-changed/setup.bash +++ b/test/scenarios/pushed-then-changed/setup.bash @@ -10,7 +10,7 @@ scenario_pushed_then_changed() { echo "pushed change" >>vendor/a/file.txt git add vendor/a/file.txt git commit -q -m "pushed change" - git subtree push -q --prefix=vendor/a vendor/a main >/dev/null 2>&1 + git subtree push -q --prefix=vendor/a "$upstream" main >/dev/null 2>&1 git fetch -q vendor/a echo "later change" >>vendor/a/file.txt git add vendor/a/file.txt diff --git a/test/scenarios/shared-remote-url/README.md b/test/scenarios/shared-remote-url/README.md index 90a26c4..88a9dda 100644 --- a/test/scenarios/shared-remote-url/README.md +++ b/test/scenarios/shared-remote-url/README.md @@ -30,8 +30,8 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_shared_remote_ ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) -ok vendor/b -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) +ok vendor/b [push-protected] (up to date) ``` A push from `vendor/a` shows up as `pull` for `vendor/b`: @@ -57,8 +57,8 @@ ok vendor/b fetched (main moved bde4164..7f225db) ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) -ok vendor/b -> $UPSTREAM (pull) +ok vendor/a [push-protected] (up to date) +ok vendor/b [push-protected] (pull) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -76,6 +76,6 @@ ok vendor/b: pulled ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) -ok vendor/b -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) +ok vendor/b [push-protected] (up to date) ``` diff --git a/test/scenarios/up-to-date/README.md b/test/scenarios/up-to-date/README.md index f8d6d08..da3fb1d 100644 --- a/test/scenarios/up-to-date/README.md +++ b/test/scenarios/up-to-date/README.md @@ -24,7 +24,7 @@ Nothing to do on either side: ```scrut $ git subtrees status -ok vendor/a -> $UPSTREAM (up to date) +ok vendor/a [push-protected] (up to date) ``` ```scrut diff --git a/test/status.bats b/test/status.bats index 2de9a72..5dbc6a4 100644 --- a/test/status.bats +++ b/test/status.bats @@ -87,7 +87,7 @@ setup() { cd "$monorepo" git remote add ghost "$upstream" run cmd_status - [[ "$output" == *"ghost -> (no mapping)"* ]] + [[ "$output" == *"ghost [no mapping]"* ]] } @test "status: does not print unmapped remote URL" { @@ -97,7 +97,7 @@ setup() { run cmd_status - [[ "$output" == *"origin -> (no mapping)"* ]] + [[ "$output" == *"origin [no mapping]"* ]] [[ "$output" != *"token"* ]] [[ "$output" != *"example.com"* ]] } @@ -174,3 +174,52 @@ setup() { [ "$status" -eq 1 ] [[ "$output" == *"--base needs a branch name"* ]] } + +@test "status: marks a push-protected subtree, without a warning" { + scenario_up_to_date "$monorepo" "$upstream" + cd "$monorepo" + git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" + + run cmd_status + [ "$status" -eq 0 ] + [ "$output" = "ok vendor/a [push-protected] (up to date)" ] +} + +@test "status: warns about a subtree that isn't push-protected, with a command that protects it" { + scenario_up_to_date "$monorepo" "$upstream" + cd "$monorepo" + git config --unset remote.vendor/a.pushurl + + run cmd_status + [ "$status" -eq 0 ] + [[ "$output" == *"ok vendor/a [NOT push-protected] (up to date)"* ]] + [[ "$output" == *"!! vendor/a: a plain 'git push vendor/a' sends the whole monorepo there"* ]] + + local fix + fix="$(grep 'git remote set-url --push' <<<"$output")" + eval "$fix" + is_push_protected vendor/a +} + +@test "status: shows a remote with its own push URL as not push-protected" { + scenario_up_to_date "$monorepo" "$upstream" + cd "$monorepo" + git remote set-url --push vendor/a "$upstream" + + run cmd_status + [[ "$output" == *"vendor/a [NOT push-protected]"* ]] +} + +@test "status: colors the warning red only where git would color its own status" { + scenario_up_to_date "$monorepo" "$upstream" + cd "$monorepo" + git config --unset remote.vendor/a.pushurl + + run cmd_status + [[ "$output" != *$'\e['* ]] + + git config color.status always + run cmd_status + [[ "$output" == *$'\e[31m[NOT push-protected]\e[m'* ]] + [[ "$output" == *$'\e[31m!! vendor/a: '* ]] +} diff --git a/walkthrough/README.md b/walkthrough/README.md index c0a3d43..ec3a634 100644 --- a/walkthrough/README.md +++ b/walkthrough/README.md @@ -43,9 +43,9 @@ differ. `status` doesn't fetch; it uses what was fetched last. ```scrut $ git subtrees status -?? ghost -> (no mapping) -?? vendor/pkg-b -> (no mapping) -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (pull) +?? ghost [no mapping] +?? vendor/pkg-b [no mapping] +ok vendor/pkg-a [push-protected] (pull) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -58,6 +58,7 @@ otherwise. ```scrut $ git subtrees init vendor/pkg-b "$WALKTHROUGH/upstream/pkg-b.git" +=== vendor/pkg-b: push-protected -- a plain 'git push vendor/pkg-b' fails, 'git subtrees push' works === vendor/pkg-b: fetching ok vendor/pkg-b fetched === vendor/pkg-b: adding subtree from $WALKTHROUGH/upstream/pkg-b.git @@ -127,7 +128,7 @@ $ echo "a local fix" >>vendor/pkg-a/file.txt && git commit -qam "pkg-a: a local ```scrut $ git subtrees status vendor/pkg-a -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (push) +ok vendor/pkg-a [push-protected] (push) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -164,9 +165,9 @@ ok vendor/pkg-b: nothing to push ```scrut $ git subtrees status -?? ghost -> (no mapping) -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (up to date) -ok vendor/pkg-b -> $WALKTHROUGH/upstream/pkg-b.git (up to date) +?? ghost [no mapping] +ok vendor/pkg-a [push-protected] (up to date) +ok vendor/pkg-b [push-protected] (up to date) ``` ## prune diff --git a/walkthrough/diverged.md b/walkthrough/diverged.md index aef116f..524705d 100644 --- a/walkthrough/diverged.md +++ b/walkthrough/diverged.md @@ -45,7 +45,7 @@ ok vendor/pkg-a fetched (main moved 2db2a00..96a8153) ```scrut $ git subtrees status vendor/pkg-a -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (diverged) +ok vendor/pkg-a [push-protected] (diverged) file.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) ``` @@ -117,7 +117,7 @@ left to push: ```scrut $ git subtrees status vendor/pkg-a -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (push) +ok vendor/pkg-a [push-protected] (push) file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -132,7 +132,7 @@ ok vendor/pkg-a: pushed ```scrut $ git subtrees status vendor/pkg-a -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (up to date) +ok vendor/pkg-a [push-protected] (up to date) ``` If the two sides share no history at all, e.g. because the remote was diff --git a/walkthrough/feature-branches.md b/walkthrough/feature-branches.md index e9cae6d..ffbeed8 100644 --- a/walkthrough/feature-branches.md +++ b/walkthrough/feature-branches.md @@ -29,9 +29,9 @@ Switched to a new branch 'feature' ```scrut $ git subtrees status -?? ghost -> (no mapping) -?? vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (no 'feature' branch on remote; monorepo base branch unknown -- pass --base ) -?? vendor/pkg-b -> $WALKTHROUGH/upstream/pkg-b.git (no 'feature' branch on remote; monorepo base branch unknown -- pass --base ) +?? ghost [no mapping] +?? vendor/pkg-a [push-protected] (no 'feature' branch on remote; monorepo base branch unknown -- pass --base ) +?? vendor/pkg-b [push-protected] (no 'feature' branch on remote; monorepo base branch unknown -- pass --base ) ``` To tell whether a subtree changed on `feature`, git-subtrees compares it @@ -45,9 +45,9 @@ $ git config init.defaultBranch main ```scrut $ git subtrees status -?? ghost -> (no mapping) -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (no 'feature' branch on remote; unchanged since 'main') -ok vendor/pkg-b -> $WALKTHROUGH/upstream/pkg-b.git (no 'feature' branch on remote; unchanged since 'main') +?? ghost [no mapping] +ok vendor/pkg-a [push-protected] (no 'feature' branch on remote; unchanged since 'main') +ok vendor/pkg-b [push-protected] (no 'feature' branch on remote; unchanged since 'main') ``` ## Changing one subtree @@ -58,9 +58,9 @@ $ echo "a new option" >>vendor/pkg-b/file.txt && git commit -qam "pkg-b: add an ```scrut $ git subtrees status -?? ghost -> (no mapping) -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (no 'feature' branch on remote; unchanged since 'main') -ok vendor/pkg-b -> $WALKTHROUGH/upstream/pkg-b.git (no 'feature' branch on remote; changed since 'main' -- push would create it) +?? ghost [no mapping] +ok vendor/pkg-a [push-protected] (no 'feature' branch on remote; unchanged since 'main') +ok vendor/pkg-b [push-protected] (no 'feature' branch on remote; changed since 'main' -- push would create it) vendor/pkg-b/file.txt | 1 + 1 file changed, 1 insertion(+) ``` @@ -94,9 +94,9 @@ ok vendor/pkg-b: pushed ```scrut $ git subtrees status -?? ghost -> (no mapping) -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (no 'feature' branch on remote; unchanged since 'main') -ok vendor/pkg-b -> $WALKTHROUGH/upstream/pkg-b.git (up to date) +?? ghost [no mapping] +ok vendor/pkg-a [push-protected] (no 'feature' branch on remote; unchanged since 'main') +ok vendor/pkg-b [push-protected] (up to date) ``` `pull` on `feature` pulls from `pkg-b`'s `feature` branch. `pkg-a`'s @@ -153,7 +153,7 @@ URL: $WALKTHROUGH/upstream/pkg-b.git ```scrut $ git subtrees status -?? ghost -> (no mapping) -ok vendor/pkg-a -> $WALKTHROUGH/upstream/pkg-a.git (up to date) -ok vendor/pkg-b -> $WALKTHROUGH/upstream/pkg-b.git (up to date) +?? ghost [no mapping] +ok vendor/pkg-a [push-protected] (up to date) +ok vendor/pkg-b [push-protected] (up to date) ``` diff --git a/walkthrough/setup.sh b/walkthrough/setup.sh index ed78cf8..8fd8773 100755 --- a/walkthrough/setup.sh +++ b/walkthrough/setup.sh @@ -88,6 +88,8 @@ git init -q --initial-branch=main "$mono_dir" git commit -q --allow-empty -m "initial commit" git remote add vendor/pkg-a "$upstream_dir/pkg-a.git" + # Push-protected, as 'git subtrees init' would leave it. + git remote set-url --push vendor/pkg-a "push with git subtrees push, not git push" git fetch -q vendor/pkg-a git subtree add -q --prefix=vendor/pkg-a vendor/pkg-a main --squash From 3fd16c4380ffb8a526eca2f18cd33a59154869e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 01:15:26 +0200 Subject: [PATCH 2/7] feat(remotes): mark the blocked push URL as git-subtrees' and show the command In Git's error the old text was easy to read past: "fatal: 'push with git subtrees push, not git push' does not appear to be a git repository", followed by generic advice. BLOCKED catches the eye, git-subtrees says who set it up, and => sets off the command to run. It can't be a colon: Git would then hand the URL to ssh as a host name and print an ssh error instead. Decided before the first release, since is_push_protected compares the exact text and a later change would mark protected remotes unprotected. The scenario now lists every way the monorepo ends up on a subtree remote, taken from #50, and shows one that isn't a typo: push.autoSetupRemote in a monorepo without origin. --- lib/common.sh | 5 +- test/scenarios/push-protection/README.md | 101 ++++++++++++++++------- walkthrough/setup.sh | 2 +- 3 files changed, 74 insertions(+), 34 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 012e704..5219deb 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -169,9 +169,10 @@ target_ref_for() { # The push URL of a push-protected subtree remote. Any push to the remote # that isn't a `git subtree split` sends the whole monorepo there, so a plain # `git push ` must fail: Git can't find a repository at this URL and -# prints it. It must not contain ':', or Git reads it as an ssh host. +# prints it. It must not contain ':', or Git hands it to ssh as a host name +# and prints an ssh error instead. # See test/scenarios/push-protection/README.md. -PUSH_PROTECTED_URL="push with git subtrees push, not git push" +PUSH_PROTECTED_URL="BLOCKED by git-subtrees -- push with => git subtrees push" # Succeeds if remote $1's only push URL is PUSH_PROTECTED_URL. A remote # with a push URL of its own is left alone, and isn't protected. diff --git a/test/scenarios/push-protection/README.md b/test/scenarios/push-protection/README.md index cb2e8bb..a71ee83 100644 --- a/test/scenarios/push-protection/README.md +++ b/test/scenarios/push-protection/README.md @@ -13,27 +13,47 @@ that changes. A subtree remote is an ordinary Git remote. `git subtree push` sends it the output of `git subtree split`: a history with only the subtree's files. Any other push to it sends the monorepo's own commits, with **every -folder** in them. That's a slip of the fingers, `git push vendor/a main` -instead of `git push origin main`, but it doesn't have to be one: - -- With `push.autoSetupRemote` and no `origin`, a plain `git push` on a new - branch picks the only remote there is. -- Once a branch's upstream is a subtree remote, every plain `git push`, an - IDE's "Sync" button, or a Git GUI's push goes there. -- A reverse search for `push vendor/a` in your shell history finds - `git push vendor/a main` as well as `git subtree push ... vendor/a main`. +folder** in them. + +Typing `git push vendor/a main` by mistake is the obvious way, but not the +only one: + +- **`push.autoSetupRemote` on a repo whose only remote is a subtree + remote.** A plain `git push` on a new branch picks the only remote, + pushes the monorepo there and makes it the upstream. A monorepo without + `origin` (e.g. local-only, publishing parts of itself) is exactly the + setup where this happens. The output below shows it. +- **Once a branch's upstream is a subtree remote**, every later plain + `git push`, the IDE's "Sync" button or LazyGit's `P` goes there. Besides + `push.autoSetupRemote` and `git push -u`, `remote.pushDefault` and + `branch..pushRemote` can point there too. `git switch ` also + creates a branch tracking a subtree remote when only that remote has a + branch ``. +- **LazyGit**, for a branch without an upstream, suggests `origin` if it + exists and otherwise the first remote. Without `origin`, a subtree remote + is one Enter away. With `push.default=current` it doesn't ask at all. +- **Shell history.** `git push vendor/a main` and + `git subtree push --prefix=vendor/a vendor/a main` both match a reverse + search for `push vendor/a`. +- `git push --all ` and `git push --mirror `. When the remote already has the branch, a plain push is usually rejected -as non-fast-forward. New branches and `--force` go through. Deleting the -branch afterwards doesn't unpublish anything: hosts like GitHub keep the -commits reachable by their hash, and anyone who fetched in the meantime has -a copy. [Issue #50](https://github.com/roschaefer/git-subtrees/issues/50) -has the details and the other options that were considered. +as non-fast-forward. The dangerous cases are **new branches** and +**`--force`**. + +It's hard to undo: deleting the branch afterwards doesn't unpublish +anything. GitHub keeps the commits reachable by their hash until support +purges them, and anyone who fetched or forked in the meantime has a copy +([an example with ~4000 commits pushed by +mistake](https://github.com/orgs/community/discussions/21995)). So this +shouldn't be left to the user being careful. +[Issue #50](https://github.com/roschaefer/git-subtrees/issues/50) has the +other options that were considered. ## The protection A remote can have a push URL that differs from its fetch URL. -`git subtrees init` sets it to `push with git subtrees push, not git push`. +`git subtrees init` sets it to `BLOCKED by git-subtrees -- push with => git subtrees push`. Fetching is unaffected. Any push to the remote by its name fails, since Git finds no repository at that URL, and the error message says what to do. `git push --no-verify` doesn't get around it, and no hook is involved. @@ -67,7 +87,7 @@ $ git subtrees status ok vendor/a [NOT push-protected] (up to date) !! vendor/a: a plain 'git push vendor/a' sends the whole monorepo there -- push-protect it with: - git remote set-url --push vendor/a 'push with git subtrees push, not git push' + git remote set-url --push vendor/a 'BLOCKED by git-subtrees -- push with => git subtrees push' ``` @@ -85,17 +105,25 @@ To $UPSTREAM ok vendor/a: pushed ``` -But so does the slip. A plain push to a new branch publishes the whole -monorepo, `internal/` included: +But nothing stops the slip either. This monorepo has no `origin`, so with +`push.autoSetupRemote`, a plain `git push` on a new branch picks +`vendor/a`: ```scrut -$ git push vendor/a main:oops +$ git config push.autoSetupRemote true && git switch -q -c topic +``` + +```scrut +$ git push To $UPSTREAM - * [new branch] main -> oops + * [new branch] topic -> topic +branch 'topic' set up to track 'vendor/a/topic'. ``` +That published the whole monorepo, `internal/` included: + ```scrut -$ git -C "$UPSTREAM" ls-tree -r --name-only oops +$ git -C "$UPSTREAM" ls-tree -r --name-only topic internal/notes.txt vendor/a/file.txt ``` @@ -104,19 +132,19 @@ Protect the remote with the command `status` printed (or by running `git subtrees init vendor/a ` again): ```scrut -$ git remote set-url --push vendor/a 'push with git subtrees push, not git push' +$ git remote set-url --push vendor/a 'BLOCKED by git-subtrees -- push with => git subtrees push' ``` +`topic` now tracks `vendor/a`, so every later plain `git push` would go +there. Now it fails, before anything is sent: + ```scrut -$ git subtrees status -ok vendor/a [push-protected] (up to date) +$ echo "more notes" >>internal/notes.txt && git commit -qam "more internal notes" ``` -Now the same slip fails, before anything is sent: - ```scrut -$ git push vendor/a main:oops-again -fatal: 'push with git subtrees push, not git push' does not appear to be a git repository +$ git push +fatal: 'BLOCKED by git-subtrees -- push with => git subtrees push' does not appear to be a git repository fatal: Could not read from remote repository. Please make sure you have the correct access rights @@ -124,13 +152,24 @@ and the repository exists. [128] ``` -So does a plain `git subtree push`, which goes through the remote's name -too. Use `git subtrees push`: +Back on `main`, `status` shows the remote as protected: + +```scrut +$ git switch -q main +``` + +```scrut +$ git subtrees status +ok vendor/a [push-protected] (up to date) +``` + +A plain `git subtree push` fails too, since it also pushes by the remote's +name. Use `git subtrees push`: ```scrut $ git subtree push --prefix=vendor/a vendor/a main git push using: vendor/a main -fatal: 'push with git subtrees push, not git push' does not appear to be a git repository +fatal: 'BLOCKED by git-subtrees -- push with => git subtrees push' does not appear to be a git repository fatal: Could not read from remote repository. Please make sure you have the correct access rights diff --git a/walkthrough/setup.sh b/walkthrough/setup.sh index 8fd8773..f3a7f0c 100755 --- a/walkthrough/setup.sh +++ b/walkthrough/setup.sh @@ -89,7 +89,7 @@ git init -q --initial-branch=main "$mono_dir" git remote add vendor/pkg-a "$upstream_dir/pkg-a.git" # Push-protected, as 'git subtrees init' would leave it. - git remote set-url --push vendor/pkg-a "push with git subtrees push, not git push" + git remote set-url --push vendor/pkg-a "BLOCKED by git-subtrees -- push with => git subtrees push" git fetch -q vendor/pkg-a git subtree add -q --prefix=vendor/pkg-a vendor/pkg-a main --squash From 5625a568b43bedd673e055daf7a6fb9b134ed703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 01:22:09 +0200 Subject: [PATCH 3/7] fix(status): mark unprotected remotes without repeating the fix each time status runs often, and a warning block on every run gets annoying, all the more for a remote that keeps a push URL of its own on purpose. The red [NOT push-protected] label is signal enough; the command that protects a remote moves to 'git subtrees status -h' and the scenario. --- README.md | 5 +---- lib/status.sh | 20 +++++--------------- test/scenarios/push-protection/README.md | 8 ++------ test/status.bats | 18 +++++++++++------- 4 files changed, 19 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 424f5f1..8128159 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,7 @@ repo: git remote add vendor/foo # vendor/foo is now a subtree That's all the configuration there is. `git subtrees status` lists what it -found, and warns about each subtree remote that isn't push-protected: a -plain `git push vendor/foo` sends the whole monorepo there, not just the -folder ([why and how](test/scenarios/push-protection/README.md)). `git -subtrees init` protects the remotes it sets up. Everything follows from two rules: +found. Everything follows from two rules: 1. **The remote name is the folder path.** If you move the folder, also run `git remote rename `. diff --git a/lib/status.sh b/lib/status.sh index 7c07cac..e635800 100644 --- a/lib/status.sh +++ b/lib/status.sh @@ -1,7 +1,7 @@ # Assumes lib/common.sh is already sourced. usage_status() { - cat <<'EOF' + cat <] [path...] Shows the sync state of every subtree, plus every registered remote that @@ -14,8 +14,10 @@ state is whether the subtree changed on this branch compared with the monorepo's base branch (--base, else origin/HEAD, else init.defaultBranch). Each subtree is marked [push-protected] or [NOT push-protected]. A plain -'git push' to a remote that isn't protected sends the whole monorepo there; -status ends with the command that protects it. +'git push' to a remote that isn't protected sends the whole monorepo there. +Protect it like 'git subtrees init' does: + + git remote set-url --push $(shell_quote "$PUSH_PROTECTED_URL") EOF } @@ -42,15 +44,6 @@ subtree_label() { fi } -# Warns about subtree path $1's remote not being push-protected, with the -# command that protects it. -format_unprotected_warning() { - local q_path - q_path="$(shell_quote "$1")" - colorize $'\e[31m' "!! $1: a plain 'git push $q_path' sends the whole monorepo there -- push-protect it with:" $'\e[m' - printf '\n\n git remote set-url --push %s %s\n\n' "$q_path" "$(shell_quote "$PUSH_PROTECTED_URL")" -} - # Prints the status of a subtree whose remote has no branch like the current # one: what changed on this branch compared with the monorepo's base branch. format_missing_branch_line() { @@ -152,7 +145,4 @@ cmd_status() { for path in "${paths[@]}"; do format_status_line "$path" "$branch" "$base" done - for path in "${paths[@]}"; do - is_push_protected "$path" || format_unprotected_warning "$path" - done } diff --git a/test/scenarios/push-protection/README.md b/test/scenarios/push-protection/README.md index a71ee83..db1e6e5 100644 --- a/test/scenarios/push-protection/README.md +++ b/test/scenarios/push-protection/README.md @@ -80,15 +80,11 @@ $ source "$TESTDIR/../readme-setup.sh" && build_scenario scenario_push_protectio ``` --> -`status` warns about the remote that isn't protected: +`status` marks the remote as not protected (in red, on a terminal): ```scrut $ git subtrees status ok vendor/a [NOT push-protected] (up to date) -!! vendor/a: a plain 'git push vendor/a' sends the whole monorepo there -- push-protect it with: - - git remote set-url --push vendor/a 'BLOCKED by git-subtrees -- push with => git subtrees push' - ``` Unprotected, `git subtrees push` works as you'd expect: @@ -128,7 +124,7 @@ internal/notes.txt vendor/a/file.txt ``` -Protect the remote with the command `status` printed (or by running +Protect the remote, as `git subtrees status -h` shows (or by running `git subtrees init vendor/a ` again): ```scrut diff --git a/test/status.bats b/test/status.bats index 5dbc6a4..41e057e 100644 --- a/test/status.bats +++ b/test/status.bats @@ -185,19 +185,24 @@ setup() { [ "$output" = "ok vendor/a [push-protected] (up to date)" ] } -@test "status: warns about a subtree that isn't push-protected, with a command that protects it" { +@test "status: marks a subtree that isn't push-protected, without nagging about it" { scenario_up_to_date "$monorepo" "$upstream" cd "$monorepo" git config --unset remote.vendor/a.pushurl run cmd_status [ "$status" -eq 0 ] - [[ "$output" == *"ok vendor/a [NOT push-protected] (up to date)"* ]] - [[ "$output" == *"!! vendor/a: a plain 'git push vendor/a' sends the whole monorepo there"* ]] + [ "$output" = "ok vendor/a [NOT push-protected] (up to date)" ] +} + +@test "status: -h shows a command that push-protects a subtree" { + scenario_up_to_date "$monorepo" "$upstream" + cd "$monorepo" + git config --unset remote.vendor/a.pushurl local fix - fix="$(grep 'git remote set-url --push' <<<"$output")" - eval "$fix" + fix="$(usage_status | grep 'git remote set-url --push')" + eval "${fix///vendor/a}" is_push_protected vendor/a } @@ -210,7 +215,7 @@ setup() { [[ "$output" == *"vendor/a [NOT push-protected]"* ]] } -@test "status: colors the warning red only where git would color its own status" { +@test "status: colors [NOT push-protected] red only where git would color its own status" { scenario_up_to_date "$monorepo" "$upstream" cd "$monorepo" git config --unset remote.vendor/a.pushurl @@ -221,5 +226,4 @@ setup() { git config color.status always run cmd_status [[ "$output" == *$'\e[31m[NOT push-protected]\e[m'* ]] - [[ "$output" == *$'\e[31m!! vendor/a: '* ]] } From b0b9d609f0282ca228c1b17645fe7a88db0a5f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 01:57:13 +0200 Subject: [PATCH 4/7] fix(status): color [NOT push-protected] on a terminal The terminal check ran inside the command substitution that builds each status line, where stdout is a pipe, so the label was never colored. The test forced color.status=always, which skips that check; the new tests run status in a terminal via script(1). Also fixes tests that asserted with a bare `! cmd`, which bats doesn't enforce unless it's the last command. One of them hid that add_subtree already protects the remote, so it never exercised init's protection. --- lib/status.sh | 17 +++++++++++++---- test/common.bats | 6 ++++-- test/init.bats | 4 +++- test/status.bats | 21 +++++++++++++++++---- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/lib/status.sh b/lib/status.sh index e635800..58cd723 100644 --- a/lib/status.sh +++ b/lib/status.sh @@ -23,12 +23,20 @@ EOF status_warn() { printf '?? %s\n' "$*"; } -# Prints "$1$2$3", with ANSI codes $1 and $3 only if Git would color -# `git status` here (color.status, else color.ui; default: on a terminal). -colorize() { +# Whether status colors its output, like Git colors `git status` (color.status, +# else color.ui; by default only on a terminal). Set by cmd_status, before any +# output goes through a command substitution, where stdout is never a terminal. +declare -g STATUS_COLOR=false + +set_status_color() { local tty=false [[ -t 1 ]] && tty=true - if [[ "$(git config --get-colorbool color.status "$tty")" == true ]]; then + STATUS_COLOR="$(git config --get-colorbool color.status "$tty")" +} + +# Prints "$1$2$3", with ANSI codes $1 and $3 only if STATUS_COLOR is true. +colorize() { + if [[ "$STATUS_COLOR" == true ]]; then printf '%s%s%s' "$1" "$2" "$3" else printf '%s' "$2" @@ -115,6 +123,7 @@ format_unmapped_remote_line() { cmd_status() { parse_base_args usage_status "$@" + set_status_color local base="$BASE_ARG" local paths=("${PATH_ARGS[@]}") diff --git a/test/common.bats b/test/common.bats index cf650ea..d76cd8c 100644 --- a/test/common.bats +++ b/test/common.bats @@ -235,7 +235,8 @@ setup() { git remote add vendor/a "$upstream" git remote set-url --push vendor/a "$BATS_TEST_TMPDIR/elsewhere.git" - ! is_push_protected vendor/a + run is_push_protected vendor/a + [ "$status" -eq 1 ] [ "$(with_push_allowed vendor/a git remote get-url --push vendor/a)" = "$BATS_TEST_TMPDIR/elsewhere.git" ] } @@ -259,7 +260,8 @@ setup() { git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" git remote set-url --add --push vendor/a "$upstream" - ! is_push_protected vendor/a + run is_push_protected vendor/a + [ "$status" -eq 1 ] } @test "classify_subtree: not-connected" { diff --git a/test/init.bats b/test/init.bats index b59333a..993dfba 100644 --- a/test/init.bats +++ b/test/init.bats @@ -429,7 +429,9 @@ setup() { init_monorepo "$monorepo" add_subtree "$monorepo" "$upstream" "vendor/a" cd "$monorepo" - ! is_push_protected vendor/a + git config --unset remote.vendor/a.pushurl + run is_push_protected vendor/a + [ "$status" -eq 1 ] run cmd_init "vendor/a" "$upstream" [ "$status" -eq 0 ] diff --git a/test/status.bats b/test/status.bats index 41e057e..3810d8f 100644 --- a/test/status.bats +++ b/test/status.bats @@ -215,7 +215,19 @@ setup() { [[ "$output" == *"vendor/a [NOT push-protected]"* ]] } -@test "status: colors [NOT push-protected] red only where git would color its own status" { +@test "status: colors [NOT push-protected] red on a terminal" { + command -v script >/dev/null || skip "needs script(1) for a terminal" + scenario_up_to_date "$monorepo" "$upstream" + cd "$monorepo" + git config --unset remote.vendor/a.pushurl + + run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null + [ "$status" -eq 0 ] + [[ "$output" == *$'\e[31m[NOT push-protected]\e[m'* ]] +} + +@test "status: doesn't color where git wouldn't color its own status" { + command -v script >/dev/null || skip "needs script(1) for a terminal" scenario_up_to_date "$monorepo" "$upstream" cd "$monorepo" git config --unset remote.vendor/a.pushurl @@ -223,7 +235,8 @@ setup() { run cmd_status [[ "$output" != *$'\e['* ]] - git config color.status always - run cmd_status - [[ "$output" == *$'\e[31m[NOT push-protected]\e[m'* ]] + git config color.status never + run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null + [[ "$output" == *"[NOT push-protected]"* ]] + [[ "$output" != *$'\e['* ]] } From 296ba7cb848a4d87806a42985a777b28130d2ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 02:12:48 +0200 Subject: [PATCH 5/7] fix(push): push a protected remote where it would push without protection Git applies url..pushInsteadOf to a remote's URL, but never to an explicit push URL like the protected one. Rewriting that to the plain fetch URL silently moved pushes from e.g. ssh (pushInsteadOf) to the https fetch URL. The rewrite target now follows Git's own rules: the longest matching pushInsteadOf, else insteadOf. init no longer protects a remote with several URLs: Git pushes to all of them, and the protected URL can only stand for one. For a protected remote, the unrelated-history commands lift and restore the protection around the force push instead of pushing to the remote's URL, which may hold credentials and ends up in logs. The scenario shows the same three steps for any deliberate plain push, such as deleting a branch, which git subtrees push can't do. --- lib/common.sh | 60 +++++++++++++++---- lib/init.sh | 9 +-- test/common.bats | 36 ++++++++++- test/init.bats | 14 +++++ test/push.bats | 17 +++++- .../diverged-unrelated-history/README.md | 5 +- test/scenarios/push-protection/README.md | 29 ++++++++- walkthrough/setup.sh | 5 +- 8 files changed, 150 insertions(+), 25 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 5219deb..2effc47 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -174,21 +174,57 @@ target_ref_for() { # See test/scenarios/push-protection/README.md. PUSH_PROTECTED_URL="BLOCKED by git-subtrees -- push with => git subtrees push" +# Prints remote $1's configured push URLs, one per line. +push_urls() { + git config --get-all "remote.$1.pushurl" 2>/dev/null || true +} + # Succeeds if remote $1's only push URL is PUSH_PROTECTED_URL. A remote # with a push URL of its own is left alone, and isn't protected. is_push_protected() { - [[ "$(git config --get-all "remote.$1.pushurl" 2>/dev/null)" == "$PUSH_PROTECTED_URL" ]] + [[ "$(push_urls "$1")" == "$PUSH_PROTECTED_URL" ]] +} + +# Succeeds if init may push-protect remote $1. Not if it has a push URL of +# its own, or several URLs: Git pushes to all of them, but the protected URL +# can only be rewritten to one. +can_push_protect() { + [[ -z "$(push_urls "$1")" && "$(git config --get-all "remote.$1.url" 2>/dev/null | wc -l)" -eq 1 ]] +} + +# Prints the URL a push to remote $1 would go to if it weren't protected. +# Git rewrites the remote's URL with the longest matching +# url..pushInsteadOf, else with url..insteadOf -- but not a +# push URL like PUSH_PROTECTED_URL, so with_push_allowed has to do it. +unprotected_push_url() { + local raw entry key prefix best_base="" best_prefix="" + raw="$(git config --get "remote.$1.url")" || return + while IFS= read -r -d '' entry; do + key="${entry%%$'\n'*}" + prefix="${entry#*$'\n'}" + if [[ "$raw" == "$prefix"* && ${#prefix} -gt ${#best_prefix} ]]; then + best_prefix="$prefix" + best_base="${key#url.}" + best_base="${best_base%.*}" + fi + done < <(git config -z --get-regexp '^url\..*\.pushinsteadof$' 2>/dev/null || true) + if [[ -n "$best_prefix" ]]; then + printf '%s%s\n' "$best_base" "${raw#"$best_prefix"}" + else + git remote get-url -- "$1" + fi } -# Runs "$@" with PUSH_PROTECTED_URL rewritten to remote $1's fetch URL, so a -# push to the remote by name gets through and updates its tracking refs. -# The rewrite only touches that one URL: a remote with a push URL of its own -# keeps pushing there. Passed through the environment rather than `git -c`, -# which splits at the first '=' a URL may contain. +# Runs "$@" with PUSH_PROTECTED_URL rewritten to where remote $1 would push +# if it weren't protected, so a push to the remote by name gets through and +# updates its tracking refs. The rewrite only touches that one URL: a remote +# with a push URL of its own keeps pushing there. Passed through the +# environment rather than `git -c`, which splits at the first '=' a URL may +# contain. with_push_allowed() { local remote="$1" url n="${GIT_CONFIG_COUNT:-0}" shift - url="$(git remote get-url -- "$remote")" || return + url="$(unprotected_push_url "$remote")" || return ( export GIT_CONFIG_COUNT=$((n + 1)) export "GIT_CONFIG_KEY_$n=url.$url.insteadOf" "GIT_CONFIG_VALUE_$n=$PUSH_PROTECTED_URL" @@ -488,12 +524,14 @@ print_unrelated_history_guidance() { q_tmp="$(shell_quote "$tmp_branch")" q_msg="$(shell_quote "remove $path before re-adopting it from its remote")" q_refspec="$(shell_quote "$tmp_branch:$branch")" - # A push-protected remote refuses the force push by name, so it goes to - # the fetch URL, and a fetch updates the tracking ref the push didn't. + # A push-protected remote refuses the force push, so the commands lift + # the protection for it -- rather than push to the remote's URL, which + # may hold credentials and would skip url..pushInsteadOf. local push_cmd="git push --force $q_path $q_refspec" if is_push_protected "$path"; then - push_cmd="git push --force $(shell_quote "$(git remote get-url -- "$path")") $q_refspec" - push_cmd+=$'\n'" git fetch $q_path" + push_cmd="git config --unset $(shell_quote "remote.$path.pushurl")" + push_cmd+=$'\n'" git push --force $q_path $q_refspec" + push_cmd+=$'\n'" git remote set-url --push $q_path $(shell_quote "$PUSH_PROTECTED_URL")" fi log_warn "$path: remote and local share no history -- pick one side manually:" cat >&2 <' can't -send the whole monorepo there. 'git subtrees push' still works. +init push-protects the remote unless it has a push URL of its own or +several URLs: it sets the push URL to one Git can't push to, so a plain +'git push ' can't send the whole monorepo there. 'git subtrees push' +still works. EOF } @@ -121,7 +122,7 @@ cmd_init() { log_step "$path: registering remote -> $url" git remote add -- "$path" "$url" fi - if [[ -z "$(git config --get-all "remote.$path.pushurl" 2>/dev/null)" ]]; then + if can_push_protect "$path"; then git remote set-url --push -- "$path" "$PUSH_PROTECTED_URL" log_step "$path: push-protected -- a plain 'git push $(shell_quote "$path")' fails, 'git subtrees push' works" fi diff --git a/test/common.bats b/test/common.bats index d76cd8c..cfaa3a8 100644 --- a/test/common.bats +++ b/test/common.bats @@ -492,12 +492,42 @@ add_monorepo_origin() { [[ "$output" == *"git push --force 'vendor/x;id' 'tmp-split-x;id:main'"* ]] } -@test "print_unrelated_history_guidance: force-pushes a protected remote at its fetch URL, then fetches" { +@test "print_unrelated_history_guidance: lifts a protected remote's protection for the force push, without printing its URL" { init_monorepo "$monorepo" cd "$monorepo" - git remote add vendor/a "$BATS_TEST_TMPDIR/up stream.git" + git remote add vendor/a "https://x-access-token:secret@example.com/a.git" git remote set-url --push vendor/a "$PUSH_PROTECTED_URL" run print_unrelated_history_guidance vendor/a main - [[ "$output" == *"git push --force '$BATS_TEST_TMPDIR/up stream.git' tmp-split-a:main"$'\n'" git fetch vendor/a"$'\n'* ]] + [[ "$output" == *"git config --unset remote.vendor/a.pushurl"$'\n'" git push --force vendor/a tmp-split-a:main"$'\n'" git remote set-url --push vendor/a '$PUSH_PROTECTED_URL'"$'\n'* ]] + [[ "$output" != *"secret"* ]] +} + +# What a push to $1 goes to without protection, according to Git itself. +git_push_target() { + local saved + saved="$(git config --get-all "remote.$1.pushurl")" + git config --unset-all "remote.$1.pushurl" + git remote get-url --push "$1" + git config --add "remote.$1.pushurl" "$saved" +} + +@test "unprotected_push_url: the fetch URL, rewritten like Git rewrites it for a push" { + init_monorepo "$monorepo" + cd "$monorepo" + add_subtree_remote vendor/a "https://example.com/org/a.git" + [ "$(unprotected_push_url vendor/a)" = "$(git_push_target vendor/a)" ] + [ "$(unprotected_push_url vendor/a)" = "https://example.com/org/a.git" ] + + git config url.https://mirror.example.com/.insteadOf https://example.com/ + [ "$(unprotected_push_url vendor/a)" = "$(git_push_target vendor/a)" ] + [ "$(unprotected_push_url vendor/a)" = "https://mirror.example.com/org/a.git" ] + + git config url.git@example.com:.pushInsteadOf https://example.com/ + [ "$(unprotected_push_url vendor/a)" = "$(git_push_target vendor/a)" ] + [ "$(unprotected_push_url vendor/a)" = "git@example.com:org/a.git" ] + + git config "url.git@example.com:org/special-.pushInsteadOf" https://example.com/org/ + [ "$(unprotected_push_url vendor/a)" = "$(git_push_target vendor/a)" ] + [ "$(unprotected_push_url vendor/a)" = "git@example.com:org/special-a.git" ] } diff --git a/test/init.bats b/test/init.bats index 993dfba..e88e029 100644 --- a/test/init.bats +++ b/test/init.bats @@ -452,3 +452,17 @@ setup() { [[ "$output" != *"push-protected"* ]] [ "$(git config --get-all remote.vendor/a.pushurl)" = "$BATS_TEST_TMPDIR/elsewhere.git" ] } + +@test "init: leaves a remote with several URLs unprotected, since a push goes to all of them" { + make_bare_repo "$upstream" + seed_bare_repo "$upstream" "seed" + init_monorepo "$monorepo" + cd "$monorepo" + git remote add vendor/a "$upstream" + git remote set-url --add vendor/a "$BATS_TEST_TMPDIR/mirror.git" + + run cmd_init "vendor/a" "$upstream" + [ "$status" -eq 0 ] + [[ "$output" != *"push-protected"* ]] + [ -z "$(push_urls vendor/a)" ] +} diff --git a/test/push.bats b/test/push.bats index 200b223..188f89a 100644 --- a/test/push.bats +++ b/test/push.bats @@ -58,7 +58,7 @@ setup() { after="$(git -C "$upstream" rev-parse main)" [ "$before" = "$after" ] [[ "$output" == *"share no history"* ]] - [[ "$output" == *"git push --force $upstream tmp-split-a:main"* ]] + [[ "$output" == *"git push --force vendor/a tmp-split-a:main"* ]] } @test "push_one: refuses a branch git-subtree cannot use, without classifying" { @@ -348,3 +348,18 @@ remote_has_branch() { classify_subtree "vendor/a" "main" [ "$SUBTREE_STATE" = "up-to-date" ] } + +@test "push: a push-protected remote is pushed to where url..pushInsteadOf sends it" { + scenario_push_ahead "$monorepo" "$upstream" + local push_target="$BATS_TEST_TMPDIR/push-target.git" + git clone -q --bare "$upstream" "$push_target" + cd "$monorepo" + git config "url.$push_target.pushInsteadOf" "$upstream" + local before + before="$(git -C "$upstream" rev-parse main)" + + run push_one "vendor/a" "main" + [ "$status" -eq 0 ] + [ "$(git -C "$upstream" rev-parse main)" = "$before" ] + [ "$(git -C "$push_target" rev-parse main)" != "$before" ] +} diff --git a/test/scenarios/diverged-unrelated-history/README.md b/test/scenarios/diverged-unrelated-history/README.md index 9bab750..c3294e3 100644 --- a/test/scenarios/diverged-unrelated-history/README.md +++ b/test/scenarios/diverged-unrelated-history/README.md @@ -49,8 +49,9 @@ ok vendor/a fetched # OR: accept the local (monorepo) version, overwriting vendor/a's history: git subtree split --prefix=vendor/a -b tmp-split-a - git push --force $UPSTREAM tmp-split-a:main - git fetch vendor/a + git config --unset remote.vendor/a.pushurl + git push --force vendor/a tmp-split-a:main + git remote set-url --push vendor/a 'BLOCKED by git-subtrees -- push with => git subtrees push' git branch -D tmp-split-a !! Failed: vendor/a diff --git a/test/scenarios/push-protection/README.md b/test/scenarios/push-protection/README.md index db1e6e5..6e1c006 100644 --- a/test/scenarios/push-protection/README.md +++ b/test/scenarios/push-protection/README.md @@ -58,8 +58,9 @@ Fetching is unaffected. Any push to the remote by its name fails, since Git finds no repository at that URL, and the error message says what to do. `git push --no-verify` doesn't get around it, and no hook is involved. -`git subtrees push` rewrites exactly that URL to the fetch URL for its own -push, and only for that push. It still pushes by the remote's name, so Git +`git subtrees push` rewrites exactly that URL, for its own push only, to +where the remote would push without it: the fetch URL, after any +`url..pushInsteadOf` or `url..insteadOf` rewriting. It still pushes by the remote's name, so Git updates the remote's tracking refs as before: no `fetch` is needed after a push, protected or not. @@ -67,7 +68,8 @@ The protection is part of the local repository's config, like the remote itself, so every clone of the monorepo needs it again. `git subtrees init` sets it for a remote that has no push URL yet. A remote with a push URL of its own keeps it, and `git subtrees push` pushes there; it just doesn't -count as protected. +count as protected. The same goes for a remote with several URLs, since a +push goes to all of them, but the protected URL can only stand for one. ## Output @@ -193,3 +195,24 @@ the tracking ref moved with the push, so `status` is up to date without a $ git subtrees status ok vendor/a [push-protected] (up to date) ``` + +## When you do need a plain push + +`git subtrees push` only sends subtree changes. To delete a branch on the +remote or force-push a split by hand, lift the protection for that one +push, and put it back right after. Here, the branch the slip created +earlier goes away (which, as said above, doesn't unpublish its commits): + +```scrut +$ git config --unset remote.vendor/a.pushurl +``` + +```scrut +$ git push vendor/a --delete topic +To $UPSTREAM + - [deleted] topic +``` + +```scrut +$ git remote set-url --push vendor/a 'BLOCKED by git-subtrees -- push with => git subtrees push' +``` diff --git a/walkthrough/setup.sh b/walkthrough/setup.sh index f3a7f0c..9cfb362 100755 --- a/walkthrough/setup.sh +++ b/walkthrough/setup.sh @@ -81,6 +81,9 @@ seed_bare_repo "$upstream_dir/pkg-b.git" "pkg-b: seed" echo "=== building monorepo ===" git init -q --initial-branch=main "$mono_dir" +# shellcheck disable=SC1091,SC2153 # sources lib/common.sh relative to this script +push_protected_url="$(source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/../lib/common.sh" && printf '%s' "$PUSH_PROTECTED_URL")" + ( cd "$mono_dir" git config user.name "Walkthrough" @@ -89,7 +92,7 @@ git init -q --initial-branch=main "$mono_dir" git remote add vendor/pkg-a "$upstream_dir/pkg-a.git" # Push-protected, as 'git subtrees init' would leave it. - git remote set-url --push vendor/pkg-a "BLOCKED by git-subtrees -- push with => git subtrees push" + git remote set-url --push vendor/pkg-a "$push_protected_url" git fetch -q vendor/pkg-a git subtree add -q --prefix=vendor/pkg-a vendor/pkg-a main --squash From dde0d291dd6028d5f5811eb34fa973ef80c1c8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 02:34:55 +0200 Subject: [PATCH 6/7] fix(push): leave a remote with another push URL to Git's own push A protected remote that later gets another push URL with `set-url --add --push` isn't protected any more, but push still rewrote the blocked URL, so it also pushed to the fetch URL nobody asked for. The rewrite now only applies to a remote whose only push URL is the blocked one. The recovery commands for a protected remote are one line joined with ';', so the protection comes back even if the force push fails. The color test gives its terminal a TERM: without one, as on CI runners, Git doesn't color, and status rightly follows it. The README now names Git 2.31, the oldest version CI tests and the first that reads config from GIT_CONFIG_COUNT, which push relies on. --- README.md | 1 + lib/common.sh | 19 +++++++++++-------- test/common.bats | 4 ++-- test/push.bats | 13 +++++++++++++ .../diverged-unrelated-history/README.md | 4 +--- test/status.bats | 4 ++-- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8128159..3c226bb 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ Requires: - Bash >= 4.4. macOS ships 3.2, so install a newer one (e.g. `brew install bash`) and put it first on your `PATH`. +- Git >= 2.31. - `git subtree`, which most Linux distributions bundle with git. Check with `git subtree --help`. diff --git a/lib/common.sh b/lib/common.sh index 2effc47..fe74d9d 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -217,13 +217,17 @@ unprotected_push_url() { # Runs "$@" with PUSH_PROTECTED_URL rewritten to where remote $1 would push # if it weren't protected, so a push to the remote by name gets through and -# updates its tracking refs. The rewrite only touches that one URL: a remote -# with a push URL of its own keeps pushing there. Passed through the -# environment rather than `git -c`, which splits at the first '=' a URL may -# contain. +# updates its tracking refs. Only for a protected remote: a remote with a +# push URL of its own keeps pushing there, even if PUSH_PROTECTED_URL is +# one of its push URLs. Passed through the environment rather than `git -c`, +# which splits at the first '=' a URL may contain. with_push_allowed() { local remote="$1" url n="${GIT_CONFIG_COUNT:-0}" shift + if ! is_push_protected "$remote"; then + "$@" + return + fi url="$(unprotected_push_url "$remote")" || return ( export GIT_CONFIG_COUNT=$((n + 1)) @@ -526,12 +530,11 @@ print_unrelated_history_guidance() { q_refspec="$(shell_quote "$tmp_branch:$branch")" # A push-protected remote refuses the force push, so the commands lift # the protection for it -- rather than push to the remote's URL, which - # may hold credentials and would skip url..pushInsteadOf. + # may hold credentials and would skip url..pushInsteadOf. One line + # joined with ';', so the protection comes back even if the push fails. local push_cmd="git push --force $q_path $q_refspec" if is_push_protected "$path"; then - push_cmd="git config --unset $(shell_quote "remote.$path.pushurl")" - push_cmd+=$'\n'" git push --force $q_path $q_refspec" - push_cmd+=$'\n'" git remote set-url --push $q_path $(shell_quote "$PUSH_PROTECTED_URL")" + push_cmd="git config --unset $(shell_quote "remote.$path.pushurl"); git push --force $q_path $q_refspec; git remote set-url --push $q_path $(shell_quote "$PUSH_PROTECTED_URL")" fi log_warn "$path: remote and local share no history -- pick one side manually:" cat >&2 < git subtrees push' + git config --unset remote.vendor/a.pushurl; git push --force vendor/a tmp-split-a:main; git remote set-url --push vendor/a 'BLOCKED by git-subtrees -- push with => git subtrees push' git branch -D tmp-split-a !! Failed: vendor/a diff --git a/test/status.bats b/test/status.bats index 3810d8f..aeb69b8 100644 --- a/test/status.bats +++ b/test/status.bats @@ -221,7 +221,7 @@ setup() { cd "$monorepo" git config --unset remote.vendor/a.pushurl - run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null + TERM=xterm run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null [ "$status" -eq 0 ] [[ "$output" == *$'\e[31m[NOT push-protected]\e[m'* ]] } @@ -236,7 +236,7 @@ setup() { [[ "$output" != *$'\e['* ]] git config color.status never - run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null + TERM=xterm run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null [[ "$output" == *"[NOT push-protected]"* ]] [[ "$output" != *$'\e['* ]] } From 11a96ad66bd3a781d1886248fe3538a108956fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Fri, 2 Oct 2026 16:54:33 +0200 Subject: [PATCH 7/7] fix(push): honor an empty pushInsteadOf, and keep every URL of a remote An empty url..pushInsteadOf matches every URL in Git, e.g. to map relative URLs onto a push host, but the longest-match check needed a non-empty prefix, so push went to the fetch URL instead. A protected remote that later got a second URL with `set-url --add` still counted as protected, so push rewrote the blocked URL to the first URL only and silently skipped the other. A remote now only counts as protected with a single URL, the rule init applies before protecting one; push then fails on the blocked URL instead of skipping a URL, and status marks the remote [NOT push-protected]. The fix command in `status -h` says it's for a remote without a push URL of its own, since it would replace one that was set on purpose. --- lib/common.sh | 32 ++++++++++++++++++++++---------- lib/status.sh | 3 ++- test/common.bats | 21 +++++++++++++++++++++ test/push.bats | 16 ++++++++++++++++ 4 files changed, 61 insertions(+), 11 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index fe74d9d..acdb3e1 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -179,17 +179,25 @@ push_urls() { git config --get-all "remote.$1.pushurl" 2>/dev/null || true } -# Succeeds if remote $1's only push URL is PUSH_PROTECTED_URL. A remote -# with a push URL of its own is left alone, and isn't protected. +# Succeeds if remote $1 has exactly one URL. Without a push URL, Git pushes +# to all of a remote's URLs, but PUSH_PROTECTED_URL can only be rewritten to +# one of them. +has_single_url() { + [[ "$(git config --get-all "remote.$1.url" 2>/dev/null | wc -l)" -eq 1 ]] +} + +# Succeeds if remote $1's only push URL is PUSH_PROTECTED_URL and it has a +# single URL to push to instead. A remote with a push URL of its own is left +# alone, and isn't protected; neither is one that got another URL later, so +# push leaves its blocked URL in place and fails instead of skipping a URL. is_push_protected() { - [[ "$(push_urls "$1")" == "$PUSH_PROTECTED_URL" ]] + [[ "$(push_urls "$1")" == "$PUSH_PROTECTED_URL" ]] && has_single_url "$1" } -# Succeeds if init may push-protect remote $1. Not if it has a push URL of -# its own, or several URLs: Git pushes to all of them, but the protected URL -# can only be rewritten to one. +# Succeeds if init may push-protect remote $1: it has no push URL of its +# own, and a single URL. can_push_protect() { - [[ -z "$(push_urls "$1")" && "$(git config --get-all "remote.$1.url" 2>/dev/null | wc -l)" -eq 1 ]] + [[ -z "$(push_urls "$1")" ]] && has_single_url "$1" } # Prints the URL a push to remote $1 would go to if it weren't protected. @@ -197,18 +205,22 @@ can_push_protect() { # url..pushInsteadOf, else with url..insteadOf -- but not a # push URL like PUSH_PROTECTED_URL, so with_push_allowed has to do it. unprotected_push_url() { - local raw entry key prefix best_base="" best_prefix="" + local raw entry key prefix found=0 best_base="" best_prefix="" raw="$(git config --get "remote.$1.url")" || return while IFS= read -r -d '' entry; do + # A key without '=' has no newline and no value: not a prefix. + [[ "$entry" == *$'\n'* ]] || continue key="${entry%%$'\n'*}" prefix="${entry#*$'\n'}" - if [[ "$raw" == "$prefix"* && ${#prefix} -gt ${#best_prefix} ]]; then + # An empty prefix matches every URL, like in Git. + if [[ "$raw" == "$prefix"* ]] && ((!found || ${#prefix} > ${#best_prefix})); then + found=1 best_prefix="$prefix" best_base="${key#url.}" best_base="${best_base%.*}" fi done < <(git config -z --get-regexp '^url\..*\.pushinsteadof$' 2>/dev/null || true) - if [[ -n "$best_prefix" ]]; then + if ((found)); then printf '%s%s\n' "$best_base" "${raw#"$best_prefix"}" else git remote get-url -- "$1" diff --git a/lib/status.sh b/lib/status.sh index 58cd723..158a844 100644 --- a/lib/status.sh +++ b/lib/status.sh @@ -15,7 +15,8 @@ monorepo's base branch (--base, else origin/HEAD, else init.defaultBranch). Each subtree is marked [push-protected] or [NOT push-protected]. A plain 'git push' to a remote that isn't protected sends the whole monorepo there. -Protect it like 'git subtrees init' does: +A remote without a push URL of its own, and with a single URL, can be +protected like 'git subtrees init' does: git remote set-url --push $(shell_quote "$PUSH_PROTECTED_URL") EOF diff --git a/test/common.bats b/test/common.bats index 91c56c1..e500708 100644 --- a/test/common.bats +++ b/test/common.bats @@ -531,3 +531,24 @@ git_push_target() { [ "$(unprotected_push_url vendor/a)" = "$(git_push_target vendor/a)" ] [ "$(unprotected_push_url vendor/a)" = "git@example.com:org/special-a.git" ] } + +@test "unprotected_push_url: an empty pushInsteadOf prefix matches every URL, like in Git" { + init_monorepo "$monorepo" + cd "$monorepo" + add_subtree_remote vendor/a "org/a.git" + git config url.ssh://example.com/.pushInsteadOf "" + + [ "$(unprotected_push_url vendor/a)" = "$(git_push_target vendor/a)" ] + [ "$(unprotected_push_url vendor/a)" = "ssh://example.com/org/a.git" ] +} + +@test "is_push_protected: not for a protected remote that got another URL later" { + init_monorepo "$monorepo" + cd "$monorepo" + add_subtree_remote vendor/a "$upstream" + is_push_protected vendor/a + + git remote set-url --add vendor/a "$BATS_TEST_TMPDIR/mirror.git" + run is_push_protected vendor/a + [ "$status" -eq 1 ] +} diff --git a/test/push.bats b/test/push.bats index 4652015..89bfc23 100644 --- a/test/push.bats +++ b/test/push.bats @@ -376,3 +376,19 @@ remote_has_branch() { run push_one "vendor/a" "main" [ "$(git -C "$upstream" rev-parse main)" = "$before" ] } + +@test "push: a protected remote that got another URL later fails instead of skipping that URL" { + scenario_push_ahead "$monorepo" "$upstream" + local mirror="$BATS_TEST_TMPDIR/mirror.git" + git clone -q --bare "$upstream" "$mirror" + cd "$monorepo" + git remote set-url --add vendor/a "$mirror" + local before + before="$(git -C "$upstream" rev-parse main)" + + run push_one "vendor/a" "main" + [ "$status" -ne 0 ] + [[ "$output" == *"'$PUSH_PROTECTED_URL' does not appear to be a git repository"* ]] + [ "$(git -C "$upstream" rev-parse main)" = "$before" ] + [ "$(git -C "$mirror" rev-parse main)" = "$before" ] +}