diff --git a/README.md b/README.md index e5f29d6..3c226bb 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,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 +124,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. @@ -149,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 7bc975b..acdb3e1 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -166,6 +166,88 @@ 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 hands it to ssh as a host name +# and prints an ssh error instead. +# 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 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" ]] && has_single_url "$1" +} + +# 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")" ]] && has_single_url "$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 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'}" + # 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 ((found)); then + printf '%s%s\n' "$best_base" "${raw#"$best_prefix"}" + else + git remote get-url -- "$1" + fi +} + +# 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. 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)) + 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 +404,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 +434,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 +540,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, 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. 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"); 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 <' can't send the whole monorepo there. 'git subtrees push' +still works. EOF } @@ -117,6 +122,10 @@ cmd_init() { log_step "$path: registering remote -> $url" git remote add -- "$path" "$url" fi + 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 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..158a844 100644 --- a/lib/status.sh +++ b/lib/status.sh @@ -1,22 +1,58 @@ # 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 -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. +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 } status_warn() { printf '?? %s\n' "$*"; } +# 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 + 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" + 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 +} + # 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 +60,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 +89,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,18 +112,19 @@ 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() { parse_base_args usage_status "$@" + set_status_color local base="$BASE_ARG" local paths=("${PATH_ARGS[@]}") 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..e500708 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,52 @@ 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" + + 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" ] +} - classify_subtree "-n" "main" - [ "$SUBTREE_URL" = "$upstream" ] +@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" + + 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" + + run is_push_protected vendor/a + [ "$status" -eq 1 ] } @test "classify_subtree: not-connected" { @@ -241,7 +276,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 +491,64 @@ 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: lifts a protected remote's protection for the force push in one line, without printing its URL" { + init_monorepo "$monorepo" + cd "$monorepo" + 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 config --unset remote.vendor/a.pushurl; git push --force vendor/a tmp-split-a:main; 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" ] +} + +@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/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..e88e029 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,65 @@ 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" + 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 ] + [[ "$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" ] +} + +@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/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..89bfc23 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 vendor/a 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,91 @@ 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" ] +} + +@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" ] +} + +@test "push: a protected remote with another push URL added later doesn't push to its fetch URL" { + scenario_push_ahead "$monorepo" "$upstream" + local other="$BATS_TEST_TMPDIR/other.git" + git clone -q --bare "$upstream" "$other" + cd "$monorepo" + git remote set-url --add --push vendor/a "$other" + local before + before="$(git -C "$upstream" rev-parse main)" + + 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" ] +} 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..89198ee 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,7 @@ 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 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/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..6e1c006 --- /dev/null +++ b/test/scenarios/push-protection/README.md @@ -0,0 +1,218 @@ +# 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. + +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. 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 `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. + +`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. + +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. 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 + +`scenario_push_protection` in [`setup.bash`](setup.bash) +builds this state. [How scenarios work](../README.md). + + + +`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) +``` + +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 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 config push.autoSetupRemote true && git switch -q -c topic +``` + +```scrut +$ git push +To $UPSTREAM + * [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 topic +internal/notes.txt +vendor/a/file.txt +``` + +Protect the remote, as `git subtrees status -h` shows (or by running +`git subtrees init vendor/a ` again): + +```scrut +$ 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 +$ echo "more notes" >>internal/notes.txt && git commit -qam "more internal notes" +``` + +```scrut +$ 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 +and the repository exists. +[128] +``` + +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: '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 +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) +``` + +## 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/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..aeb69b8 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,69 @@ 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: 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)" ] +} + +@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="$(usage_status | grep 'git remote set-url --push')" + eval "${fix///vendor/a}" + 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 [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 + + TERM=xterm 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 + + run cmd_status + [[ "$output" != *$'\e['* ]] + + git config color.status never + TERM=xterm run script -qec "$BATS_TEST_DIRNAME/../git-subtrees status" /dev/null + [[ "$output" == *"[NOT push-protected]"* ]] + [[ "$output" != *$'\e['* ]] +} 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..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" @@ -88,6 +91,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_protected_url" git fetch -q vendor/pkg-a git subtree add -q --prefix=vendor/pkg-a vendor/pkg-a main --squash