Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
151 changes: 151 additions & 0 deletions git-trees
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
# git trees track [path] [--no-push]
# git trees list [--json] (alias: ls)
# git trees rm <branch|path> [--apply]
# git trees sync [worktree] [--pull] [--ff-only|--rebase]
# git trees clean [--merged|--gone] [--apply]
#
# Env (all optional):
Expand Down Expand Up @@ -144,6 +145,29 @@ _ref_info() { # _ref_info <branch> -> 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 <path> -> 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 <bare> 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
Expand Down Expand Up @@ -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 <branch|path> -> 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() {
Expand Down Expand Up @@ -763,6 +911,8 @@ usage: git trees <command> [args]
track [path] [--no-push] ensure branch has an upstream
list [--json] worktrees + branches without one
rm <branch|path> [--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


Expand Down Expand Up @@ -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 ;;
Expand Down
136 changes: 136 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ assert_contains "help lists add" "$out" "add <branch>"
assert_contains "help lists list" "$out" "list [--json]"
assert_contains "help lists rm" "$out" "rm <branch|path>"
assert_contains "help lists clean" "$out" "clean [--merged|--gone]"
assert_contains "help lists sync" "$out" "sync [worktree]"


section "outside a repo"
Expand Down Expand Up @@ -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
Expand Down
Loading