diff --git a/README.md b/README.md index e5588cf..7aa8b4a 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,60 @@ directory. > you want `git worktree remove --force` semantics deliberately. +### `git trees sync [worktree] [--pull] [--ff-only|--rebase]` + +Brings the container up to date with `origin`. With no positional argument it +covers every worktree; passing one names a single worktree, by branch name or by +path. + +**The default is fetch only** — it runs one `git fetch --prune origin` and stops. +Nothing in any working tree is touched, so there is no `--apply` gate: the +command acts immediately. Every worktree shares a single object store, so one +fetch updates the remote-tracking refs for all of them; fetching per worktree +would transfer nothing after the first and cost only round-trips. + +`--pull` then updates the working trees from the refs that fetch just brought in. + +| Strategy | Behavior | +|---|---| +| `--ff-only` (default) | `git merge --ff-only @{upstream}`; refuses to touch a diverged branch | +| `--rebase` | `git rebase @{upstream}`; replays local commits on top of the upstream | + +`--ff-only` is the default because it is the only update that can neither discard +work nor stop half-finished. The two are mutually exclusive, and passing either +without `--pull` is an error rather than a silent no-op — `sync --rebase` that +only fetched would look like it had rebased. + +There is no `git pull` under the hood, deliberately: `pull` re-fetches on every +invocation, which would undo the single-fetch design. `merge --ff-only` and +`rebase` against `@{upstream}` need no fetch of their own and are idempotent. + +Under `--pull`, a worktree is skipped when: + +| Situation | Behavior | +|---|---| +| Detached HEAD | Reported on stderr, **not** counted as a failure — detaching is deliberate | +| No upstream | Reported, with `git trees track` named as the remedy; counted as a failure | +| Uncommitted changes | Reported and skipped; counted as a failure | +| Diverged under `--ff-only` | Reported, with `--rebase` named as the remedy; counted as a failure | +| Rebase conflict | Reported; the worktree is **left mid-rebase** so you can resolve it, or run `git rebase --abort` | + +Dirtiness includes untracked files, matching the `dirty` column in +[`git trees list`](#git-trees-list---json) and `git worktree remove`'s own +refusal — so a stray `.DS_Store` is enough to skip a pull. + +The branch name of each successfully updated worktree goes to stdout, one per +line; every notice, warning, and error goes to stderr. `sync` exits nonzero if +any worktree was skipped for a reason above other than a detached HEAD, or if the +fetch itself failed — in which case nothing is pulled. The loop always runs to +completion, so a nonzero exit means partial success, not a stop. + +```bash +git trees sync # fetch origin, touch nothing +git trees sync --pull # fast-forward every clean, tracked worktree +git trees sync feature-x --pull --rebase # rebase one worktree onto its upstream +``` + ### `git trees clean [--merged|--gone] [--apply]` Reports or removes stale worktrees and branches. diff --git a/git-trees b/git-trees index 786496a..5035489 100755 --- a/git-trees +++ b/git-trees @@ -9,6 +9,7 @@ # git trees track [path] [--no-push] # git trees list [--json] (alias: ls) # git trees rm [--apply] +# git trees sync [worktree] [--pull] [--ff-only|--rebase] # git trees clean [--merged|--gone] [--apply] # # Env (all optional): @@ -144,6 +145,29 @@ _ref_info() { # _ref_info -> upstream|track|date "refs/heads/$1" } +# Untracked files count as dirty here. That is deliberate: it matches the dirty +# column in `git trees list` and `git worktree remove`'s own refusal, so one +# definition of "has work in it" holds across the tool. The cost is that a stray +# .DS_Store is enough to make `sync --pull` skip a worktree. +_is_dirty() { # _is_dirty -> 0 if the work tree has changes + [ -n "$(git -C "$1" status --porcelain 2>/dev/null)" ] +} + +# Every worktree path except the container's own bare store. `git worktree list +# --porcelain` emits a `bare` stanza for it, and that entry has no work tree at +# all — `git -C status` exits 128 — so any iteration over worktrees has to +# drop it. Stanzas are blank-line separated; a `bare` line marks the record it +# appears in, so the path is held back until the record ends. +_worktree_paths() { + git worktree list --porcelain \ + | awk ' + /^worktree /{p=$0; sub(/^worktree /, "", p); bare=0; next} + /^bare$/{bare=1; next} + /^$/{ if (p != "" && !bare) print p; p=""; bare=0 } + END { if (p != "" && !bare) print p } + ' +} + _is_bare_dir() { local p="$1" [ -f "$p/HEAD" ] && [ -d "$p/refs" ] && [ -d "$p/objects" ] && [ ! -e "$p/.git" ] || return 1 @@ -670,6 +694,130 @@ cmd_rm() { } +# --- sync -------------------------------------------------------------------- + +# Resolve a positional target to a worktree path. Local to sync on purpose: +# cmd_rm's resolution carries a worktree-registration gate that exists to keep a +# custom TREES_RM_CMD away from the container root, a concern sync does not have, +# and its messages name `git trees rm`. +_sync_target() { # _sync_target -> worktree path, or empty + local target="$1" path="" + + if git show-ref --verify --quiet "refs/heads/$target"; then + path=$(_path_for "$target") + elif [ -d "$target" ]; then + # `pwd -P`, not `pwd`: git records worktrees by physical path, so a logical + # one (macOS /var -> /private/var) would match nothing in the loop below. + path=$(cd "$target" 2>/dev/null && pwd -P) + fi + + [ -n "$path" ] && printf '%s\n' "$path" +} + +cmd_sync() { + local target="" pull=0 strategy="" want_path="" failed=0 path br up + + while [ $# -gt 0 ]; do + case "$1" in + --pull) pull=1; shift ;; + --ff-only|--rebase) + # Two strategies cannot both apply, and silently keeping the last one + # would misreport what the command did. + if [ -n "$strategy" ] && [ "$strategy" != "${1#--}" ]; then + echo "git trees sync: --ff-only and --rebase are mutually exclusive" >&2 + return 1 + fi + strategy="${1#--}"; shift ;; + -*) echo "git trees sync: unknown option $1" >&2; return 1 ;; + *) + if [ -z "$target" ]; then target="$1"; shift + else echo "git trees sync: unexpected argument $1" >&2; return 1 + fi ;; + esac + done + + # A strategy without --pull would be a no-op, and `sync --rebase` silently + # only fetching would look like it had rebased. + if [ -n "$strategy" ] && [ "$pull" -eq 0 ]; then + echo "git trees sync: --$strategy requires --pull" >&2 + echo "usage: git trees sync [worktree] [--pull] [--ff-only|--rebase]" >&2 + return 1 + fi + : "${strategy:=ff-only}" + + _root >/dev/null || { echo "git trees sync: not in a git repo" >&2; return 1; } + + # Resolved before the fetch: a typo'd target should not fire a network op. + if [ -n "$target" ]; then + want_path=$(_sync_target "$target") + [ -n "$want_path" ] || { + echo "git trees sync: target '$target' is not a worktree" >&2 + return 1 + } + fi + + # One fetch for the whole container. Every worktree shares a single object + # store, so a per-worktree fetch transfers nothing after the first and costs + # only round-trips. Not silenced (unlike clean's): with no flags the fetch is + # the entire job, and a silent success would be indistinguishable from a no-op. + git fetch --prune origin || { + echo "git trees sync: fetch from origin failed" >&2 + return 1 + } + + [ "$pull" -eq 0 ] && return 0 + + while read -r path; do + [ -z "$path" ] && continue + [ -n "$want_path" ] && [ "$path" != "$want_path" ] && continue + + br=$(git -C "$path" symbolic-ref --quiet --short HEAD 2>/dev/null) || { + # Not a failure: detaching is deliberate, and failing here would make + # `sync --pull` permanently nonzero for anyone keeping such a worktree. + echo "git trees sync: skipping $path — detached HEAD" >&2 + continue + } + + up=$(git -C "$path" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null) || { + echo "git trees sync: skipping $br — no upstream (set one with: git trees track \"$path\")" >&2 + failed=1 + continue + } + + # Only checked under --pull; fetch-only never touches a work tree, so the + # check would be pure cost there. + if _is_dirty "$path"; then + echo "git trees sync: skipping $br — uncommitted changes" >&2 + failed=1 + continue + fi + + # `git merge`/`git rebase` against @{upstream}, never `git pull`: pull would + # re-fetch once per worktree, undoing the single fetch above. Both are + # idempotent and need no fetch of their own — the refs are already current. + if [ "$strategy" = "rebase" ]; then + if git -C "$path" rebase "$up" >/dev/null 2>&1; then + echo "$br" + else + # Deliberately not auto-aborting: that would discard the user's chance + # to resolve the conflict themselves. + echo "git trees sync: $br left mid-rebase — resolve, or run: git -C \"$path\" rebase --abort" >&2 + failed=1 + fi + else + if git -C "$path" merge --ff-only "$up" >/dev/null 2>&1; then + echo "$br" + else + echo "git trees sync: $br has diverged from $up — retry with --rebase" >&2 + failed=1 + fi + fi + done < <(_worktree_paths) + + return "$failed" +} + + # --- clean ------------------------------------------------------------------- cmd_clean() { @@ -763,6 +911,8 @@ usage: git trees [args] track [path] [--no-push] ensure branch has an upstream list [--json] worktrees + branches without one rm [--apply] remove worktree and delete branch + sync [worktree] [--pull] [--ff-only|--rebase] + fetch origin; --pull updates worktrees clean [--merged|--gone] [--apply] report/remove merged or gone branches @@ -795,6 +945,7 @@ main() { track) cmd_track "$@" ;; list|ls) cmd_list "$@" ;; rm) cmd_rm "$@" ;; + sync) cmd_sync "$@" ;; clean) cmd_clean "$@" ;; help|--help|-h) usage; return 0 ;; *) echo "git trees: unknown command '$cmd'" >&2; usage; return 1 ;; diff --git a/tests/smoke.sh b/tests/smoke.sh index 63d4f59..dec54b4 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -144,6 +144,7 @@ assert_contains "help lists add" "$out" "add " assert_contains "help lists list" "$out" "list [--json]" assert_contains "help lists rm" "$out" "rm " assert_contains "help lists clean" "$out" "clean [--merged|--gone]" +assert_contains "help lists sync" "$out" "sync [worktree]" section "outside a repo" @@ -550,6 +551,141 @@ assert_fail "rm with no argument" bash "$T" rm assert_fail "rm with nonexistent target" bash "$T" rm nonexistent +# --- sync -------------------------------------------------------------------- + +# These fixtures mutate the shared $ORIGIN, so they commit on `feature-x` ONLY, +# never on `main`. Every new_container clones $ORIGIN and `clean` below derives +# its expectations from main's history; a commit on main here would change what +# later sections see. The `clean` section must still run last for the same +# reason — it mutates main. +section "sync" +SYNC_C=$(new_container sync-c) +cd "$SYNC_C" || exit 1 + +assert_ok "sync: add feature-x worktree" bash "$T" add feature-x --no-push +assert_ok "sync: feature-x tracks origin" \ + in_dir feature-x git rev-parse --abbrev-ref '@{upstream}' + +# Advance origin/feature-x behind the container's back. Asserted step by step: +# a fixture that failed quietly would leave feature-x already up to date, and +# every assertion below would pass without testing anything. +assert_ok "sync: checkout feature-x on origin" in_dir "$ORIGIN" git checkout -q feature-x +echo "upstream change" > "$ORIGIN/upstream.txt" +assert_ok "sync: stage upstream change" in_dir "$ORIGIN" git add upstream.txt +assert_ok "sync: commit upstream change" in_dir "$ORIGIN" git commit -qm "upstream commit" +assert_ok "sync: leave origin on main" in_dir "$ORIGIN" git checkout -q main + +before_head=$(git -C "$SYNC_C/feature-x" rev-parse HEAD) +before_remote=$(git -C "$SYNC_C" rev-parse origin/feature-x) + +# Fetch only: the remote-tracking ref advances, the work tree does not. +assert_ok "sync (fetch only) exits 0" bash "$T" sync +assert_eq "sync fetch advanced origin/feature-x" \ + "$(git -C "$SYNC_C" rev-parse origin/feature-x)" \ + "$(git -C "$ORIGIN" rev-parse feature-x)" +assert_fail "sync fetch actually moved the remote ref" \ + test "$before_remote" = "$(git -C "$SYNC_C" rev-parse origin/feature-x)" +assert_eq "sync fetch left the worktree HEAD alone" \ + "$(git -C "$SYNC_C/feature-x" rev-parse HEAD)" "$before_head" +assert_fail "sync fetch did not write the upstream file" test -e feature-x/upstream.txt + +# --pull fast-forwards and names the branch on stdout. +out=$(bash "$T" sync --pull 2>/dev/null) +assert_contains "sync --pull names the updated branch on stdout" "$out" "feature-x" +assert_eq "sync --pull fast-forwarded the worktree" \ + "$(git -C "$SYNC_C/feature-x" rev-parse HEAD)" \ + "$(git -C "$SYNC_C" rev-parse origin/feature-x)" +assert_ok "sync --pull applied the upstream file" test -e feature-x/upstream.txt + +# A dirty worktree is skipped: nonzero exit, uncommitted work preserved, and the +# upstream change NOT applied over it. +assert_ok "sync: checkout feature-x on origin again" in_dir "$ORIGIN" git checkout -q feature-x +echo "second upstream change" > "$ORIGIN/upstream2.txt" +assert_ok "sync: stage second upstream change" in_dir "$ORIGIN" git add upstream2.txt +assert_ok "sync: commit second upstream change" in_dir "$ORIGIN" git commit -qm "second upstream commit" +assert_ok "sync: back to main on origin" in_dir "$ORIGIN" git checkout -q main + +echo "my work in progress" > feature-x/dirty.txt +assert_fail "sync --pull exits nonzero on a dirty worktree" bash "$T" sync feature-x --pull +out=$(bash "$T" sync feature-x --pull 2>&1 >/dev/null) +assert_contains "sync reports the dirty skip" "$out" "uncommitted changes" +assert_ok "sync left the uncommitted file in place" test -e feature-x/dirty.txt +assert_fail "sync did not apply the upstream change over dirty work" \ + test -e feature-x/upstream2.txt +rm -f feature-x/dirty.txt + +# Clean again, so the pending upstream commit lands and later cases start level. +assert_ok "sync --pull after cleaning the worktree" bash "$T" sync feature-x --pull +assert_ok "sync applied the second upstream change" test -e feature-x/upstream2.txt + +# Single target by branch name and by path both resolve to the same worktree. +out=$(bash "$T" sync feature-x --pull 2>/dev/null) +assert_eq "sync by branch name targets only that worktree" "$out" "feature-x" +out=$(bash "$T" sync "$SYNC_C/feature-x" --pull 2>/dev/null) +assert_eq "sync by path targets only that worktree" "$out" "feature-x" + +# No upstream: skipped, named, and counted as a failure. +assert_ok "sync: add branch with no upstream" bash "$T" add no-upstream --no-push +assert_fail "sync --pull exits nonzero with an untracked branch" \ + bash "$T" sync no-upstream --pull +out=$(bash "$T" sync no-upstream --pull 2>&1 >/dev/null) +assert_contains "sync reports the missing upstream" "$out" "no upstream" +assert_contains "sync names track as the remedy" "$out" "git trees track" + +# Detached HEAD: reported, but not a failure on its own — detaching is +# deliberate, and failing would make `sync --pull` permanently nonzero. +assert_ok "sync: add detached worktree" bash "$T" add detached-wt --no-push +assert_ok "sync: detach its HEAD" \ + in_dir detached-wt git -c advice.detachedHead=false checkout -q HEAD~0 --detach +out=$(bash "$T" sync "$SYNC_C/detached-wt" --pull 2>&1 >/dev/null) +assert_contains "sync reports the detached HEAD skip" "$out" "detached HEAD" +assert_ok "sync --pull exits 0 for a detached worktree alone" \ + bash "$T" sync "$SYNC_C/detached-wt" --pull + +# Divergence: --ff-only refuses (git exits 128, not 1 — assert nonzero only), +# the local commit survives, and --rebase gets past it keeping both commits. +assert_ok "sync: checkout feature-x on origin for divergence" \ + in_dir "$ORIGIN" git checkout -q feature-x +echo "diverging upstream" > "$ORIGIN/diverge-remote.txt" +assert_ok "sync: stage diverging upstream" in_dir "$ORIGIN" git add diverge-remote.txt +assert_ok "sync: commit diverging upstream" in_dir "$ORIGIN" git commit -qm "diverging upstream commit" +assert_ok "sync: origin back to main after divergence" in_dir "$ORIGIN" git checkout -q main + +echo "diverging local" > feature-x/diverge-local.txt +assert_ok "sync: stage diverging local" in_dir feature-x git add diverge-local.txt +assert_ok "sync: commit diverging local" in_dir feature-x git commit -qm "diverging local commit" +local_commit=$(git -C "$SYNC_C/feature-x" rev-parse HEAD) + +assert_fail "sync --pull --ff-only exits nonzero when diverged" \ + bash "$T" sync feature-x --pull --ff-only +out=$(bash "$T" sync feature-x --pull --ff-only 2>&1 >/dev/null) +assert_contains "sync reports the divergence" "$out" "diverged" +assert_contains "sync names --rebase as the remedy" "$out" "--rebase" +assert_eq "sync --ff-only preserved the local commit" \ + "$(git -C "$SYNC_C/feature-x" rev-parse HEAD)" "$local_commit" + +assert_ok "sync --pull --rebase gets past the divergence" \ + bash "$T" sync feature-x --pull --rebase +assert_ok "sync --rebase kept the local change" test -e feature-x/diverge-local.txt +assert_ok "sync --rebase applied the upstream change" test -e feature-x/diverge-remote.txt +assert_ok "sync --rebase left no rebase in progress" \ + test ! -d "$(git -C "$SYNC_C/feature-x" rev-parse --git-path rebase-merge)" + +# Argument validation. +assert_fail "sync rejects --ff-only with --rebase" \ + bash "$T" sync --pull --ff-only --rebase +out=$(bash "$T" sync --pull --ff-only --rebase 2>&1 >/dev/null) +assert_contains "sync explains the strategy conflict" "$out" "mutually exclusive" +assert_fail "sync rejects --ff-only without --pull" bash "$T" sync --ff-only +assert_fail "sync rejects --rebase without --pull" bash "$T" sync --rebase +out=$(bash "$T" sync --rebase 2>&1 >/dev/null) +assert_contains "sync explains that a strategy needs --pull" "$out" "requires --pull" +assert_fail "sync rejects an unknown option" bash "$T" sync --nope +assert_fail "sync rejects a second positional" bash "$T" sync feature-x extra +assert_fail "sync rejects a nonexistent target" bash "$T" sync definitely-not-a-worktree +assert_fail "sync outside a repo" in_dir "$TMP/plain" bash "$T" sync + + # --- clean ------------------------------------------------------------------- # KEEP THIS SECTION LAST. Its fixtures mutate the shared $ORIGIN — deleting a