From fa00cde07bab54996ab348fca5c40153f91cd75a Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 21 Aug 2026 11:56:41 -0400 Subject: [PATCH 1/7] Add `git trees prune` subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces `git worktree prune` as a user-facing command. When a worktree directory is deleted by hand (`rm -rf feature-x`) instead of through `git trees rm`, git's administrative entry lingers in `git worktree list` and keeps the branch locked against a fresh checkout. `prune` acts immediately with only a `--dry-run` preview, deliberately exempt from the `--apply` rule: it unlinks metadata only for directories already gone from disk, and leaves the branch untouched, so there is no work to lose. AGENTS.md is amended to scope that rule to operations that can lose work and to name prune as the exception. The report is captured before acting, from stderr (`git worktree prune --verbose` writes there, not stdout), and parsed with a whole-line regex so a name containing a space is not truncated by field splitting. `cmd_clean` is left unmodified — it already prunes internally. Closes #55 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- README.md | 18 +++++++++++++++ git-trees | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/smoke.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4e6e039..9d518de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ consequence is that `feature/x` and `feature-x` compete for one directory; the directory (`_branch_at`). Do not "fix" that by inventing a suffixed variant: a directory whose name the user cannot predict is worse than an error. -**Nothing destructive without `--apply`.** `rm` and `clean` report by default and modify state only when `--apply` is explicitly passed. Local branch deletions use `git branch -d` (falling back to `-D` on `clean` once confirmed gone/merged, or on `rm` when `--apply` is passed), and worktree directory removals route through `TREES_RM_CMD` when configured (defaulting to `git worktree remove`). +**Nothing that can lose work without `--apply`.** `rm` and `clean` report by default and modify state only when `--apply` is explicitly passed. Local branch deletions use `git branch -d` (falling back to `-D` on `clean` once confirmed gone/merged, or on `rm` when `--apply` is passed), and worktree directory removals route through `TREES_RM_CMD` when configured (defaulting to `git worktree remove`). `prune` is the deliberate exception: it only unlinks metadata for worktree directories already gone from disk, leaving the branch intact, so there is nothing to lose and it acts immediately with only a `--dry-run` preview. **`TREES_RM_CMD` is the one place the safety net comes off.** `git worktree remove` refuses a worktree with uncommitted changes or untracked files; a custom diff --git a/README.md b/README.md index e5588cf..89421aa 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,24 @@ By default (without `--apply`), `clean` operates in dry-run mode and prints matc on stderr, and exits nonzero if any of them did. +### `git trees prune [--dry-run]` + +Drops git's administrative entries for worktree directories that are no longer on disk. + +When a worktree directory is deleted by hand (`rm -rf feature-x`) instead of through `git trees rm`, git keeps its bookkeeping under the bare store. The stale entry keeps showing up in `git worktree list` and holds the branch locked against a fresh checkout. `prune` clears those entries. + +Stale worktree names are printed to stdout, one per line; git's reason for each goes to stderr. With nothing to prune it prints a notice on stderr and exits 0. + +Pass `--dry-run` to list what would be dropped without touching anything. + +> **Unlike `rm` and `clean`, `prune` acts immediately — there is no `--apply`.** It only removes metadata for directories that are *already gone*; a worktree still on disk is never a candidate, and the branch a pruned entry held is left alone. There is no work to lose. + +```bash +git trees prune --dry-run # list stale entries, change nothing +git trees prune # drop them +``` + + ## Removing worktrees diff --git a/git-trees b/git-trees index 786496a..e2cd8af 100755 --- a/git-trees +++ b/git-trees @@ -10,6 +10,7 @@ # git trees list [--json] (alias: ls) # git trees rm [--apply] # git trees clean [--merged|--gone] [--apply] +# git trees prune [--dry-run] # # Env (all optional): # TREES_HOST default host for init (default: github.com) @@ -750,6 +751,64 @@ cmd_clean() { +# --- prune ------------------------------------------------------------------- + +cmd_prune() { + local dry=0 report names failed=0 + + while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry=1; shift ;; + -*) echo "git trees prune: unknown option $1" >&2; return 1 ;; + *) echo "usage: git trees prune [--dry-run]" >&2; return 1 ;; + esac + done + + _root >/dev/null || { echo "git trees prune: not in a git repo" >&2; return 1; } + + # Exempt from the "nothing destructive without --apply" rule on purpose: this + # only unlinks $GIT_COMMON_DIR/worktrees// metadata for directories that + # are ALREADY GONE from disk. A directory still present is never a candidate, + # and the branch the entry held is left untouched — there is no work to lose. + # Gating it behind --apply would make the common case a no-op plus a nag. + # + # 2>&1 is required: `git worktree prune --verbose` writes its report to stderr, + # so a plain command substitution captures nothing. Capture before acting so + # the list survives a prune that fails partway, and so --dry-run can report it. + report=$(git worktree prune --dry-run --verbose 2>&1) + + if [ -z "$report" ]; then + echo "git trees prune: nothing to prune" >&2 + return 0 + fi + + # Whole-line match rather than $2: a worktree name may contain a space, which + # field splitting would truncate. + names=$(printf '%s\n' "$report" | awk ' + /^Removing worktrees\// { + n = $0 + sub(/^Removing worktrees\//, "", n) + sub(/: .*$/, "", n) + print n + }') + + [ -n "$names" ] && printf '%s\n' "$names" + printf '%s\n' "$report" >&2 + + if [ "$dry" -eq 1 ]; then + echo "(dry run — no metadata was removed)" >&2 + return 0 + fi + + if ! git worktree prune; then + echo "git trees prune: git worktree prune failed" >&2 + failed=1 + fi + + return "$failed" +} + + # --- usage / dispatch -------------------------------------------------------- usage() { @@ -764,6 +823,7 @@ usage: git trees [args] list [--json] worktrees + branches without one rm [--apply] remove worktree and delete branch clean [--merged|--gone] [--apply] report/remove merged or gone branches + prune [--dry-run] drop metadata for deleted worktree dirs @@ -796,6 +856,7 @@ main() { list|ls) cmd_list "$@" ;; rm) cmd_rm "$@" ;; clean) cmd_clean "$@" ;; + prune) cmd_prune "$@" ;; help|--help|-h) usage; return 0 ;; *) echo "git trees: unknown command '$cmd'" >&2; usage; return 1 ;; esac diff --git a/tests/smoke.sh b/tests/smoke.sh index 63d4f59..90161e4 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 prune" "$out" "prune [--dry-run]" section "outside a repo" @@ -550,6 +551,62 @@ assert_fail "rm with no argument" bash "$T" rm assert_fail "rm with nonexistent target" bash "$T" rm nonexistent +# --- prune ------------------------------------------------------------------- + +# Fixtures here must not mutate the shared $ORIGIN — everything stays inside +# this container, so the section is safe to run before clean. +section "prune" +PR_C=$(new_container prune-c) +cd "$PR_C" || exit 1 + +# A container with every worktree present has nothing to prune. +out=$(bash "$T" prune 2>/dev/null) +assert_eq "prune on a clean container prints nothing to stdout" "$out" "" +assert_ok "prune on a clean container exits 0" bash "$T" prune +out=$(bash "$T" prune 2>&1) +assert_contains "prune reports nothing to prune on stderr" "$out" "nothing to prune" + +# Delete a worktree directory behind git's back, the way a user would. +assert_ok "create worktree to prune" bash "$T" add prune-target --no-push +assert_ok "prune target directory exists" test -d prune-target +rm -rf prune-target +assert_ok "stale entry still registered before prune" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-target" + +out=$(bash "$T" prune --dry-run 2>/dev/null) +assert_eq "dry run names the stale worktree on stdout" "$out" "prune-target" +assert_ok "dry run leaves the metadata intact" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-target" +out=$(bash "$T" prune --dry-run 2>&1) +assert_contains "dry run says it was a dry run" "$out" "dry run" + +out=$(bash "$T" prune 2>/dev/null) +assert_eq "prune names the stale worktree on stdout" "$out" "prune-target" +assert_fail "prune removed the stale metadata" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-target" +# The branch is the whole reason prune is safe without --apply: it survives. +assert_ok "prune left the branch alone" \ + git show-ref --verify --quiet refs/heads/prune-target +assert_not_contains "pruned worktree is gone from git worktree list" \ + "$(git worktree list)" "prune-target" + +# Idempotent: a second run finds nothing and still succeeds. +assert_ok "prune is idempotent" bash "$T" prune +out=$(bash "$T" prune 2>/dev/null) +assert_eq "second prune prints nothing to stdout" "$out" "" + +# A worktree still on disk is never a candidate. +assert_ok "create a live worktree" bash "$T" add prune-live --no-push +assert_ok "prune with a live worktree exits 0" bash "$T" prune +assert_ok "prune left the live worktree directory" test -d prune-live +assert_ok "prune left the live worktree registered" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-live" + +assert_fail "prune unknown option" bash "$T" prune --nope +assert_fail "prune rejects a positional argument" bash "$T" prune extra +assert_fail "prune outside a repo" in_dir "$TMP/plain" bash "$T" prune + + # --- clean ------------------------------------------------------------------- # KEEP THIS SECTION LAST. Its fixtures mutate the shared $ORIGIN — deleting a From 6ed584db2478c63f725df942fe90fb9250b30be5 Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 25 Aug 2026 11:28:00 -0400 Subject: [PATCH 2/7] Add `sync` subcommand for fetching and updating worktrees (#50, #58) --- README.md | 54 ++++++++++++++++++ git-trees | 151 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/smoke.sh | 134 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 339 insertions(+) diff --git a/README.md b/README.md index 89421aa..9a6bb6d 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 e2cd8af..fb87388 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] # git trees prune [--dry-run] # @@ -145,6 +146,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 @@ -671,6 +695,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() { @@ -822,6 +970,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 prune [--dry-run] drop metadata for deleted worktree dirs @@ -855,6 +1005,7 @@ main() { track) cmd_track "$@" ;; list|ls) cmd_list "$@" ;; rm) cmd_rm "$@" ;; + sync) cmd_sync "$@" ;; clean) cmd_clean "$@" ;; prune) cmd_prune "$@" ;; help|--help|-h) usage; return 0 ;; diff --git a/tests/smoke.sh b/tests/smoke.sh index 90161e4..4acbb40 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]" assert_contains "help lists prune" "$out" "prune [--dry-run]" @@ -551,6 +552,139 @@ 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 # --- prune ------------------------------------------------------------------- # Fixtures here must not mutate the shared $ORIGIN — everything stays inside From 5db46a1c5c529b9bb8e2587ced195ad8e0e3d66b Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 25 Aug 2026 14:17:05 -0400 Subject: [PATCH 3/7] Add bash and zsh completions (#51, #60) --- .github/workflows/ci.yml | 4 +- README.md | 46 ++++++++++ completions/_git-trees | 120 ++++++++++++++++++++++++ completions/git-trees.bash | 183 +++++++++++++++++++++++++++++++++++++ install.sh | 23 +++++ tests/smoke.sh | 67 ++++++++++++++ 6 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 completions/_git-trees create mode 100644 completions/git-trees.bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9e390d..392424c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - name: Syntax check - run: bash -n git-trees && bash -n install.sh && bash -n tests/smoke.sh + run: bash -n git-trees && bash -n install.sh && bash -n tests/smoke.sh && bash -n completions/git-trees.bash - name: Install ShellCheck run: | @@ -27,7 +27,7 @@ jobs: fi - name: ShellCheck - run: shellcheck -s bash git-trees install.sh tests/smoke.sh + run: shellcheck -s bash git-trees install.sh tests/smoke.sh completions/git-trees.bash - name: Smoke tests run: tests/smoke.sh ./git-trees diff --git a/README.md b/README.md index 9a6bb6d..641f91f 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,52 @@ case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) `install.sh` warns if it isn't; the curl path cannot. Anything on `PATH` named `git-trees` becomes `git trees`. +### Shell completions + +`install.sh` copies both completion files to `~/.config/git-trees/completions/` +and prints the activation line for each. It never overwrites a copy you have +edited, so a reinstall keeps your changes. + +**bash** — source the file from `~/.bashrc`, after bash-completion itself: + +```bash +source ~/.config/git-trees/completions/git-trees.bash +``` + +**zsh** — source the same bash file from `~/.zshrc` (after oh-my-zsh / +`bashcompinit` if you use them): + +```zsh +source ~/.config/git-trees/completions/git-trees.bash +``` + +Homebrew's `git` completion is a bash wrapper: it dispatches `git trees` to a +function named `_git_trees`, so the bash file is what `git trees ` needs. +Putting only `completions/` on `fpath` wires up the standalone `git-trees` +binary under stock zsh `_git`, but is not enough for Homebrew. + +Completion covers every subcommand and its own flags, and completes branch and +worktree names for `rm` from git itself. Outside a repository it stays silent +rather than erroring. + +**If you installed via the curl path**, `install.sh` never ran, so fetch the +files yourself first: + +```bash +mkdir -p ~/.config/git-trees/completions +for f in git-trees.bash _git-trees; do + curl -fsSL -o ~/.config/git-trees/completions/"$f" \ + https://raw.githubusercontent.com/brightdigit/git-trees/main/completions/"$f" +done +``` + +Then add the `source` line above. + +The filenames are load-bearing. Git's completion dispatches `git trees` to a +function named `_git_trees`, and stock zsh's `_git` also looks for a file named +`_git-trees` on `fpath` for the standalone binary — renaming either one +silently disables completion. + ## Configuration All three variables are optional. Add to `~/.zshrc` (or `~/.bashrc`): diff --git a/completions/_git-trees b/completions/_git-trees new file mode 100644 index 0000000..2f0a521 --- /dev/null +++ b/completions/_git-trees @@ -0,0 +1,120 @@ +#compdef git-trees +# zsh completion for git-trees. +# +# The filename is not arbitrary: zsh's `_git` dispatches `git ` by calling +# a function named `_git-`, so `git trees` requires this file to be named +# `_git-trees` and to sit on `fpath` ahead of `compinit`. The `#compdef +# git-trees` tag additionally wires up the standalone `git-trees` binary. + +# Worktree directory names, which are branch names slugged with `/`->`-` and so +# routinely coincide with branch names — hence the `(u)` dedupe below. The bare +# container root is listed as a worktree by git but is not a removable target. +# Errors are swallowed so completing outside a repository is silent, not noisy. +__git_trees_worktree_names() { + git worktree list --porcelain 2>/dev/null | + awk '/^worktree /{ sub(/^worktree /, ""); n = split($0, p, "/"); if (p[n] !~ /\.git$/) print p[n] }' +} + +__git_trees_targets() { + local -a targets + targets=( + ${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"} + ${(f)"$(__git_trees_worktree_names)"} + ) + _describe -t targets 'branch or worktree' "${(@u)targets}" +} + +__git_trees_worktrees() { + local -a wts + wts=( ${(f)"$(__git_trees_worktree_names)"} ) + _describe -t worktrees 'worktree' wts +} + +_git-trees() { + local curcontext="$curcontext" state line ret=1 + typeset -A opt_args + + local -a commands + commands=( + 'init:create bare repo + worktree layout' + 'root:print project root; link .git if missing' + 'add:create a worktree (sets upstream)' + 'track:ensure branch has an upstream' + 'list:worktrees + branches without one' + 'ls:alias for list' + 'rm:remove worktree and delete branch' + 'clean:report/remove merged or gone branches' + 'sync:update a worktree from its upstream' + 'prune:remove stale worktree administrative files' + 'help:show usage' + ) + + _arguments -C \ + '1: :->command' \ + '*:: :->args' && ret=0 + + case $state in + command) + _describe -t commands 'git trees command' commands && ret=0 + ;; + args) + case $words[1] in + init) + _arguments \ + '--host[host for the clone URL]:host:_hosts' \ + '--dir[directory to create]:directory:_files -/' \ + '1:repository:' && ret=0 + ;; + root) + _arguments \ + '--agents[seed AGENTS.md at the container root]' \ + '1:directory:_files -/' && ret=0 + ;; + add) + _arguments \ + '--print-path[print the worktree path on stdout]' \ + '--no-push[do not create the branch on origin]' \ + '1:branch:__git_trees_targets' \ + '2:base:__git_trees_targets' && ret=0 + ;; + track) + _arguments \ + '--no-push[do not create the branch on origin]' \ + '1:worktree path:_files -/' && ret=0 + ;; + list|ls) + _arguments '--json[emit JSON]' && ret=0 + ;; + rm) + _arguments \ + '--apply[actually remove; without it, report only]' \ + '1:branch or path:__git_trees_targets' && ret=0 + ;; + clean) + # --merged and --gone are mutually exclusive selectors. + _arguments \ + '(--gone)--merged[select branches merged into the default branch]' \ + '(--merged)--gone[select branches whose upstream is gone]' \ + '--apply[actually remove; without it, report only]' && ret=0 + ;; + sync) + # --ff-only and --rebase pick competing merge strategies. + _arguments \ + '--pull[fetch and integrate from the upstream]' \ + '(--rebase)--ff-only[refuse anything but a fast-forward]' \ + '(--ff-only)--rebase[rebase onto the upstream]' \ + '1:worktree:__git_trees_worktrees' && ret=0 + ;; + prune) + _arguments '--dry-run[report without removing]' && ret=0 + ;; + esac + ;; + esac + + return ret +} + +# When zsh's `_git` sources this file it only wants the function defined; when +# compinit autoloads it for the standalone binary the function must also run. +_git-trees "$@" diff --git a/completions/git-trees.bash b/completions/git-trees.bash new file mode 100644 index 0000000..0969b54 --- /dev/null +++ b/completions/git-trees.bash @@ -0,0 +1,183 @@ +# git-trees bash completion +# +# Source from ~/.bashrc (after bash-completion), or from ~/.zshrc when using +# Homebrew's git completion (a bash wrapper). Drop into a bash-completion +# completions directory as `git-trees`. +# +# The function name is not arbitrary: git's completion dispatches `git ` +# to `_git_` with dashes turned into underscores, so `git trees` lands on +# `_git_trees`. That path is shared by bash-completion and by Homebrew's zsh +# `_git` wrapper — both expect this function to speak the git-completion API +# (`$cur` / `$words` / `__gitcomp`), not raw `compgen`/`COMPREPLY`. Using +# `compgen` under the zsh wrapper leaves `_ret=1` and falls through to path +# completion. +# +# The standalone `git-trees` binary is wired up separately at the bottom. + +# Commands and per-subcommand flags live in one place so the two entry points +# (`git trees` and `git-trees`) cannot drift apart. +__git_trees_commands='init root add track list ls rm clean sync prune help' + +__git_trees_flags() { # __git_trees_flags + case "$1" in + init) echo '--host --dir' ;; + root) echo '--agents' ;; + add) echo '--print-path --no-push' ;; + track) echo '--no-push' ;; + list|ls) echo '--json' ;; + rm) echo '--apply' ;; + clean) echo '--merged --gone --apply' ;; + sync) echo '--pull --ff-only --rebase' ;; + prune) echo '--dry-run' ;; + *) echo '' ;; + esac +} + +# Worktree directory names, which are branch names slugged with `/`->`-`, so +# they routinely coincide with branch names — hence the awk dedupe. The bare +# container root is listed as a worktree by git but is not a removable target. +# Every git call is silenced so completing outside a repository is empty, not +# noisy. +__git_trees_worktrees() { + git worktree list --porcelain 2>/dev/null | + awk '/^worktree /{ + sub(/^worktree /, "") + n = split($0, p, "/") + if (p[n] ~ /\.git$/) next + if (!seen[p[n]]++) print p[n] + }' +} + +# Branch names plus worktree directory names — `rm` accepts either. +__git_trees_targets() { + { + git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null + __git_trees_worktrees + } | awk '!seen[$0]++' +} + +# Prefer git-completion's __gitcomp when present (bash, and Homebrew's zsh +# wrapper which redefines it to compadd). Fall back to a COMPREPLY filler so +# tests and a bare `source` without git-completion still work. +__git_trees_comp() { + if declare -F __gitcomp >/dev/null 2>&1; then + __gitcomp "$@" + return + fi + local list="$1" prefix="${2-}" cur_="${3-$cur}" suffix="${4- }" + local c i=0 + local IFS=$' \t\n' + COMPREPLY=() + for c in $list; do + if [ "$c" = "--" ]; then + continue + fi + case "$c" in + "$cur_"*) + case "$c" in + *=|*.) COMPREPLY[i++]="${prefix}$c" ;; + *) COMPREPLY[i++]="${prefix}$c${suffix}" ;; + esac + ;; + esac + done +} + +__git_trees_comp_nl() { + if declare -F __gitcomp_nl >/dev/null 2>&1; then + __gitcomp_nl "$@" + return + fi + local list="$1" prefix="${2-}" cur_="${3-$cur}" suffix="${4- }" + local c i=0 + local IFS=$'\n' + COMPREPLY=() + for c in $list; do + case "$c" in + "$cur_"*) COMPREPLY[i++]="${prefix}$c${suffix}" ;; + esac + done +} + +# Uses git-completion locals: cur, words, cword, prev, __git_cmd_idx. +# __git_cmd_idx is the index of `trees` (or `git-trees` for the standalone). +__git_trees_complete() { + local sub i flags + + sub= + i=$((__git_cmd_idx + 1)) + while [ "$i" -lt "$cword" ]; do + case "${words[i]}" in + -*) ;; + *) sub="${words[i]}"; break ;; + esac + i=$((i + 1)) + done + + if [ -z "$sub" ]; then + __git_trees_comp "$__git_trees_commands" + return + fi + + # --host and --dir take a value; offering flags there would be wrong. + # Returning with no completer lets the shell fall back to default/path + # completion for --dir (and for root/track positionals below). + case "$prev" in + --host) return ;; + --dir) return ;; + esac + + flags=$(__git_trees_flags "$sub") + + case "$cur" in + -*) + __git_trees_comp "$flags" + return + ;; + esac + + # Positional argument. `init` takes an org/repo or URL we cannot enumerate. + case "$sub" in + rm|add) __git_trees_comp_nl "$(__git_trees_targets)" ;; + sync) __git_trees_comp_nl "$(__git_trees_worktrees)" ;; + root|track) return ;; + *) __git_trees_comp "$flags" ;; + esac +} + +# git's completion driver (bash, and Homebrew's zsh wrapper) calls this with +# cur/words/cword/prev/__git_cmd_idx already set. When invoked from tests via +# COMP_WORDS only, bootstrap those locals so the shared body can run. +_git_trees() { + if [ -z "${words+set}" ] && [ -n "${COMP_WORDS+set}" ]; then + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + cur="${COMP_WORDS[COMP_CWORD]}" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD-1]}" + else + prev= + fi + __git_cmd_idx=1 + fi + __git_trees_complete +} + +# Direct invocation as `git-trees` (COMP_WORDS[0]=git-trees). +_git_trees_standalone() { + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + cur="${COMP_WORDS[COMP_CWORD]}" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD-1]}" + else + prev= + fi + __git_cmd_idx=0 + __git_trees_complete +} + +# `complete` is a bash builtin; under zsh it exists only after bashcompinit. +if [ -n "${BASH_VERSION-}" ] || declare -F complete >/dev/null 2>&1; then + complete -F _git_trees_standalone git-trees +fi diff --git a/install.sh b/install.sh index 98363a4..0649a39 100755 --- a/install.sh +++ b/install.sh @@ -24,6 +24,21 @@ if [ -f "$SRC/AGENTS.md.template" ] && [ ! -f "$CFG/AGENTS.md" ]; then echo "installed $CFG/AGENTS.md (template; used by init or root --agents to seed the container root)" fi +# Shell completions — same no-overwrite shape as the template above, so a user +# who edited an installed copy keeps it across reinstalls. +BASHCOMP="$CFG/completions/git-trees.bash" +ZSHCOMP="$CFG/completions/_git-trees" +if [ -f "$SRC/completions/git-trees.bash" ] && [ ! -f "$BASHCOMP" ]; then + mkdir -p "$CFG/completions" + cp "$SRC/completions/git-trees.bash" "$BASHCOMP" + echo "installed $BASHCOMP (completion for bash and for zsh with Homebrew git; source it from your shell rc)" +fi +if [ -f "$SRC/completions/_git-trees" ] && [ ! -f "$ZSHCOMP" ]; then + mkdir -p "$CFG/completions" + cp "$SRC/completions/_git-trees" "$ZSHCOMP" + echo "installed $ZSHCOMP (zsh completion for the standalone git-trees binary under stock zsh _git)" +fi + case ":$PATH:" in *":$DEST:"*) ;; *) echo "warning: $DEST is not on PATH — add it to use \`git trees\`" >&2 ;; @@ -32,6 +47,14 @@ esac echo echo "try: git trees help" +if [ -f "$BASHCOMP" ] || [ -f "$ZSHCOMP" ]; then + echo + echo "to activate completions, add this to your shell rc:" + # Homebrew's zsh git completion is a bash wrapper and needs the bash file; + # the zsh `_git-trees` on fpath only covers the standalone binary under stock zsh. + [ -f "$BASHCOMP" ] && echo " source $BASHCOMP" +fi + if [ -z "${TREES_ORG:-}" ]; then echo echo "optional: set a default org so you can write 'git trees init '" diff --git a/tests/smoke.sh b/tests/smoke.sh index 4acbb40..6b28edb 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -482,12 +482,79 @@ assert_ok "install.sh seeded the agents template" \ assert_eq "install.sh template matches AGENTS.md.template" \ "$(cat "$IHOME/.config/git-trees/AGENTS.md")" \ "$(cat "$REPO/AGENTS.md.template")" +assert_ok "install.sh installed the bash completion" \ + test -f "$IHOME/.config/git-trees/completions/git-trees.bash" +assert_ok "install.sh installed the zsh completion" \ + test -f "$IHOME/.config/git-trees/completions/_git-trees" +assert_eq "installed bash completion matches the source" \ + "$(cat "$IHOME/.config/git-trees/completions/git-trees.bash")" \ + "$(cat "$REPO/completions/git-trees.bash")" +assert_eq "installed zsh completion matches the source" \ + "$(cat "$IHOME/.config/git-trees/completions/_git-trees")" \ + "$(cat "$REPO/completions/_git-trees")" +assert_contains "install.sh reports where the bash completion landed" \ + "$out" ".config/git-trees/completions/git-trees.bash" +assert_contains "install.sh reports where the zsh completion landed" \ + "$out" ".config/git-trees/completions/_git-trees" +assert_contains "install.sh explains how to activate completions" \ + "$out" "to activate completions" + +# The bash completion must define the function bash-completion's git driver +# dispatches to: `git trees` -> `_git_trees` (dashes become underscores). +out=$(bash -c ' + source "$1" || exit 1 + declare -f _git_trees >/dev/null || exit 1 + COMP_WORDS=(git trees ""); COMP_CWORD=2; COMPREPLY=() + _git_trees + echo "${COMPREPLY[*]}" +' _ "$REPO/completions/git-trees.bash" 2>&1) +assert_contains "bash completion defines _git_trees and offers subcommands" "$out" "clean" +assert_contains "bash completion offers the list alias" "$out" "ls" + +out=$(bash -c ' + source "$1" || exit 1 + COMP_WORDS=(git trees clean "--"); COMP_CWORD=3; COMPREPLY=() + _git_trees + echo "${COMPREPLY[*]}" +' _ "$REPO/completions/git-trees.bash" 2>&1) +assert_contains "bash completion offers clean flags" "$out" "--merged" +assert_contains "bash completion offers --apply" "$out" "--apply" + +# Under Homebrew's zsh git wrapper, _git_trees must use __gitcomp (not +# compgen/COMPREPLY). Stub the git-completion API and ensure we call it. +out=$(bash -c ' + source "$1" || exit 1 + __gitcomp() { printf "GITCOMP:%s\n" "$1"; } + words=(git trees ""); cword=2; cur=""; prev=trees; __git_cmd_idx=1 + _git_trees +' _ "$REPO/completions/git-trees.bash" 2>&1) +assert_contains "bash completion uses __gitcomp when available" "$out" "GITCOMP:" +assert_contains "bash completion __gitcomp receives subcommands" "$out" "clean" + +# Completing outside a repository must be silent and empty, never an error. +# The single quotes are deliberate: these expansions belong to the inner bash. +# shellcheck disable=SC2016 +COMP_PROBE_OUTSIDE=' + source "$1" || exit 1 + COMP_WORDS=(git trees rm ""); COMP_CWORD=3; COMPREPLY=() + _git_trees + echo "rc=$? n=${#COMPREPLY[@]}" +' +out=$(in_dir "$TMP" bash -c "$COMP_PROBE_OUTSIDE" _ "$REPO/completions/git-trees.bash" 2>&1) +assert_eq "bash completion is empty and quiet outside a repo" "$out" "rc=0 n=0" + echo CUSTOM > "$IHOME/.config/git-trees/AGENTS.md" +echo CUSTOMBASH > "$IHOME/.config/git-trees/completions/git-trees.bash" +echo CUSTOMZSH > "$IHOME/.config/git-trees/completions/_git-trees" HOME="$IHOME" bash "$REPO/install.sh" "$IDEST" >/dev/null 2>&1 rc=$? assert_eq "install.sh rerun exits 0" "$rc" "0" assert_eq "install.sh does not overwrite an existing template" \ "$(cat "$IHOME/.config/git-trees/AGENTS.md")" "CUSTOM" +assert_eq "install.sh does not overwrite an existing bash completion" \ + "$(cat "$IHOME/.config/git-trees/completions/git-trees.bash")" "CUSTOMBASH" +assert_eq "install.sh does not overwrite an existing zsh completion" \ + "$(cat "$IHOME/.config/git-trees/completions/_git-trees")" "CUSTOMZSH" # --- rm ---------------------------------------------------------------------- From 11a5361fd3424f9090ab4d3fcc65fd8bc8ff3b39 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 21 Aug 2026 12:04:57 -0400 Subject: [PATCH 4/7] Add a one-line curl install (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README's curl path was a 12-line copy-paste blob that mktemp'd, curled, moved, chmod'd, then repeated the whole dance for the agents template. Replace it with the conventional one-liner: curl -fsSL https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bash That requires install.sh to work with no repo around it. It now detects the case where the expected files are not next to the script, downloads git-trees and AGENTS.md.template into a mktemp -d, and proceeds exactly as before, with a trap cleaning up the temp directory on exit. Downloads go through curl or wget, whichever is present, and each is verified non-empty before anything is installed — curl -fsSL fails on HTTP errors but still leaves a zero-byte file behind, and an empty or truncated git-trees installed onto PATH is the worst outcome here. A piped script receives no positional arguments, so TREES_DEST is the only way to choose a destination on that path. The positional argument still wins for the clone path, which is otherwise unchanged: same ~/.local/bin default, same install -m 0755, same template no-overwrite guard, same PATH warning and hints. Piped bash has neither BASH_SOURCE nor $1, and set -u makes a bare reference to either fatal, so the source-directory probe defaults them. The template guard also moves from -f to -e/-L, matching the no-clobber behavior the README already documented for a broken symlink. Also fixes the README's "All three variables are optional" against an Environment table that listed five, and adds TREES_DEST to that table. Closes #54 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 12 +++- README.md | 35 +++++----- install.sh | 50 ++++++++++++-- tests/smoke.sh | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d518de..92635a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,7 +139,17 @@ What the suite covers: through `json.load` - **install.sh** — places the binary; seeds `~/.config/git-trees/AGENTS.md` from the template under a redirected `HOME`; does not overwrite an existing config - file + file; honours `TREES_DEST`, with a positional argument still winning over it +- **install.sh — no-repo bootstrap** — the `curl | bash` path, with + `TREES_BASE_URL` pointed at a `file://` fixture so the real download branch + runs without touching the network: piped on stdin from a directory with no + `git-trees` in it (piped bash has neither `BASH_SOURCE` nor `$1`, and `set -u` + makes a bare reference to either fatal), the `wget` fallback on a `PATH` built + without `curl`, a clear error when neither downloader exists, a **zero-byte + body** rejected (the transfer succeeds, so only the non-empty check catches + it), a missing script failing loudly and installing nothing, a missing + template warning while the binary still installs, no-clobber on rerun, and the + temp download directory cleaned up by its trap - **rm** — dry run vs `--apply`, worktree removal by branch and by path (a slugged directory whose name is not a branch name, so the path arm is the one that runs), `-d` escalating to `-D` so an unmerged branch is still deleted diff --git a/README.md b/README.md index 641f91f..ea88663 100644 --- a/README.md +++ b/README.md @@ -119,22 +119,25 @@ cd git-trees && ./install.sh # → ~/.local/bin ./install.sh /usr/local/bin # or anywhere else ``` -**Convenience — curl.** Fetches the script and the agents template (skips the -template if that path is already occupied, including a broken symlink): +**Convenience — one-line curl.** Same installer, downloaded and run in place. +It fetches the script *and* the agents template (skipping the template if that +path is already occupied, including a broken symlink): ```bash -mkdir -p ~/.local/bin ~/.config/git-trees -tmp=$(mktemp) && curl -fsSL -o "$tmp" \ - https://raw.githubusercontent.com/brightdigit/git-trees/main/git-trees \ - && mv "$tmp" ~/.local/bin/git-trees -chmod +x ~/.local/bin/git-trees -if [ ! -e ~/.config/git-trees/AGENTS.md ] && [ ! -L ~/.config/git-trees/AGENTS.md ]; then - tmp=$(mktemp) && curl -fsSL -o "$tmp" \ - https://raw.githubusercontent.com/brightdigit/git-trees/main/AGENTS.md.template \ - && mv "$tmp" ~/.config/git-trees/AGENTS.md -fi +curl -fsSL https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bash ``` +A piped script receives no positional arguments, so set `TREES_DEST` to install +somewhere other than `~/.local/bin`: + +```bash +TREES_DEST=/usr/local/bin curl -fsSL \ + https://raw.githubusercontent.com/brightdigit/git-trees/main/install.sh | bash +``` + +The installer uses `curl` or `wget`, whichever it finds, and verifies each +download is complete and non-empty before installing anything. + `main` is the stable release. A re-install from these URLs picks up the current stable script and template. @@ -145,8 +148,8 @@ case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc ;; esac ``` -`install.sh` warns if it isn't; the curl path cannot. Anything on `PATH` named -`git-trees` becomes `git trees`. +`install.sh` warns if it isn't — either way you run it. Anything on `PATH` +named `git-trees` becomes `git trees`. ### Shell completions @@ -196,7 +199,8 @@ silently disables completion. ## Configuration -All three variables are optional. Add to `~/.zshrc` (or `~/.bashrc`): +Every variable in [**Environment**](#environment) is optional. Add to +`~/.zshrc` (or `~/.bashrc`): ```zsh export TREES_ORG=your-org @@ -440,6 +444,7 @@ git worktree prune | `TREES_AGENTS_TEMPLATE` | `~/.config/git-trees/AGENTS.md` | Seeded at the container root by `init` (and `root --agents`) | | `TREES_NO_PUSH` | *(unset)* | Any non-empty value: `add`/`track` never create a branch on `origin` | | `TREES_RM_CMD` | *(unset)* | Custom command for worktree directory removal (defaults to `git worktree remove`). Bypasses git's uncommitted-work check — see [`git trees rm`](#git-trees-rm-branchpath---apply) | +| `TREES_DEST` | `~/.local/bin` | Install destination for `install.sh`; the only way to choose one when piping the installer | ## Shell wrapper (optional) diff --git a/install.sh b/install.sh index 0649a39..311e3cf 100755 --- a/install.sh +++ b/install.sh @@ -3,22 +3,62 @@ # # ./install.sh install to ~/.local/bin # ./install.sh /usr/local/bin install elsewhere +# +# Also works with no repo around it, piped straight from the raw URL: +# +# curl -fsSL .../install.sh | bash +# TREES_DEST=/usr/local/bin curl -fsSL .../install.sh | bash +# +# A piped script gets no positional arguments, so TREES_DEST is the only way to +# choose a destination on that path. set -uo pipefail -SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Pinned to main: main is the stable release for this project. +BASE_URL="${TREES_BASE_URL:-https://raw.githubusercontent.com/brightdigit/git-trees/main}" + +SRC="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd)" || SRC="" CFG="$HOME/.config/git-trees" -DEST="${1:-$HOME/.local/bin}" +# Positional wins for the clone path; TREES_DEST is the piped path's only lever. +DEST="${1:-${TREES_DEST:-$HOME/.local/bin}}" -[ -f "$SRC/git-trees" ] || { echo "install.sh: git-trees not found in $SRC" >&2; exit 1; } +# Fetch one file to a path. Verifies the transfer rather than trusting that a +# file appeared: a truncated or 404 body installed onto PATH is the worst +# outcome here, and `curl -o` leaves an empty file behind on failure. +fetch() { # fetch + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "$2" "$1" || return 1 + elif command -v wget >/dev/null 2>&1; then + wget -qO "$2" "$1" || return 1 + else + echo "install.sh: need curl or wget to download $1" >&2 + return 1 + fi + [ -s "$2" ] +} + +# No repo around the script — piped into bash, or copied off somewhere alone. +if [ -z "$SRC" ] || [ ! -f "$SRC/git-trees" ]; then + SRC=$(mktemp -d "${TMPDIR:-/tmp}/git-trees-install.XXXXXX") || exit 1 + trap 'rm -rf "$SRC"' EXIT + + echo "downloading git-trees from $BASE_URL" >&2 + fetch "$BASE_URL/git-trees" "$SRC/git-trees" || { + echo "install.sh: failed to download git-trees" >&2; exit 1; } + # The template is optional at install time; git-trees warns without it. + fetch "$BASE_URL/AGENTS.md.template" "$SRC/AGENTS.md.template" || { + echo "warning: failed to download AGENTS.md.template — skipping" >&2 + rm -f "$SRC/AGENTS.md.template" + } +fi mkdir -p "$DEST" || exit 1 install -m 0755 "$SRC/git-trees" "$DEST/git-trees" || exit 1 echo "installed $DEST/git-trees" -# AGENTS.md template — README's curl path installs this too. -if [ -f "$SRC/AGENTS.md.template" ] && [ ! -f "$CFG/AGENTS.md" ]; then +# AGENTS.md template — the curl path installs this too. +if [ -f "$SRC/AGENTS.md.template" ] && [ ! -e "$CFG/AGENTS.md" ] && [ ! -L "$CFG/AGENTS.md" ]; then mkdir -p "$CFG" cp "$SRC/AGENTS.md.template" "$CFG/AGENTS.md" echo "installed $CFG/AGENTS.md (template; used by init or root --agents to seed the container root)" diff --git a/tests/smoke.sh b/tests/smoke.sh index 6b28edb..9d4cb35 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -556,6 +556,180 @@ assert_eq "install.sh does not overwrite an existing bash completion" \ assert_eq "install.sh does not overwrite an existing zsh completion" \ "$(cat "$IHOME/.config/git-trees/completions/_git-trees")" "CUSTOMZSH" +# TREES_DEST is the piped path's only way to choose a destination, but it must +# work from a clone too, and the positional must still win over it. +EDEST="$TMP/install-envdest" +out=$(HOME="$IHOME" TREES_DEST="$EDEST" bash "$REPO/install.sh" 2>&1) +rc=$? +assert_eq "install.sh TREES_DEST exits 0" "$rc" "0" +assert_ok "install.sh honours TREES_DEST" test -x "$EDEST/git-trees" +PDEST="$TMP/install-posdest" +HOME="$IHOME" TREES_DEST="$EDEST" bash "$REPO/install.sh" "$PDEST" >/dev/null 2>&1 +assert_ok "install.sh positional beats TREES_DEST" test -x "$PDEST/git-trees" + +# --- install.sh — no-repo bootstrap (the `curl | bash` path) ----------------- +# +# The script is copied somewhere with no git-trees beside it, so it takes the +# download branch. TREES_BASE_URL points at a file:// fixture: the real fetch +# code runs, no network is touched, and the test cannot silently no-op offline. + +section "install.sh — no-repo bootstrap" +SERVE="$TMP/serve" +mkdir -p "$SERVE" +cp "$REPO/git-trees" "$SERVE/git-trees" +cp "$REPO/AGENTS.md.template" "$SERVE/AGENTS.md.template" +BOOT="$TMP/boot" +mkdir -p "$BOOT" +cp "$REPO/install.sh" "$BOOT/install.sh" + +BHOME="$TMP/boot-home" +BDEST="$TMP/boot-bin" +mkdir -p "$BHOME" +out=$(HOME="$BHOME" TREES_DEST="$BDEST" TREES_BASE_URL="file://$SERVE" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap exits 0" "$rc" "0" +assert_contains "bootstrap announces the download" "$out" "downloading git-trees" +assert_ok "bootstrap placed the binary" test -x "$BDEST/git-trees" +assert_eq "bootstrap binary matches the source" \ + "$(cat "$BDEST/git-trees")" "$(cat "$REPO/git-trees")" +assert_ok "bootstrap binary runs" bash "$BDEST/git-trees" help +assert_ok "bootstrap seeded the agents template" \ + test -f "$BHOME/.config/git-trees/AGENTS.md" +assert_eq "bootstrap template matches AGENTS.md.template" \ + "$(cat "$BHOME/.config/git-trees/AGENTS.md")" "$(cat "$REPO/AGENTS.md.template")" + +# A rerun must not clobber a template the user has edited. +echo BOOTCUSTOM > "$BHOME/.config/git-trees/AGENTS.md" +HOME="$BHOME" TREES_DEST="$BDEST" TREES_BASE_URL="file://$SERVE" \ + bash "$BOOT/install.sh" >/dev/null 2>&1 +rc=$? +assert_eq "bootstrap rerun exits 0" "$rc" "0" +assert_eq "bootstrap rerun does not overwrite the template" \ + "$(cat "$BHOME/.config/git-trees/AGENTS.md")" "BOOTCUSTOM" + +# The temp download directory is trapped away on exit. A private TMPDIR makes +# that observable: mktemp -d lands inside it, so anything left is a leak. +SCRATCH="$TMP/boot-tmpdir" +mkdir -p "$SCRATCH" +HOME="$BHOME" TMPDIR="$SCRATCH" TREES_DEST="$BDEST" TREES_BASE_URL="file://$SERVE" \ + bash "$BOOT/install.sh" >/dev/null 2>&1 +assert_eq "bootstrap cleans up its temp dir" \ + "$(find "$SCRATCH" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" "0" + +# A missing git-trees at the base URL must fail loudly, not install nothing +# quietly. `curl -fsSL` fails on HTTP errors; file:// fails on a missing path. +EMPTY="$TMP/serve-empty" +mkdir -p "$EMPTY" +FHOME="$TMP/boot-fail-home" +FDEST="$TMP/boot-fail-bin" +mkdir -p "$FHOME" +out=$(HOME="$FHOME" TREES_DEST="$FDEST" TREES_BASE_URL="file://$EMPTY" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap fails when git-trees is missing" "$rc" "1" +assert_contains "bootstrap says why it failed" "$out" "failed to download git-trees" +assert_fail "bootstrap installed nothing on failure" test -e "$FDEST/git-trees" + +# The actual `curl ... | bash` shape: piped on stdin, from a directory with no +# git-trees in it. Piped bash has no BASH_SOURCE and no $1, and `set -u` makes a +# bare reference to either fatal — a copied-file test cannot catch that. +PHOME="$TMP/boot-piped-home" +PPDEST="$TMP/boot-piped-bin" +mkdir -p "$PHOME" "$TMP/boot-piped-cwd" +out=$(in_dir "$TMP/boot-piped-cwd" env HOME="$PHOME" TREES_DEST="$PPDEST" \ + TREES_BASE_URL="file://$SERVE" bash < "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "piped bootstrap exits 0" "$rc" "0" +assert_not_contains "piped bootstrap has no unbound-variable error" \ + "$out" "unbound variable" +assert_ok "piped bootstrap placed the binary" test -x "$PPDEST/git-trees" +assert_ok "piped bootstrap seeded the template" \ + test -f "$PHOME/.config/git-trees/AGENTS.md" + +# The wget fallback and the neither-downloader error, on a PATH built to contain +# exactly what each case needs. macOS ships /usr/bin/curl, so proving the +# fallback runs at all means excluding the real curl from PATH. +STUBBIN="$TMP/stub-bin" +mkdir -p "$STUBBIN" +for c in bash mkdir install cp mktemp rm cat dirname pwd sed find chmod wc tr; do + cbin=$(command -v "$c") && ln -sf "$cbin" "$STUBBIN/$c" +done + +WHOME="$TMP/boot-wget-home" +WDEST="$TMP/boot-wget-bin" +mkdir -p "$WHOME" +out=$(env -i HOME="$WHOME" PATH="$STUBBIN" TREES_DEST="$WDEST" \ + TREES_BASE_URL="file://$SERVE" "$STUBBIN/bash" "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap fails with neither curl nor wget" "$rc" "1" +assert_contains "bootstrap names the missing tools" "$out" "need curl or wget" +assert_fail "bootstrap installed nothing without a downloader" \ + test -e "$WDEST/git-trees" + +# Minimal wget standing in for the real thing: only the -qO form install.sh +# uses, over file://. Exits nonzero on a missing source, as wget does. +cat > "$STUBBIN/wget" <<'WGET_STUB' +#!/usr/bin/env bash +out=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -qO) out="$2"; shift 2 ;; + -q) shift ;; + *) url="$1"; shift ;; + esac +done +src="${url#file://}" +[ -f "$src" ] || exit 8 +cat "$src" > "$out" +WGET_STUB +chmod +x "$STUBBIN/wget" + +out=$(env -i HOME="$WHOME" PATH="$STUBBIN" TREES_DEST="$WDEST" \ + TREES_BASE_URL="file://$SERVE" "$STUBBIN/bash" "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap via wget exits 0" "$rc" "0" +assert_ok "bootstrap via wget placed the binary" test -x "$WDEST/git-trees" +assert_eq "bootstrap via wget binary matches the source" \ + "$(cat "$WDEST/git-trees")" "$(cat "$REPO/git-trees")" +assert_ok "bootstrap via wget seeded the template" \ + test -f "$WHOME/.config/git-trees/AGENTS.md" + +# A zero-byte body is the truncated-download case: curl succeeds (the transfer +# completed), so only the non-empty check catches it. Installing an empty +# git-trees onto PATH is the worst outcome here, hence its own fixture. +TRUNC="$TMP/serve-truncated" +mkdir -p "$TRUNC" +: > "$TRUNC/git-trees" +cp "$REPO/AGENTS.md.template" "$TRUNC/AGENTS.md.template" +THOME="$TMP/boot-trunc-home" +TDEST="$TMP/boot-trunc-bin" +mkdir -p "$THOME" +out=$(HOME="$THOME" TREES_DEST="$TDEST" TREES_BASE_URL="file://$TRUNC" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap rejects a zero-byte download" "$rc" "1" +assert_contains "bootstrap says why the empty download failed" \ + "$out" "failed to download git-trees" +assert_fail "bootstrap installed nothing from a zero-byte download" \ + test -e "$TDEST/git-trees" + +# A present script with a missing template warns and still installs the binary. +ONLY="$TMP/serve-binary-only" +mkdir -p "$ONLY" +cp "$REPO/git-trees" "$ONLY/git-trees" +NHOME="$TMP/boot-notmpl-home" +NDEST="$TMP/boot-notmpl-bin" +mkdir -p "$NHOME" +out=$(HOME="$NHOME" TREES_DEST="$NDEST" TREES_BASE_URL="file://$ONLY" \ + bash "$BOOT/install.sh" 2>&1) +rc=$? +assert_eq "bootstrap without a template exits 0" "$rc" "0" +assert_ok "bootstrap without a template still installs" test -x "$NDEST/git-trees" +assert_contains "bootstrap warns about the missing template" "$out" "AGENTS.md.template" +assert_fail "bootstrap wrote no config template" \ + test -e "$NHOME/.config/git-trees/AGENTS.md" + # --- rm ---------------------------------------------------------------------- section "rm" From f749837a644a680b2193924576b1eb74c3d677fd Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 25 Aug 2026 14:30:10 -0400 Subject: [PATCH 5/7] Fix git worktree add DWIM when base exists only on remote (#62) --- AGENTS.md | 19 +++++++++++++++++++ README.md | 5 +++++ git-trees | 43 +++++++++++++++++++++++++++++++++++++++++-- tests/smoke.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92635a8..b5705aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,21 @@ created from `origin/main` silently gets `origin/main` as its upstream and will push there. The new-branch path must pass `--no-track`, then let `cmd_track` set the correct upstream. Live in `cmd_add`; any change there needs a fresh test. +## Git pitfall: a start-point can override `-b` + +`git worktree add --no-track -b ` does **not** guarantee a +worktree on ``. When `` is a bare name matching a branch that exists +only on the remote, git's DWIM reads it as "create a local branch tracking +`origin/`" and overrides `-b ` entirely: the worktree comes up on +``, `` is never created, a stray local `` ref is left to go +stale, and the exit status is 0. `--no-track` does not help — it governs the +upstream, not the branch name. + +`cmd_add` resolves the base through `_base_sha` first (local commit-ish, else +`origin/`) and passes the sha, which leaves nothing for the DWIM to latch +onto, and then asserts the new worktree's `HEAD` really is `
`. Keep both: +the resolution is the fix, the assertion is what makes a future regression loud. + ## Git pitfall: worktree paths are physical `git worktree list` reports the *physical* path. Resolve any user-supplied @@ -129,6 +144,10 @@ What the suite covers: exactly `origin/feature-x` for an existing remote branch and exactly `origin/brandnew` for a new one; directory collision; `--print-path` emitting only a path; argument errors; nonzero exit when `track`/push fails +- **add with a remote-only base** — the worktree lands on the requested branch + (not the base), starts at `origin/`, leaves no stray local ref, and + tracks its own remote; an explicit `origin/` behaves identically; an + unresolvable base fails and creates no worktree - **add with a slash in the branch** — the directory is slugged (`feature/x` → `feature-x/`, `deep/new/branch` → `deep-new-branch/`) while the ref keeps its slash and tracks `origin/feature/x`; a second branch slugging to a taken diff --git a/README.md b/README.md index ea88663..cd198f0 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,11 @@ Creates a worktree, handling three cases: | Branch exists on `origin` | Fetch, create with `--track` | | Branch is new | Create from `base` (default `origin/`) with `--no-track` | +`base` is resolved to a commit before the worktree is created. A bare name that +exists only on `origin` resolves to `origin/`, so `add newwork v1.2.0` +starts the branch where you meant and leaves no local `v1.2.0` behind; a base +that resolves to nothing is an error rather than a worktree on something else. + > **`add` writes to the remote by default.** When pushing is enabled, upstream is > set afterward via `track`. If the branch does not exist on `origin`, that runs > `git push -u origin HEAD` — **which creates the branch on the remote.** This diff --git a/git-trees b/git-trees index fb87388..ffcb019 100755 --- a/git-trees +++ b/git-trees @@ -141,6 +141,26 @@ _default_branch() { echo main } +# _base_sha -> commit sha of , or nonzero +# +# `git worktree add -b ` DWIMs a base that names a branch +# existing only on the remote: it creates a local branch named tracking +# it and overrides the explicit `-b ` entirely, so the worktree lands on +# the wrong branch, is never created, and the command exits 0. `--no-track` +# does not prevent it — that flag governs the upstream, not the branch name. +# Resolving the base to a sha first leaves nothing for the DWIM to latch onto, +# and creates no stray local ref that would later go stale as origin advances. +# +# A bare remote-only name is not resolvable as a commit-ish, so it is retried +# against `origin/` — the branch the user meant. +_base_sha() { + local sha + sha=$(git rev-parse --verify --quiet "$1^{commit}") && { printf '%s\n' "$sha"; return 0; } + sha=$(git rev-parse --verify --quiet "refs/remotes/origin/$1^{commit}") \ + && { printf '%s\n' "$sha"; return 0; } + return 1 +} + _ref_info() { # _ref_info -> upstream|track|date git for-each-ref --format='%(upstream:short)|%(upstream:track)|%(committerdate:short)' \ "refs/heads/$1" @@ -402,7 +422,7 @@ cmd_root() { # --- add --------------------------------------------------------------------- cmd_add() { - local br="" base="" print_path=0 no_push=0 root path dir owner + local br="" base="" print_path=0 no_push=0 root path dir owner base_sha created while [ $# -gt 0 ]; do case "$1" in @@ -474,9 +494,28 @@ cmd_add() { git worktree add --track -b "$br" "$dir" "origin/$br" >&2 || return 1 else : "${base:=origin/$(_default_branch)}" + # A sha, never the base name: see _base_sha for the wrong-branch DWIM this + # avoids. Resolving here also turns a bogus base into a clear error instead + # of a worktree on something the user did not ask for. + base_sha=$(_base_sha "$base") || { + echo "git trees add: base '$base' is not a valid commit" >&2 + echo "git trees add: run \`git trees sync\` to fetch the latest remote branches" >&2 + return 1 + } # --no-track: a new branch must not inherit the base ref's upstream, # or it would silently push to the base branch. - git worktree add --no-track -b "$br" "$dir" "$base" >&2 || return 1 + git worktree add --no-track -b "$br" "$dir" "$base_sha" >&2 || return 1 + fi + + # Belt and braces against a future DWIM: `add` must never leave a worktree + # checked out on a branch other than the one asked for, silently or otherwise. + # Asked of the worktree itself, not `git worktree list`: that reports physical + # paths, and $dir is built from _root, so a symlinked container root would make + # the lookup miss and this check cry wolf. + created=$(git -C "$dir" symbolic-ref --quiet --short HEAD 2>/dev/null) + if [ "$created" != "$br" ]; then + echo "git trees add: worktree at $dir is on '${created:-detached HEAD}', not '$br'" >&2 + return 1 fi if [ "$no_push" -eq 1 ]; then diff --git a/tests/smoke.sh b/tests/smoke.sh index 9d4cb35..8393cad 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -363,6 +363,56 @@ assert_not_contains "list does not show it as having no worktree" \ cd "$C" || exit 1 +# --- add with a remote-only base --------------------------------------------- + +# A base naming a branch that exists only on the remote used to be handed to +# `git worktree add -b ` verbatim, where git's DWIM turned it +# into "create a local branch tracking origin/" and overrode `-b ` +# entirely: the worktree came up on , was never created, a stray +# local ref was left behind to go stale, and the command exited 0. +section "add — remote-only base" +RB=$(new_container remote-base-c) + +# A branch on origin that the container has never had locally: created after the +# clone, so only the remote-tracking ref exists. Its own commit, so "based on it" +# is distinguishable from "based on main". +RB_SHA=$(git -C "$ORIGIN" commit-tree "$(git -C "$ORIGIN" rev-parse 'main^{tree}')" \ + -p main -m relbase) +git -C "$ORIGIN" branch relbase "$RB_SHA" >/dev/null 2>&1 +cd "$RB" || exit 1 +git fetch -q origin +assert_fail "the base branch is remote-only" \ + git show-ref --verify --quiet refs/heads/relbase + +assert_ok "add on a remote-only base" bash "$T" add newfromremote relbase +assert_eq "the worktree is on the requested branch, not the base" \ + "$(git -C newfromremote symbolic-ref --short HEAD 2>/dev/null)" "newfromremote" +assert_eq "the new branch starts at origin/" \ + "$(git -C newfromremote rev-parse HEAD 2>/dev/null)" "$RB_SHA" +assert_fail "no stray local branch named after the base" \ + git show-ref --verify --quiet refs/heads/relbase +assert_eq "the new branch tracks its own remote, not the base" \ + "$(git -C newfromremote rev-parse --abbrev-ref '@{upstream}' 2>/dev/null)" \ + "origin/newfromremote" + +# The same base spelled out explicitly must behave identically. +assert_ok "add on an explicit origin/" bash "$T" add explicitbase origin/relbase +assert_eq "the explicit form checks out the requested branch" \ + "$(git -C explicitbase symbolic-ref --short HEAD 2>/dev/null)" "explicitbase" +assert_eq "the explicit form starts at that commit" \ + "$(git -C explicitbase rev-parse HEAD 2>/dev/null)" "$RB_SHA" + +# A base that resolves to nothing is an error, not a worktree on something else. +out=$(bash "$T" add frombogus no/such/base 2>&1) +assert_fail "add on a nonexistent base fails" bash "$T" add frombogus no/such/base +assert_contains "the error names the bad base" "$out" "no/such/base" +assert_contains "the error suggests sync to fetch remotes" "$out" "git trees sync" +assert_fail "no worktree was left behind for a bad base" test -e "$RB/frombogus" + +git -C "$ORIGIN" branch -D relbase >/dev/null 2>&1 + +cd "$C" || exit 1 + # A failed upstream setup must fail `add` (the worktree may still exist). BROKE=$(new_container add-nopush-remote) cd "$BROKE" || exit 1 From ca39009103c0711cb34256090f15bb283cb2dd42 Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 25 Aug 2026 15:55:36 -0400 Subject: [PATCH 6/7] Add Homebrew formula and release checklist (#49, #57) --- .github/workflows/homebrew-tap.yml | 107 +++++++++++++++++++++++++++++ README.md | 16 +++++ docs/RELEASING.md | 105 ++++++++++++++++++++++++++++ homebrew-tap/.gitrepo | 12 ++++ homebrew-tap/Formula/git-trees.rb | 36 ++++++++++ homebrew-tap/README.md | 14 ++++ 6 files changed, 290 insertions(+) create mode 100644 .github/workflows/homebrew-tap.yml create mode 100644 docs/RELEASING.md create mode 100644 homebrew-tap/.gitrepo create mode 100644 homebrew-tap/Formula/git-trees.rb create mode 100644 homebrew-tap/README.md diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 0000000..5b9d21b --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,107 @@ +name: Homebrew tap + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to publish (e.g. v1.0.3) + required: true + type: string + # Temporary: exercise the tap push from this PR branch (workflow_dispatch + # only works once the workflow exists on the default branch). + push: + branches: [49-homebrew] + +permissions: + contents: write + +jobs: + update: + runs-on: ubuntu-latest + steps: + - name: Check tap token + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + if [ -z "$HOMEBREW_TAP_TOKEN" ]; then + echo "HOMEBREW_TAP_TOKEN secret is not set" >&2 + echo "Create a PAT (contents:write on brightdigit/homebrew-tap and brightdigit/git-trees)" >&2 + echo "and add it as repository secret HOMEBREW_TAP_TOKEN." >&2 + exit 1 + fi + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Install git-subrepo + run: | + git clone --depth 1 https://github.com/ingydotnet/git-subrepo.git /tmp/git-subrepo + echo "/tmp/git-subrepo/lib" >> "$GITHUB_PATH" + + - name: Resolve tag + id: meta + run: | + case "${{ github.event_name }}" in + workflow_dispatch) tag='${{ inputs.tag }}' ;; + release) tag='${{ github.event.release.tag_name }}' ;; + push) tag=$(git tag -l 'v*' --sort=-v:refname | head -n1) ;; + *) + echo "Unsupported event: ${{ github.event_name }}" >&2 + exit 1 + ;; + esac + if [ -z "$tag" ]; then + echo "No tag to publish" >&2 + exit 1 + fi + case "$tag" in + v*) ;; + *) + echo "Expected a v-prefixed tag, got: $tag" >&2 + exit 1 + ;; + esac + url="https://github.com/${{ github.repository }}/archive/refs/tags/${tag}.tar.gz" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "url=$url" >> "$GITHUB_OUTPUT" + + - name: Bump formula url and sha256 + run: | + tag='${{ steps.meta.outputs.tag }}' + url='${{ steps.meta.outputs.url }}' + formula=homebrew-tap/Formula/git-trees.rb + + # curl -f fails on a missing tag instead of hashing a 404 body. + sha=$(curl -fsSL "$url" | sha256sum | awk '{ print $1 }') + echo "sha256=$sha" + + # Pass values via ENV so / in the URL cannot break s/// delimiters. + URL="$url" perl -i -pe 's/^(\s*url\s+)"[^"]*"/$1"$ENV{URL}"/' "$formula" + SHA="$sha" perl -i -pe 's/^(\s*sha256\s+)"[^"]*"/$1"$ENV{SHA}"/' "$formula" + + grep -F "url \"$url\"" "$formula" + grep -F "sha256 \"$sha\"" "$formula" + ruby -c "$formula" + + - name: Commit formula bump + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add homebrew-tap/Formula/git-trees.rb + if git diff --staged --quiet; then + echo "Formula already at ${{ steps.meta.outputs.tag }}; nothing to commit" + else + git commit -m "homebrew: git-trees ${{ steps.meta.outputs.tag }}" + git push + fi + + - name: Push subrepo to brightdigit/homebrew-tap + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + run: | + git config --global url."https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/".insteadOf "https://github.com/" + git subrepo push homebrew-tap diff --git a/README.md b/README.md index cd198f0..2220456 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,22 @@ download is complete and non-empty before installing anything. `main` is the stable release. A re-install from these URLs picks up the current stable script and template. +**Homebrew.** Not yet published — the formula lives at +[`homebrew-tap/Formula/git-trees.rb`](homebrew-tap/Formula/git-trees.rb) (a +[git subrepo](https://github.com/ingydotnet/git-subrepo) of +[`brightdigit/homebrew-tap`](https://github.com/brightdigit/homebrew-tap)) and +takes effect once it is pushed to the tap (see +[`docs/RELEASING.md`](docs/RELEASING.md)). After that: + +```bash +brew tap brightdigit/tap +brew install git-trees +``` + +Homebrew cannot write to your home directory, so this path installs the script +but not the agents template. `brew install` prints the one command that puts the +bundled template at `~/.config/git-trees/AGENTS.md`. + Either way, make sure the destination is on your `PATH`: ```bash diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..78d41e0 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,105 @@ +# Releasing + +There is no release automation for cutting tags — those are still done by hand. +Once a GitHub release is published, `.github/workflows/homebrew-tap.yml` bumps +the formula and pushes it to the tap via the +[`homebrew-tap/`](../homebrew-tap) [git subrepo](https://github.com/ingydotnet/git-subrepo). + +`homebrew-tap/Formula/git-trees.rb` in this repo is the **source of truth** for +the formula. Homebrew installs it from +[`brightdigit/homebrew-tap`](https://github.com/brightdigit/homebrew-tap), which +is what `brew tap brightdigit/tap` clones. + +## One-time setup + +### 1. Embed the tap as a subrepo + +From a commit that already contains `homebrew-tap/Formula/git-trees.rb`: + +```bash +git subrepo init homebrew-tap \ + -r https://github.com/brightdigit/homebrew-tap.git \ + -b main +git subrepo push homebrew-tap +``` + +`init` records `homebrew-tap/.gitrepo`; `push` populates the (possibly empty) +tap remote. Requires push access to `brightdigit/homebrew-tap`. + +### 2. Repository secret + +Add a `HOMEBREW_TAP_TOKEN` secret on `brightdigit/git-trees`: a classic PAT or +fine-grained token with `contents: write` on both `brightdigit/git-trees` and +`brightdigit/homebrew-tap`. The workflow uses it to commit the formula bump here +and to `git subrepo push` the tap. + +## Cut a release + +Update `CHANGELOG.md` with the new version's `## What's Changed` section, then: + +```bash +git tag -a v1.0.3 -m "v1.0.3" +git push origin v1.0.3 +``` + +Create the GitHub release for that tag (or publish from the tag). The +`Homebrew tap` workflow then: + +1. Downloads `https://github.com/brightdigit/git-trees/archive/refs/tags/v1.0.3.tar.gz` +2. Rewrites `url` / `sha256` in `homebrew-tap/Formula/git-trees.rb` +3. Commits and pushes that bump to this repo +4. Runs `git subrepo push homebrew-tap` + +To re-run for an existing tag: **Actions → Homebrew tap → Run workflow** and +pass the tag (e.g. `v1.0.3`). + +## Manual fallback + +If the workflow cannot run, bump and push by hand: + +```bash +tag=v1.0.3 +url="https://github.com/brightdigit/git-trees/archive/refs/tags/${tag}.tar.gz" +sha=$(curl -fsSL "$url" | shasum -a 256 | awk '{ print $1 }') + +# edit homebrew-tap/Formula/git-trees.rb — set url and sha256 together +ruby -c homebrew-tap/Formula/git-trees.rb +brew style homebrew-tap/Formula/git-trees.rb + +# optional: audit requires the formula to live in a tap checkout +git subrepo push homebrew-tap +brew untap brightdigit/tap 2>/dev/null +brew tap brightdigit/tap +brew audit --strict --formula brightdigit/tap/git-trees +``` + +## Verify with brew install + +```bash +brew untap brightdigit/tap 2>/dev/null # ensure a fresh clone +brew tap brightdigit/tap +brew install git-trees +git trees help +``` + +`git trees help` writes its usage to stderr and exits 0. Then leave the machine +as you found it if this was only a verification: + +```bash +brew uninstall git-trees +``` + +## Follow-up: shell completions + +Once shell completions ship (PR #51, targeted at v1.0.3), the completion files +are part of the release tarball and the formula's `install` block should install +them: + +```ruby +bash_completion.install "completions/git-trees.bash" +zsh_completion.install "completions/_git-trees" => "_git-trees" +``` + +Add those lines only in the formula revision whose `url` points at a tag that +actually contains `completions/` — referencing files missing from the tarball +breaks `brew install` outright. diff --git a/homebrew-tap/.gitrepo b/homebrew-tap/.gitrepo new file mode 100644 index 0000000..85c8894 --- /dev/null +++ b/homebrew-tap/.gitrepo @@ -0,0 +1,12 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = https://github.com/brightdigit/homebrew-tap.git + branch = main + commit = 9c94a73ab51d66c9141a2b3d5bd99c69ac077f58 + method = merge + cmdver = 0.4.9 + parent = 08b6fff8c93b96cf8b676efbd33f00b5ec187277 diff --git a/homebrew-tap/Formula/git-trees.rb b/homebrew-tap/Formula/git-trees.rb new file mode 100644 index 0000000..85821e7 --- /dev/null +++ b/homebrew-tap/Formula/git-trees.rb @@ -0,0 +1,36 @@ +class GitTrees < Formula + desc "Git subcommand for managing a bare-repo plus worktrees layout" + homepage "https://github.com/brightdigit/git-trees" + url "https://github.com/brightdigit/git-trees/archive/refs/tags/v1.0.2.tar.gz" + sha256 "301a8ab3f3a860c8e3125a61d45527f7a2f70a881c41d3d2ea899d47d4b547ef" + license "MIT" + + def install + bin.install "git-trees" + # TREES_AGENTS_TEMPLATE defaults to ~/.config/git-trees/AGENTS.md, which a + # formula must not write. Stage the template in the prefix and let caveats + # tell the user how to put it in place. + pkgshare.install "AGENTS.md.template" + end + + def caveats + <<~EOS + `git trees init` and `git trees root --agents` seed an AGENTS.md at the + container root from TREES_AGENTS_TEMPLATE, which defaults to + ~/.config/git-trees/AGENTS.md. Formulae cannot write there, so copy the + bundled template yourself: + + mkdir -p ~/.config/git-trees + cp #{pkgshare}/AGENTS.md.template ~/.config/git-trees/AGENTS.md + + Or point TREES_AGENTS_TEMPLATE at the bundled copy instead: + + export TREES_AGENTS_TEMPLATE=#{pkgshare}/AGENTS.md.template + EOS + end + + test do + # `help` writes its usage to stderr and exits 0. + assert_match "usage: git trees", shell_output("#{bin}/git-trees help 2>&1") + end +end diff --git a/homebrew-tap/README.md b/homebrew-tap/README.md new file mode 100644 index 0000000..107d428 --- /dev/null +++ b/homebrew-tap/README.md @@ -0,0 +1,14 @@ +# brightdigit/homebrew-tap + +Homebrew tap for [BrightDigit](https://github.com/brightdigit) formulae. + +## Usage + +```bash +brew tap brightdigit/tap +brew install git-trees +``` + +This repository is maintained as a [git subrepo](https://github.com/ingydotnet/git-subrepo) +inside [brightdigit/git-trees](https://github.com/brightdigit/git-trees). Formula bumps +are pushed here automatically on each git-trees GitHub release. From 0d7540f29f78c074b60c87ecbbb0354ad89dc1eb Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Tue, 25 Aug 2026 16:48:45 -0400 Subject: [PATCH 7/7] Address review findings and prepare the 1.0.3 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the CodeRabbit findings on #63, add the v1.0.3 changelog section, drop the temporary tap-push trigger, and bring the docs in line with what actually shipped. sync: gate _sync_target on worktree registration. An existing directory git did not know as a worktree resolved to a real path, matched nothing in the pull loop, and exited 0 having done nothing — a silent no-op where the "is not a worktree" error was expected. Reuses cmd_rm's registration check; covered by a new smoke assertion. completions: pass an array name to _describe. It dereferences each argument as a parameter (${(@P)name}), so the expanded "${(@u)targets}" made it look up branch names as parameters, which are empty, and completion silently offered nothing. homebrew-tap workflow: - Remove the `push: [49-homebrew]` trigger; that branch is gone from origin. Its `push)` case arm goes with it — the `*)` arm already errors on unsupported events, and leaving it would resurrect "guess the newest tag" if a push trigger were ever re-added. - Check out the default branch. A release event checks out the tag, leaving HEAD detached; `git push` fails and `git subrepo push` refuses outright ("Must be on a branch to run this command"), so the release path could not have published at all. - Route tag values through `env:` rather than `${{ }}` inside run blocks, and tighten validation from `v*` to a version prefix plus a character allowlist. The checkout persists a write-capable PAT, and the commit message interpolation sat inside double quotes where $(...) would execute. Skipped deliberately: zizmor's `persist-credentials: false`. Both `git push` and `git subrepo push` need the persisted credential; set to explicit true, which is zizmor's own documented resolution for artipacked. Also rejected CodeRabbit's merge-risk claim that this branch breaks the smoke suite — it passes locally and both CI jobs are green on the PR. homebrew formula: install both completion files, and lead the caveats with TREES_AGENTS_TEMPLATE, which survives `brew upgrade` where a copied file goes stale. url/sha256 stay at v1.0.2 on purpose: the workflow rewrites the pair together after the release publishes, and the v1.0.3 tarball sha cannot be computed before the tag exists. docs: fix the broken `git trees list` anchor; correct the claim that install.sh prints an activation line for each completion file (it prints only the bash one, deliberately — the zsh file is wired up via fpath); note that Homebrew installs completions automatically; fold RELEASING.md's stale "Follow-up: shell completions" section into normal release guidance; and document the sync constraints and the sync/prune/completions test coverage in AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/agent-notes.md | 2 + .github/workflows/homebrew-tap.yml | 62 ++++++++++++++++++++---------- AGENTS.md | 37 +++++++++++++++++- CHANGELOG.md | 13 +++++++ README.md | 16 +++++--- completions/_git-trees | 7 +++- docs/RELEASING.md | 27 +++++++------ git-trees | 14 +++++-- homebrew-tap/Formula/git-trees.rb | 18 ++++++--- tests/smoke.sh | 12 ++++++ 10 files changed, 154 insertions(+), 54 deletions(-) diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 9e787b7..8bb1e42 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -20,3 +20,5 @@ update or remove the stale line rather than leaving both. - `main` is always the stable release; README curl install pins `main` (not version tags). Do not describe `main` as a development/moving target. - `clean` supports `--gone` and `--merged` (detecting direct, rebased, and squash-merged PRs); `--older-than` is omitted. - When resolving CodeRabbit review comments, verify each claim against the code before acting; report skipped findings with the reason rather than silently dropping them. +- The Homebrew workflow owns the formula's `url`/`sha256` pair — it rewrites both together after a release is published. Do not hand-bump either: the tag's tarball sha cannot be computed before the tag exists, so editing the url alone ships a checksum mismatch. +- Verify a review finding before acting on it *and* before rejecting it; CodeRabbit's 1.0.3 pass included a false claim that the smoke suite was broken (CI was green on both platforms) alongside three findings that were real. diff --git a/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml index 5b9d21b..9d7c792 100644 --- a/.github/workflows/homebrew-tap.yml +++ b/.github/workflows/homebrew-tap.yml @@ -9,10 +9,6 @@ on: description: Release tag to publish (e.g. v1.0.3) required: true type: string - # Temporary: exercise the tap push from this PR branch (workflow_dispatch - # only works once the workflow exists on the default branch). - push: - branches: [49-homebrew] permissions: contents: write @@ -35,7 +31,16 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + # A release event checks out the tag, which leaves HEAD detached, and + # both `git push` below and `git subrepo push` refuse to run that way + # (subrepo errors outright: "Must be on a branch to run this command"). + # Read the default branch rather than hardcoding it so a rename here + # does not silently start pushing the bump to a stale branch. + ref: ${{ github.event.repository.default_branch }} token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # Explicit: the bump commit and the subrepo push both reuse this + # credential, so the token has to survive the checkout step. + persist-credentials: true - name: Install git-subrepo run: | @@ -44,13 +49,17 @@ jobs: - name: Resolve tag id: meta + # Via env, not `${{ }}` inside the script: a tag is attacker-influenced + # text, and the checkout above persisted a write-capable PAT. + env: + INPUT_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event.release.tag_name }} run: | - case "${{ github.event_name }}" in - workflow_dispatch) tag='${{ inputs.tag }}' ;; - release) tag='${{ github.event.release.tag_name }}' ;; - push) tag=$(git tag -l 'v*' --sort=-v:refname | head -n1) ;; + case "$GITHUB_EVENT_NAME" in + workflow_dispatch) tag="$INPUT_TAG" ;; + release) tag="$RELEASE_TAG" ;; *) - echo "Unsupported event: ${{ github.event_name }}" >&2 + echo "Unsupported event: $GITHUB_EVENT_NAME" >&2 exit 1 ;; esac @@ -59,43 +68,54 @@ jobs: exit 1 fi case "$tag" in - v*) ;; + v[0-9]*) ;; *) - echo "Expected a v-prefixed tag, got: $tag" >&2 + echo "Expected a v-prefixed version tag, got: $tag" >&2 + exit 1 + ;; + esac + # The tag reaches a URL and a commit message, so allow only characters + # inert in both. `/` is legal in a git ref but would build a wrong + # archive URL rather than fail cleanly, so it is excluded too. + case "$tag" in + *[!A-Za-z0-9._-]*) + echo "Tag has unexpected characters: $tag" >&2 exit 1 ;; esac - url="https://github.com/${{ github.repository }}/archive/refs/tags/${tag}.tar.gz" echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "url=$url" >> "$GITHUB_OUTPUT" + echo "url=https://github.com/$GITHUB_REPOSITORY/archive/refs/tags/${tag}.tar.gz" >> "$GITHUB_OUTPUT" - name: Bump formula url and sha256 + env: + URL: ${{ steps.meta.outputs.url }} run: | - tag='${{ steps.meta.outputs.tag }}' - url='${{ steps.meta.outputs.url }}' formula=homebrew-tap/Formula/git-trees.rb # curl -f fails on a missing tag instead of hashing a 404 body. - sha=$(curl -fsSL "$url" | sha256sum | awk '{ print $1 }') + sha=$(curl -fsSL "$URL" | sha256sum | awk '{ print $1 }') echo "sha256=$sha" - # Pass values via ENV so / in the URL cannot break s/// delimiters. - URL="$url" perl -i -pe 's/^(\s*url\s+)"[^"]*"/$1"$ENV{URL}"/' "$formula" + # Read via %ENV so / in the URL cannot break the s/// delimiters. + # URL is already exported by the step's `env:`; SHA is computed here. + perl -i -pe 's/^(\s*url\s+)"[^"]*"/$1"$ENV{URL}"/' "$formula" SHA="$sha" perl -i -pe 's/^(\s*sha256\s+)"[^"]*"/$1"$ENV{SHA}"/' "$formula" - grep -F "url \"$url\"" "$formula" + grep -F "url \"$URL\"" "$formula" grep -F "sha256 \"$sha\"" "$formula" ruby -c "$formula" - name: Commit formula bump + env: + TAG: ${{ steps.meta.outputs.tag }} run: | git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add homebrew-tap/Formula/git-trees.rb if git diff --staged --quiet; then - echo "Formula already at ${{ steps.meta.outputs.tag }}; nothing to commit" + echo "Formula already at $TAG; nothing to commit" else - git commit -m "homebrew: git-trees ${{ steps.meta.outputs.tag }}" + git commit -m "homebrew: git-trees $TAG" git push fi diff --git a/AGENTS.md b/AGENTS.md index b5705aa..310f7e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,23 @@ worktree removal or branch delete, but the exit status is nonzero if any failed, matching `cmd_rm`. Do not turn that back into an unconditional `return 0` — scripting `clean` depends on it. - - +**`sync` fetches once for the whole container.** Every worktree shares one +object store, so a per-worktree fetch transfers nothing after the first — the +single fetch is the design, not an optimisation to unroll. The default strategy +is `--ff-only`; `--rebase` is opt-in, and a strategy without `--pull` is +rejected rather than silently ignored, since `sync --rebase` that only fetched +would look like it had rebased. Like `clean`, the loop runs to completion and +returns nonzero if any worktree was skipped, so a nonzero exit means partial +success, not a stop. A detached HEAD is reported but deliberately not counted +as a failure. + +**`_sync_target` gates on worktree registration too**, but for a different +reason than `cmd_rm`'s: not to keep `TREES_RM_CMD` away from the container +root — `sync` never removes anything — but because an existing directory git +does not know as a worktree would otherwise resolve to a real path, match +nothing in the pull loop, and exit 0 having done nothing. A silent no-op is +worse than an error, so the unregistered case must keep reporting `is not a +worktree`. **`track` only ever sets `origin/`.** Same remote, same name. There is no flag for an arbitrary upstream, and `origin` is hardcoded throughout — @@ -179,6 +194,24 @@ What the suite covers: fresh branch preservation, dry run vs `--apply`, worktree directories actually gone after `--apply`, each selector run on its own, and custom `TREES_RM_CMD` routing +- **sync** — fetch-only advancing the remote-tracking ref while leaving the + worktree `HEAD` and files alone; `--pull` fast-forwarding and naming the + branch on stdout; a dirty worktree skipped with the upstream change *not* + applied over it; `--rebase` keeping the local commit and applying the upstream + one with no rebase left in progress; the mutually-exclusive and + strategy-without-`--pull` argument errors; and an **existing directory that is + not a registered worktree** rejected rather than exiting 0 silently +- **prune** — a clean container reporting nothing to prune on stderr and + nothing on stdout, a worktree directory deleted behind git's back leaving a + stale entry, `--dry-run` naming it without unlinking, the branch left intact + after the metadata is cleared, idempotency on a second run, and a live + worktree left registered +- **completions** — installed by `install.sh` byte-identical to the source and + never clobbered on rerun; the bash file defining `_git_trees`, offering + subcommands, the `ls` alias, and per-subcommand flags; routing through + `__gitcomp` when git's completion provides it; and staying empty and quiet + outside a repository. The zsh file is covered only as an installed artifact — + driving zsh's completion system needs a `zpty` harness the suite does not have Two assertion shapes are easy to get wrong: diff --git a/CHANGELOG.md b/CHANGELOG.md index 5954541..6970c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v1.0.3 + +## What's Changed + +* Add `sync` subcommand for fetching and updating worktrees by @leogdion in https://github.com/brightdigit/git-trees/issues/50 +* Add `prune` subcommand for clearing stale worktree metadata by @leogdion in https://github.com/brightdigit/git-trees/issues/55 +* Add bash and zsh completions by @leogdion in https://github.com/brightdigit/git-trees/issues/51 +* Add a one-line curl install by @leogdion in https://github.com/brightdigit/git-trees/issues/54 +* Fix `add` creating the base branch instead of the requested one when the base exists only on the remote by @leogdion in https://github.com/brightdigit/git-trees/issues/61 +* Add Homebrew formula and release automation by @leogdion in https://github.com/brightdigit/git-trees/issues/49 + +**Full Changelog**: https://github.com/brightdigit/git-trees/compare/v1.0.2...v1.0.3 + ## v1.0.2 ## What's Changed diff --git a/README.md b/README.md index 2220456..d51941a 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,11 @@ brew tap brightdigit/tap brew install git-trees ``` -Homebrew cannot write to your home directory, so this path installs the script -but not the agents template. `brew install` prints the one command that puts the -bundled template at `~/.config/git-trees/AGENTS.md`. +Homebrew installs the completions for you — both files land in Homebrew's own +completion directories, so no `source` line is needed. The agents template is +the exception: a formula cannot write to your home directory, so `brew install` +bundles the template inside its prefix and prints the one line that points +`TREES_AGENTS_TEMPLATE` at it. Either way, make sure the destination is on your `PATH`: @@ -170,8 +172,10 @@ named `git-trees` becomes `git trees`. ### Shell completions `install.sh` copies both completion files to `~/.config/git-trees/completions/` -and prints the activation line for each. It never overwrites a copy you have -edited, so a reinstall keeps your changes. +and prints the `source` line for the bash file — the one both bash and +Homebrew's zsh `git` completion need. The zsh file is wired up by `fpath` +rather than sourced, so it has no activation line of its own. Neither copy is +overwritten if you have edited it, so a reinstall keeps your changes. **bash** — source the file from `~/.bashrc`, after bash-completion itself: @@ -383,7 +387,7 @@ Under `--pull`, a worktree is skipped when: | 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 +[`git trees list`](#git-trees-list---json-alias-ls) 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 diff --git a/completions/_git-trees b/completions/_git-trees index 2f0a521..7915d1a 100644 --- a/completions/_git-trees +++ b/completions/_git-trees @@ -21,7 +21,12 @@ __git_trees_targets() { ${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null)"} ${(f)"$(__git_trees_worktree_names)"} ) - _describe -t targets 'branch or worktree' "${(@u)targets}" + # `_describe` takes the *name* of an array, not its values: it dereferences + # each argument as a parameter. Passing the expansion makes it look up branch + # names as parameters, which are empty, and completion silently offers + # nothing. Dedupe in place, then pass the name. + targets=( ${(u)targets} ) + _describe -t targets 'branch or worktree' targets } __git_trees_worktrees() { diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 78d41e0..bb18898 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -89,17 +89,16 @@ as you found it if this was only a verification: brew uninstall git-trees ``` -## Follow-up: shell completions - -Once shell completions ship (PR #51, targeted at v1.0.3), the completion files -are part of the release tarball and the formula's `install` block should install -them: - -```ruby -bash_completion.install "completions/git-trees.bash" -zsh_completion.install "completions/_git-trees" => "_git-trees" -``` - -Add those lines only in the formula revision whose `url` points at a tag that -actually contains `completions/` — referencing files missing from the tarball -breaks `brew install` outright. +## Formula contents + +The formula installs the script, both completion files, and the agents +template. The completion lines are only safe while the formula's `url` points +at a tag that actually contains `completions/` — referencing files missing from +the tarball breaks `brew install` outright. Completions shipped in v1.0.3, so +any tag from v1.0.3 on satisfies that; a formula rolled back to an earlier tag +would have to drop them again. + +The template is staged in the prefix rather than written to +`~/.config/git-trees/AGENTS.md`, because a formula must not write outside its +own prefix. The `caveats` block tells the user to point +`TREES_AGENTS_TEMPLATE` at the bundled copy. diff --git a/git-trees b/git-trees index ffcb019..eccbd1d 100755 --- a/git-trees +++ b/git-trees @@ -736,10 +736,12 @@ 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`. +# Resolve a positional target to a worktree path. Local to sync on purpose: its +# messages name `git trees sync`, and cmd_rm's gate guards a different hazard — +# keeping a custom TREES_RM_CMD away from the container root. The gate here +# earns its place for its own reason: without it an existing directory git does +# not know as a worktree resolves to a real path, matches nothing in the pull +# loop, and sync exits 0 having done nothing. _sync_target() { # _sync_target -> worktree path, or empty local target="$1" path="" @@ -749,6 +751,10 @@ _sync_target() { # _sync_target -> worktree path, or empty # `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) + if [ -n "$path" ] \ + && ! git worktree list --porcelain | grep -qxF "worktree $path"; then + path="" + fi fi [ -n "$path" ] && printf '%s\n' "$path" diff --git a/homebrew-tap/Formula/git-trees.rb b/homebrew-tap/Formula/git-trees.rb index 85821e7..8bac26f 100644 --- a/homebrew-tap/Formula/git-trees.rb +++ b/homebrew-tap/Formula/git-trees.rb @@ -7,6 +7,11 @@ class GitTrees < Formula def install bin.install "git-trees" + # The filenames are load-bearing: git's completion dispatches `git trees` + # to a function named `_git_trees` (the bash file), and stock zsh's `_git` + # looks for a file named `_git-trees` on fpath for the standalone binary. + bash_completion.install "completions/git-trees.bash" + zsh_completion.install "completions/_git-trees" # TREES_AGENTS_TEMPLATE defaults to ~/.config/git-trees/AGENTS.md, which a # formula must not write. Stage the template in the prefix and let caveats # tell the user how to put it in place. @@ -17,15 +22,16 @@ def caveats <<~EOS `git trees init` and `git trees root --agents` seed an AGENTS.md at the container root from TREES_AGENTS_TEMPLATE, which defaults to - ~/.config/git-trees/AGENTS.md. Formulae cannot write there, so copy the - bundled template yourself: + ~/.config/git-trees/AGENTS.md. Formulae cannot write there, so point the + variable at the bundled template from your shell rc: - mkdir -p ~/.config/git-trees - cp #{pkgshare}/AGENTS.md.template ~/.config/git-trees/AGENTS.md + export TREES_AGENTS_TEMPLATE=#{pkgshare}/AGENTS.md.template - Or point TREES_AGENTS_TEMPLATE at the bundled copy instead: + That copy tracks the installed version. To edit your own instead, copy it + to the default path — a copy will not pick up later upgrades: - export TREES_AGENTS_TEMPLATE=#{pkgshare}/AGENTS.md.template + mkdir -p ~/.config/git-trees + cp #{pkgshare}/AGENTS.md.template ~/.config/git-trees/AGENTS.md EOS end diff --git a/tests/smoke.sh b/tests/smoke.sh index 8393cad..c005092 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -975,6 +975,18 @@ assert_contains "sync explains that a strategy needs --pull" "$out" "requires -- 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 + +# An existing directory git does not know as a worktree. Without the +# registration gate in _sync_target this resolved to a real path, matched no +# worktree in the pull loop, and exited 0 having done nothing — the silent +# no-op is the regression, so assert the exit status and the message. +mkdir -p not-a-worktree +assert_fail "sync rejects an unregistered directory" \ + bash "$T" sync not-a-worktree --pull +out=$(bash "$T" sync not-a-worktree --pull 2>&1 >/dev/null) +assert_contains "sync names the unregistered directory" "$out" "is not a worktree" +rmdir not-a-worktree + assert_fail "sync outside a repo" in_dir "$TMP/plain" bash "$T" sync # --- prune -------------------------------------------------------------------