Skip to content
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> <url>` adds the remote
if it's missing. It never changes the URL of an existing remote. If
`<path>` 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 `<path>` 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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`.

Expand Down
95 changes: 92 additions & 3 deletions lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <remote>` 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"
Comment thread
roschaefer marked this conversation as resolved.

# 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.<base>.pushInsteadOf, else with url.<base>.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"
Comment thread
roschaefer marked this conversation as resolved.
Comment thread
roschaefer marked this conversation as resolved.
"$@"
)
}

# 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, ...),
Expand Down Expand Up @@ -322,7 +404,7 @@ touched_since_sync() {
}

# Classifies subtree <path>'s sync state against remote <path>'s <branch>.
# 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.
#
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.<base>.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 <<EOF

Expand All @@ -469,7 +558,7 @@ print_unrelated_history_guidance() {

# OR: accept the local (monorepo) version, overwriting $path's history:
git subtree split --prefix=$q_path -b $q_tmp
git push --force $q_path $q_refspec
$push_cmd
git branch -D $q_tmp

EOF
Expand Down
9 changes: 9 additions & 0 deletions lib/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ base branch instead, so your first push creates the missing branch on top
of it. The base branch is taken from --base, else from origin/HEAD, else
from init.defaultBranch. If none resolves, or the remote lacks that branch
too (e.g. it's empty), init only registers the remote.

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 <path>' can't send the whole monorepo there. 'git subtrees push'
still works.
EOF
}

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/push.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
63 changes: 50 additions & 13 deletions lib/status.sh
Original file line number Diff line number Diff line change
@@ -1,46 +1,82 @@
# Assumes lib/common.sh is already sourced.

usage_status() {
cat <<'EOF'
cat <<EOF
usage: git subtrees status [--base <branch>] [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 <path> $(shell_quote "$PUSH_PROTECTED_URL")
Comment thread
roschaefer marked this conversation as resolved.
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() {
local path="$1" branch="$2" base="$3"
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 <branch>)"
status_warn "$(subtree_label "$path") (no '$branch' branch on remote; monorepo base branch unknown -- pass --base <branch>)"
fi
;;
esac
Expand All @@ -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
Expand All @@ -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[@]}")

Expand Down
2 changes: 1 addition & 1 deletion test/cli.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
Loading
Loading