From 17a21e11db8f794047adf1b298fd381eb063af62 Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 25 Jul 2026 14:55:39 -0400 Subject: [PATCH 1/8] Remove the `clean` subcommand from v1.0.0 (#35) --- .claude/agent-notes.md | 1 + .github/workflows/ci.yml | 15 +++++-- AGENTS.md | 23 ++++++---- README.md | 23 ++++------ git-trees | 97 ---------------------------------------- 5 files changed, 35 insertions(+), 124 deletions(-) diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 2d7e5f3..4f26924 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -12,3 +12,4 @@ update or remove the stale line rather than leaving both. - Never merge pull requests unless the user explicitly asks to merge. - Do not change `AGENTS.md.template` for project-specific agent conventions; that file is for the installing developer. +- User-facing docs (README, CHANGELOG) must not mention features that never shipped; 1.0.0 is the first release, so there is no prior version to reference. Rationale for removals belongs in AGENTS.md or the issue tracker. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6a49eb..69ac629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,9 +86,18 @@ jobs: bash "$T" list bash "$T" list --json | python3 -c 'import json,sys; json.load(sys.stdin)' - git branch fresh origin/main - if bash "$T" clean 2>&1 | grep -A50 'merged into' | grep -q ' fresh$'; then - echo "FAIL: fresh branch listed as merged"; exit 1 + # `clean` is not a command (#17). Assert it is *unrecognized* — a bare + # nonzero exit would also pass if it were dispatched and merely failed. + if bash "$T" clean 2>/tmp/clean-err; then + echo "FAIL: clean should be an unknown command"; exit 1 + fi + grep -q "unknown command 'clean'" /tmp/clean-err || { + echo "FAIL: clean failed, but not as an unknown command" + cat /tmp/clean-err; exit 1 + } + # Whole word: the usage text legitimately contains "clean/dirty". + if bash "$T" help 2>&1 | grep -qw clean; then + echo "FAIL: help still advertises clean"; exit 1 fi # Outside a repo → nonzero exit diff --git a/AGENTS.md b/AGENTS.md index 7449f88..b224a8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,24 +34,30 @@ needing to change the user's directory must instead print a path on stdout and be wrapped by the optional shell function in the README. `--print-path` on `add` is the established pattern: path to stdout, all other output to stderr. -**Report before destroy.** `clean` defaults to reporting. Destructive work -happens only under `--apply`. Use `git branch -d`, never `-D`; when it fails, -print the `-D` command for the user rather than running it. +**Nothing destructive.** No subcommand removes a worktree or deletes a branch. +`clean` did, and was pulled before v1.0.0 ([#34](https://github.com/brightdigit/git-trees/issues/34)). +Anything that destroys user data must report by default and act only under an +explicit `--apply`, must use `git branch -d` and never `-D`, and must route +directory removal through a user-configurable command — see #34 for the full +contract before adding one. ## Two bugs that were found by testing — don't regress them 1. **`git worktree add -b ` inherits the base ref's upstream.** A branch 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. + `cmd_track` set the correct upstream. Live in `cmd_add`; any change there + needs a fresh test. 2. **`git branch --merged` flags branches with no commits of their own.** A - branch just cut from `main` is reachable-from-`main` and looks merged. The - merged pass must skip branches where - `git rev-list --count origin/..
` is 0. + branch just cut from `main` is reachable-from-`main` and looks merged. Any + merged-branch pass must skip branches where + `git rev-list --count origin/..
` is 0. No code relies on this today — + the `clean` command that did was removed — but the note stays, because losing + it is how the bug comes back when `clean` returns (#34). Both are counterintuitive and both were caught only by running against a real -repo. Any change touching `add` or `clean --merged` needs a fresh test. +repo. ## Testing @@ -80,7 +86,6 @@ Then exercise the paths. Things worth checking after any change: - `add` into an existing directory — clear collision error, nonzero exit - `add x --print-path` — stdout must be *only* the path - `list --json` — valid JSON, includes branches with no worktree -- `clean` — a freshly cut branch must not appear under "merged" - `git trees` outside a repo — clean error, nonzero exit Automated coverage lives in `.github/workflows/ci.yml` (ubuntu + macOS). Also run diff --git a/README.md b/README.md index ae58ba8..15127a1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ git trees init brightdigit/some-repo cd some-repo git trees add feature-x git trees list -git trees clean --older-than 30 ``` ## Why @@ -20,7 +19,6 @@ support, but the built-in porcelain leaves gaps: - `git worktree add -b` silently inherits the base ref's upstream, so a new branch ends up tracking `main` and pushes to the wrong place - No single view of worktrees *and* branches that lack one -- No cleanup for branches whose remote is gone - Bare-clone setup for this layout is a four-command incantation `git-trees` covers those. It's deliberately small and readable — one bash file @@ -145,19 +143,15 @@ One entry per branch with upstream, ahead/behind, last commit date, clean/dirty, and path (relative to the project root). Includes branches with no worktree, shown with path `(none)`. `--json` emits the same fields as an array. -### `git trees clean [--older-than N] [--merged|--gone] [--apply]` +## Removing worktrees -Reports by default; `--apply` executes. Three passes: +`git-trees` does not delete anything. Remove a worktree and its branch with git: -- **gone** — branches whose upstream is deleted (`[gone]`) -- **merged** — branches merged into the default branch, *excluding* those with - no commits of their own (a branch freshly cut from `main` is technically - merged but isn't finished work) -- **older-than** — worktrees whose directory mtime exceeds N days - -`--gone` and `--merged` are mutually exclusive selectors; passing neither runs -both. Uses `git branch -d` (safe) and tells you when to escalate to `-D`. Never -removes the repo root or your current directory. +```bash +git worktree remove +git branch -d # -d refuses unmerged work; escalate to -D yourself +git worktree prune +``` ## Environment @@ -183,8 +177,7 @@ trees() { ## Known limitations -- `--older-than` uses directory mtime, which build output touches. Last commit - date would be a truer measure of staleness. +- Removing stale worktrees and branches is manual; nothing here deletes. - `list` spawns several processes per branch — fine for dozens, slow for hundreds. - `add` ignores `base` when the branch already exists rather than failing. - Bash-only (uses process substitution); not POSIX sh. diff --git a/git-trees b/git-trees index 1fc2013..f18fa35 100755 --- a/git-trees +++ b/git-trees @@ -8,7 +8,6 @@ # git trees add [base] [--print-path] # git trees track [path] # git trees list [--json] (alias: ls) -# git trees clean [--older-than N] [--merged|--gone] [--apply] # # Env (all optional): # TREES_HOST default host for init (default: github.com) @@ -407,99 +406,6 @@ cmd_list() { fi } -# --- clean ------------------------------------------------------------------- - -cmd_clean() { - local days="" apply=0 do_gone=1 do_merged=1 do_stale=0 root def br p wt - - while [ $# -gt 0 ]; do - case "$1" in - --older-than) days="${2:-}"; do_stale=1; shift 2 ;; - --gone) do_gone=1; do_merged=0; shift ;; - --merged) do_merged=1; do_gone=0; shift ;; - --apply) apply=1; shift ;; - *) echo "git trees clean: unknown option $1" >&2; return 1 ;; - esac - done - - root=$(_root) || { echo "git trees clean: not in a git repo" >&2; return 1; } - def=$(_default_branch) - git fetch --prune origin - - if [ "$do_gone" -eq 1 ]; then - echo "== branches with gone upstream ==" - while read -r br; do - [ -z "$br" ] && continue - p=$(_path_for "$br") - echo " $br${p:+ ($p)}" - if [ "$apply" -eq 1 ]; then - if [ -n "$p" ]; then - if ! git worktree remove "$p"; then - echo " ! worktree remove failed — skipped branch delete for $br" >&2 - continue - fi - fi - git branch -d "$br" 2>/dev/null \ - || echo " ! unmerged — use: git branch -D $br" - fi - done < <( - git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads \ - | grep '\[gone\]' | awk '{print $1}' - ) - fi - - if [ "$do_merged" -eq 1 ]; then - echo "== branches merged into $def ==" - while read -r br; do - [ -z "$br" ] && continue - # Skip branches that have no commits of their own — a branch pointing at - # the base, or freshly created from it, is "merged" but not finished work. - [ "$(git rev-list --count "origin/$def..$br" 2>/dev/null)" = "0" ] && continue - p=$(_path_for "$br") - echo " $br${p:+ ($p)}" - if [ "$apply" -eq 1 ]; then - if [ -n "$p" ]; then - if ! git worktree remove "$p"; then - echo " ! worktree remove failed — skipped branch delete for $br" >&2 - continue - fi - fi - git branch -d "$br" 2>/dev/null \ - || echo " ! unmerged — use: git branch -D $br" - fi - done < <( - git branch --merged "origin/$def" --format='%(refname:short)' 2>/dev/null \ - | grep -vx "$def" - ) - fi - - if [ "$do_stale" -eq 1 ]; then - [ -z "$days" ] && { echo "git trees clean: --older-than needs a number of days" >&2; return 1; } - local common cwd_canon wt_canon - common=$(cd "$(git rev-parse --git-common-dir)" && pwd) - cwd_canon=$(cd "$PWD" && pwd -P) - echo "== worktrees untouched >${days}d ==" - while read -r wt; do - [ "$wt" = "$root" ] && continue - [ "$wt" = "$common" ] && continue # bare store (trees-bare.git), not a worktree - wt_canon=$(cd "$wt" && pwd -P) || continue - # Skip the worktree containing cwd (root or any subdirectory). - [ "$cwd_canon" = "$wt_canon" ] && continue - case "$cwd_canon" in - "$wt_canon"/*) continue ;; - esac - if [ -n "$(find "$wt" -maxdepth 0 -mtime +"$days" 2>/dev/null)" ]; then - echo " $wt" - [ "$apply" -eq 1 ] && git worktree remove "$wt" - fi - done < <(git worktree list --porcelain | awk '/^worktree /{sub(/^worktree /, ""); print}') - fi - - git worktree prune - [ "$apply" -eq 0 ] && echo "(report only — pass --apply to execute)" - return 0 -} - # --- usage / dispatch -------------------------------------------------------- usage() { @@ -511,8 +417,6 @@ usage: git trees [args] add [base] [--print-path] create a worktree (sets upstream) track [path] ensure branch has an upstream list [--json] worktrees + branches without one - clean [--older-than N] [--merged|--gone] [--apply] - report/remove stale trees, branches env (optional): TREES_ORG, TREES_HOST, TREES_AGENTS_TEMPLATE — see README @@ -536,7 +440,6 @@ main() { add) cmd_add "$@" ;; track) cmd_track "$@" ;; list|ls) cmd_list "$@" ;; - clean) cmd_clean "$@" ;; help|--help|-h) usage; return 0 ;; *) echo "git trees: unknown command '$cmd'" >&2; usage; return 1 ;; esac From 15bb7dfea1531b957894961b163b04628c521cbd Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 25 Jul 2026 14:57:26 -0400 Subject: [PATCH 2/8] Fix five defects in `git-trees` (#36) --- .claude/agent-notes.md | 1 + .github/workflows/ci.yml | 80 ++++++++++++++++++++++++++--- AGENTS.md | 13 ++++- README.md | 16 ++++-- git-trees | 108 ++++++++++++++++++++++++++++++++++----- 5 files changed, 191 insertions(+), 27 deletions(-) diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 4f26924..d0b99a0 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -12,4 +12,5 @@ update or remove the stale line rather than leaving both. - Never merge pull requests unless the user explicitly asks to merge. - Do not change `AGENTS.md.template` for project-specific agent conventions; that file is for the installing developer. +- Branch names with `/` are supported and slug to `-` in the directory name; do not restore the old outright rejection (supersedes closed issues #4 and #8). - User-facing docs (README, CHANGELOG) must not mention features that never shipped; 1.0.0 is the first release, so there is no prior version to reference. Rationale for removals belongs in AGENTS.md or the issue tracker. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69ac629..e3f1b81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: git init -q -b main . echo hi > a.txt && git add . && git commit -qm init git branch feature-x + git branch slug/one # pre-existing slash branch, as a real repo has cd /tmp/tt && git clone -q --bare /tmp/tt/origin proj/trees-bare.git echo "gitdir: ./trees-bare.git" > proj/.git @@ -53,17 +54,37 @@ jobs: bash "$T" help - if bash "$T" add feature/x 2>/tmp/slash-err; then - echo "FAIL: add should reject branch names containing /"; exit 1 - fi - grep -q "must not contain '/'" /tmp/slash-err || { - echo "FAIL: missing slash-rejection message"; cat /tmp/slash-err; exit 1 - } - bash "$T" add feature-x up=$(git -C feature-x rev-parse --abbrev-ref '@{upstream}') [ "$up" = "origin/feature-x" ] || { echo "FAIL: upstream was $up"; exit 1; } + # A slash branch checks out into a slugged directory; the ref keeps + # its slash. Uses a name of its own so it cannot claim feature-x above. + bash "$T" add slug/one + [ -d slug-one ] || { echo "FAIL: slug/one did not create slug-one"; exit 1; } + b=$(git -C slug-one symbolic-ref --short HEAD) + [ "$b" = "slug/one" ] || { echo "FAIL: branch was $b, expected slug/one"; exit 1; } + up=$(git -C slug-one rev-parse --abbrev-ref '@{upstream}') + [ "$up" = "origin/slug/one" ] || { echo "FAIL: upstream was $up"; exit 1; } + + # Two branches slugging to one directory: refuse, and name the owner. + if bash "$T" add slug-one 2>/tmp/slug-err; then + echo "FAIL: slug-one should collide with slug/one"; exit 1 + fi + grep -q "slug/one" /tmp/slug-err || { + echo "FAIL: collision message did not name the owning branch" + cat /tmp/slug-err; exit 1 + } + + # New slash branch: every component slugged, and --no-track still + # applies so it does not inherit origin/main. + bash "$T" add deep/new/branch + [ -d deep-new-branch ] || { echo "FAIL: deep/new/branch did not slug"; exit 1; } + up=$(git -C deep-new-branch rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none) + [ "$up" = "origin/deep/new/branch" ] || { + echo "FAIL: new slash branch upstream was $up"; exit 1 + } + mkdir leftover-dir if bash "$T" add leftover-dir 2>/tmp/collide-err; then echo "FAIL: add should reject an existing directory"; exit 1 @@ -107,4 +128,49 @@ jobs: fi rmdir "$outside" + # --- #18: an option missing its value must fail, not spin forever --- + # Bounded, because a regression hangs the runner until the job timeout. + for opt in --host --dir; do + bash "$T" init "$opt" >/dev/null 2>&1 & + pid=$! + hung=1 + for _ in 1 2 3 4 5 6; do + if ! kill -0 $pid 2>/dev/null; then hung=0; break; fi + sleep 0.5 + done + if [ "$hung" -eq 1 ]; then + kill -9 $pid 2>/dev/null || true + echo "FAIL: 'init $opt' with no value hung"; exit 1 + fi + if wait $pid; then + echo "FAIL: 'init $opt' with no value should exit nonzero"; exit 1 + fi + done + + # --- #31: invalid branch names report the branch, not a collision --- + for bad in . ..; do + if bash "$T" add "$bad" 2>/tmp/badname-err; then + echo "FAIL: add '$bad' should fail"; exit 1 + fi + if grep -q "directory already exists" /tmp/badname-err; then + echo "FAIL: add '$bad' reported a directory collision"; cat /tmp/badname-err; exit 1 + fi + done + if bash "$T" add 'has space' 2>/tmp/space-err; then + echo "FAIL: add 'has space' should fail"; exit 1 + fi + # Must be rejected before git starts building the worktree. + if grep -q 'Preparing worktree' /tmp/space-err; then + echo "FAIL: 'has space' rejected only after worktree add ran"; cat /tmp/space-err; exit 1 + fi + + # --- #23: --json must escape backslashes, not just quotes --- + jf=$(mktemp -d)/'back\slash "quoted"' + git worktree add -b jsonesc "$jf" origin/main >/dev/null 2>&1 + bash "$T" list --json | python3 -c 'import json,sys; json.load(sys.stdin)' || { + echo "FAIL: list --json invalid with a backslash in a path"; exit 1 + } + git worktree remove --force "$jf" + git branch -D jsonesc >/dev/null + echo "smoke tests passed" diff --git a/AGENTS.md b/AGENTS.md index b224a8d..c9dba14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,14 @@ needing to change the user's directory must instead print a path on stdout and be wrapped by the optional shell function in the README. `--print-path` on `add` is the established pattern: path to stdout, all other output to stderr. +**Worktrees do not nest.** Every worktree is a direct child of the container +root, so the directory name is the branch name with each `/` replaced by `-` +(`_slug`). The branch itself is never renamed — only the directory. The +consequence is that `feature/x` and `feature-x` compete for one directory; +`cmd_add` resolves this by refusing the second and naming the branch that owns +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.** No subcommand removes a worktree or deletes a branch. `clean` did, and was pulled before v1.0.0 ([#34](https://github.com/brightdigit/git-trees/issues/34)). Anything that destroys user data must report by default and act only under an @@ -79,7 +87,10 @@ git fetch -q origin && git remote set-head origin --auto >/dev/null Then exercise the paths. Things worth checking after any change: -- `add feature/x` — must fail (no `/` in branch names) +- `add feature/x` — creates `feature-x/`; the branch keeps its slash and its + upstream is `origin/feature/x` +- `add feature-x` when `feature/x` already owns `feature-x/` — must fail, and the + error must name `feature/x` - `add feature-x` — remote branch exists; upstream must be `origin/feature-x` - `add brandnew` — no remote branch; upstream must be `origin/brandnew`, **not** `origin/main`; failed track/push must exit nonzero diff --git a/README.md b/README.md index 15127a1..dbdff69 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ you can audit in a sitting. ``` Worktrees share a single object store but have independent working trees, -indexes, and HEADs. The worktree directory matches the branch name, so branch -names must not contain `/` — use dashes (`feature-x`, not `feature/x`). +indexes, and HEADs. Every worktree is a direct child of the container root, so a +branch's `/` becomes a `-` in the directory name: `feature/x` checks out into +`feature-x/` while the branch keeps its real name. ## Install @@ -125,9 +126,12 @@ Upstream is always set afterward via `track`; if that push/upstream setup fails, `add` exits nonzero (the worktree may still exist). `--print-path` writes the path to stdout and everything else to stderr, for shell wrappers. -Branch names must not contain `/` (rejected with a clear error). If the target -directory already exists, `add` fails rather than inventing a new name. If the -branch already exists, `base` is ignored with a warning. +Branch names may contain `/`; the directory is the branch name with each `/` +replaced by `-`, since worktrees do not nest. That makes `feature/x` and +`feature-x` compete for one directory — whichever exists first keeps it, and +`add` refuses the other by name rather than inventing a variant. If the target +directory already exists for any other reason, `add` fails rather than inventing +a new name. If the branch already exists, `base` is ignored with a warning. ### `git trees track [path]` @@ -180,6 +184,8 @@ trees() { - Removing stale worktrees and branches is manual; nothing here deletes. - `list` spawns several processes per branch — fine for dozens, slow for hundreds. - `add` ignores `base` when the branch already exists rather than failing. +- Branch names beginning with `-` are unsupported: `add` parses them as options + and reports `unknown option`. There is no `--` end-of-options marker. - Bash-only (uses process substitution); not POSIX sh. ## Prior art diff --git a/git-trees b/git-trees index f18fa35..e374a3a 100755 --- a/git-trees +++ b/git-trees @@ -36,6 +36,21 @@ _path_for() { # _path_for -> worktree path or empty ' } +# Worktree directory name for a branch. Every worktree is a direct child of the +# container root, so a branch's `/` cannot become a directory separator — it +# becomes `-`. The branch keeps its real name; only the directory is slugged. +# Two branches can slug to the same name (feature/x and feature-x); `cmd_add` +# refuses the second rather than guessing which one owns the directory. +_slug() { printf '%s\n' "${1//\//-}"; } + +_branch_at() { # _branch_at -> branch checked out there, or empty + git worktree list --porcelain \ + | awk -v w="$1" ' + /^worktree /{p=$0; sub(/^worktree /, "", p)} + /^branch /{ if (p==w) { b=$0; sub(/^branch refs\/heads\//, "", b); print b } } + ' +} + # Absolute path -> path relative to $1; unchanged if outside. _rel_to() { case "$2" in @@ -110,16 +125,27 @@ _ensure_git_pointer() { # every `git status` and invites being committed to the branch. # No-op when unconfigured or already present. Notice goes to stderr: callers # print paths on stdout. +# `A || B && return` groups as `{ A || B; } && return`, so the compound status is +# 1 when neither test holds — an inverted error path waiting to happen. Both +# guards are written out. _seed_agents() { [ -f "$TREES_AGENTS_TEMPLATE" ] || return 0 # Any existing path (including a broken symlink) is occupied — never overwrite. - [ -e "$1/AGENTS.md" ] || [ -L "$1/AGENTS.md" ] && return 0 + # -e is false for a broken symlink; -L is true. Both tests are needed. + if [ -e "$1/AGENTS.md" ] || [ -L "$1/AGENTS.md" ]; then + return 0 + fi # noclobber create: refuses to replace a file that appears between check and write. if (set -C; cat -- "$TREES_AGENTS_TEMPLATE" > "$1/AGENTS.md"); then echo "wrote $1/AGENTS.md" >&2 return 0 fi - [ -e "$1/AGENTS.md" ] || [ -L "$1/AGENTS.md" ] && return 0 + # The write failed. Benign if another process won the race and created it; + # a real failure (read error, full disk) otherwise. + if [ -e "$1/AGENTS.md" ] || [ -L "$1/AGENTS.md" ]; then + return 0 + fi + echo "git trees: could not write $1/AGENTS.md" >&2 return 1 } @@ -139,8 +165,14 @@ cmd_init() { local host="" arg="" dir="" repo url="" host_set=0 while [ $# -gt 0 ]; do case "$1" in - --host) host="${2:-}"; host_set=1; shift 2 ;; - --dir) dir="${2:-}"; shift 2 ;; + # `shift 2` with one argument left fails and leaves $1 in place, so the + # while loop spins forever. Check arity before shifting. + --host) + [ $# -ge 2 ] || { echo "git trees init: --host needs a value" >&2; return 1; } + host="$2"; host_set=1; shift 2 ;; + --dir) + [ $# -ge 2 ] || { echo "git trees init: --dir needs a value" >&2; return 1; } + dir="$2"; shift 2 ;; -*) echo "git trees init: unknown option $1" >&2; return 1 ;; *) arg="$1"; shift ;; esac @@ -191,8 +223,17 @@ cmd_init() { # Pointer so plain git works from the container root; bare store stays visible. echo "gitdir: ./trees-bare.git" > "$dir/.git" - git -C "$dir" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' - git -C "$dir" fetch origin + + # The refspec + fetch are what give a bare clone its remote-tracking refs. If + # either fails the container has no origin/*, so `add` cannot resolve the + # default branch — roll back rather than report success on a broken container. + if ! git -C "$dir" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' \ + || ! git -C "$dir" fetch origin; then + echo "git trees init: clone succeeded but fetch/config failed — removing $dir" >&2 + rm -rf "$dir" + return 1 + fi + # Optional: fails harmlessly against a remote with no HEAD. git -C "$dir" remote set-head origin --auto >/dev/null 2>&1 _seed_agents "$dir" || return 1 @@ -264,7 +305,7 @@ cmd_root() { # --- add --------------------------------------------------------------------- cmd_add() { - local br="" base="" print_path=0 root path dir + local br="" base="" print_path=0 root path dir owner while [ $# -gt 0 ]; do case "$1" in @@ -282,12 +323,23 @@ cmd_add() { [ -z "$br" ] && { echo "usage: git trees add [base] [--print-path]" >&2; return 1; } case "$br" in - */*) - echo "git trees add: branch names must not contain '/'; use dashes (e.g. feature-x), not feature/x" >&2 + .|..) + echo "git trees add: '$br' is a directory reference, not a branch name" >&2 return 1 ;; esac + # Validate before anything else touches the name. Without this, `add ..` + # resolves to "$root/.." — which always exists — and fails with a directory + # collision, and `add 'has space'` only fails after `worktree add` has already + # printed "Preparing worktree…". Both report the wrong problem. + # Silenced: this runs before the `--print-path` branch, so any output of its + # own would land on the caller's stdout. + if ! git check-ref-format "refs/heads/$br" >/dev/null 2>&1; then + echo "git trees add: '$br' is not a valid branch name" >&2 + return 1 + fi + root=$(_root) || { echo "git trees add: not in a git repo" >&2; return 1; } path=$(_path_for "$br") @@ -298,9 +350,17 @@ cmd_add() { return 0 fi - dir="$root/$br" + dir="$root/$(_slug "$br")" if [ -e "$dir" ]; then - echo "git trees add: directory already exists: $dir" >&2 + # `feature/x` and `feature-x` slug to the same directory. Name the branch + # that got there first, so the error says what to rename instead of leaving + # the user staring at a directory they never created. + owner=$(_branch_at "$dir") + if [ -n "$owner" ] && [ "$owner" != "$br" ]; then + echo "git trees add: '$br' and '$owner' both map to $dir — rename one" >&2 + else + echo "git trees add: directory already exists: $dir" >&2 + fi return 1 fi @@ -383,12 +443,32 @@ cmd_list() { done < <(git for-each-ref --format='%(refname:short)' refs/heads) if [ "$json" -eq 1 ]; then + # Escaping is done character by character on purpose. The obvious + # gsub(/\\/, "\\\\", s) does not work: awk reads "\\" in a *replacement* + # string as one literal backslash, so it under-escapes and emits invalid + # JSON. Plain concatenation has no such rule. printf '%s' "$rows" | awk -F'\t' ' - BEGIN { print "["; sep = "" } + function jesc(s, out, i, n, c) { + out = ""; n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "\\") out = out "\\\\" + else if (c == "\"") out = out "\\\"" + else if (c in ctl) out = out ctl[c] + else out = out c + } + return out + } + BEGIN { + # Control characters are not legal raw in a JSON string. + for (i = 1; i < 32; i++) ctl[sprintf("%c", i)] = sprintf("\\u%04x", i) + ctl["\b"] = "\\b"; ctl["\f"] = "\\f"; ctl["\n"] = "\\n" + ctl["\r"] = "\\r"; ctl["\t"] = "\\t" + print "["; sep = "" + } NF { - for (i = 1; i <= NF; i++) gsub(/"/, "\\\"", $i) printf "%s {\"branch\":\"%s\",\"upstream\":\"%s\",\"track\":\"%s\",\"date\":\"%s\",\"state\":\"%s\",\"path\":\"%s\"}\n", \ - sep, $1, $2, $3, $4, $5, $6 + sep, jesc($1), jesc($2), jesc($3), jesc($4), jesc($5), jesc($6) sep = "," } END { print "]" }' From 5a1b941201702cc1dcb7de1f9963e3ac79475a39 Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 25 Jul 2026 14:59:02 -0400 Subject: [PATCH 3/8] Document that `add` writes to the remote, and add `--no-push` (#37) --- .github/workflows/ci.yml | 28 +++++++++++++++++++ AGENTS.md | 8 ++++++ README.md | 33 +++++++++++++++++----- git-trees | 59 ++++++++++++++++++++++++++++++++++------ 4 files changed, 112 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3f1b81..a11558a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,6 +121,34 @@ jobs: echo "FAIL: help still advertises clean"; exit 1 fi + # --- #25: --no-push must not create the branch on origin --- + # Remote branch absent: nothing pushed, upstream unset, exit 0. + bash "$T" add nopush --no-push 2>/tmp/nopush-err + if git ls-remote --exit-code --heads origin nopush >/dev/null 2>&1; then + echo "FAIL: --no-push created origin/nopush"; exit 1 + fi + if git -C nopush rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then + echo "FAIL: --no-push set an upstream"; exit 1 + fi + grep -q 'git push -u origin HEAD' /tmp/nopush-err || { + echo "FAIL: --no-push did not print the push command"; cat /tmp/nopush-err; exit 1 + } + + # Remote branch present: --no-push must still set the upstream. + git push -q origin feature-x:nopush-existing + git fetch -q origin + bash "$T" add nopush-existing --no-push >/dev/null 2>&1 + up=$(git -C nopush-existing rev-parse --abbrev-ref '@{upstream}') + [ "$up" = "origin/nopush-existing" ] || { + echo "FAIL: --no-push upstream was $up"; exit 1 + } + + # TREES_NO_PUSH does the same without the flag. + TREES_NO_PUSH=1 bash "$T" add envnopush >/dev/null 2>&1 + if git ls-remote --exit-code --heads origin envnopush >/dev/null 2>&1; then + echo "FAIL: TREES_NO_PUSH created origin/envnopush"; exit 1 + fi + # Outside a repo → nonzero exit outside=$(mktemp -d) if (cd "$outside" && bash "$T" list) >/dev/null 2>&1; then diff --git a/AGENTS.md b/AGENTS.md index c9dba14..0fd1dd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,14 @@ explicit `--apply`, must use `git branch -d` and never `-D`, and must route directory removal through a user-configurable command — see #34 for the full contract before adding one. +**`track` only ever sets `origin/`.** Same remote, same name. There is +no flag for an arbitrary upstream, and `origin` is hardcoded throughout — +deliberately, since the layout assumes one remote. A user wanting something else +runs `git branch --set-upstream-to` themselves; `track` is idempotent and returns +early once *any* upstream is set, so it will not fight them. If this ever grows a +`--upstream ` flag, `cmd_add` must pass it through — `add` calls `cmd_track` +unconditionally, and would otherwise overwrite what the user asked for. + ## Two bugs that were found by testing — don't regress them 1. **`git worktree add -b ` inherits the base ref's upstream.** A diff --git a/README.md b/README.md index dbdff69..1ac9bc2 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ containers that `init` did not create. Skipped if the file already exists or no template is configured; the notice goes to stderr, so the path on stdout stays clean for `$(git trees root)`. -### `git trees add [base] [--print-path]` +### `git trees add [base] [--print-path] [--no-push]` Creates a worktree, handling three cases: @@ -122,9 +122,21 @@ 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` | -Upstream is always set afterward via `track`; if that push/upstream setup fails, -`add` exits nonzero (the worktree may still exist). `--print-path` writes the -path to stdout and everything else to stderr, for shell wrappers. +> **`add` writes to the remote.** Upstream is always 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 +> is a reasonable default for parallel coding agents, which need an upstream to +> push to, but it means a local-feeling command fires CI, sends notifications, +> and publishes a branch name. Pass `--no-push` to skip it. + +With `--no-push`, `add` sets the upstream when `origin/` already exists +and otherwise leaves it unset, printing the `git push -u origin HEAD` you can run +yourself. It still exits 0. Set `TREES_NO_PUSH` to any non-empty value to get +that behavior everywhere without passing the flag. + +If the push or upstream setup fails, `add` exits nonzero (the worktree may still +exist). `--print-path` writes the path to stdout and everything else to stderr, +for shell wrappers. Branch names may contain `/`; the directory is the branch name with each `/` replaced by `-`, since worktrees do not nest. That makes `feature/x` and @@ -133,13 +145,19 @@ replaced by `-`, since worktrees do not nest. That makes `feature/x` and directory already exists for any other reason, `add` fails rather than inventing a new name. If the branch already exists, `base` is ignored with a warning. -### `git trees track [path]` +### `git trees track [path] [--no-push]` Idempotent. Ensures the branch in `path` (default `.`) has an upstream: sets it -to `origin/` if that exists remotely, otherwise `push -u`. Returns +to `origin/` if that exists remotely, otherwise runs +`git push -u origin HEAD`, **which creates the branch on `origin`**. Returns immediately if an upstream is already configured. -Useful for repairing worktrees created before this tool. +`--no-push` (or a non-empty `TREES_NO_PUSH`) suppresses that push: the upstream +is left unset and the exact command to run is printed to stderr. Exit status +stays 0 — not setting an upstream is the requested outcome, not a failure. + +Useful for repairing worktrees created before this tool. Note that pushing needs +forge credentials; without push rights, `track` fails unless you use `--no-push`. ### `git trees list [--json]` (alias `ls`) @@ -164,6 +182,7 @@ git worktree prune | `TREES_HOST` | `github.com` | Host for `init` URLs | | `TREES_ORG` | *(unset)* | Default org; if unset, bare repo names are rejected | | `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` | ## Shell wrapper (optional) diff --git a/git-trees b/git-trees index e374a3a..120f355 100755 --- a/git-trees +++ b/git-trees @@ -5,20 +5,22 @@ # # git trees init [--host h] [--dir d] # git trees root [dir] [--agents] print project root; link .git if missing -# git trees add [base] [--print-path] -# git trees track [path] +# git trees add [base] [--print-path] [--no-push] +# git trees track [path] [--no-push] # git trees list [--json] (alias: ls) # # Env (all optional): # TREES_HOST default host for init (default: github.com) # TREES_ORG default org; if unset, bare repo names are rejected # TREES_AGENTS_TEMPLATE AGENTS.md seeded at the container root +# TREES_NO_PUSH non-empty: never create a branch on origin set -uo pipefail : "${TREES_HOST:=}" : "${TREES_ORG:=}" : "${TREES_AGENTS_TEMPLATE:=$HOME/.config/git-trees/AGENTS.md}" +: "${TREES_NO_PUSH:=}" # --- internals --------------------------------------------------------------- @@ -305,11 +307,12 @@ cmd_root() { # --- add --------------------------------------------------------------------- cmd_add() { - local br="" base="" print_path=0 root path dir owner + local br="" base="" print_path=0 no_push=0 root path dir owner while [ $# -gt 0 ]; do case "$1" in --print-path) print_path=1; shift ;; + --no-push) no_push=1; shift ;; -*) echo "git trees add: unknown option $1" >&2; return 1 ;; *) if [ -z "$br" ]; then br="$1" @@ -320,7 +323,10 @@ cmd_add() { esac done - [ -z "$br" ] && { echo "usage: git trees add [base] [--print-path]" >&2; return 1; } + [ -z "$br" ] && { + echo "usage: git trees add [base] [--print-path] [--no-push]" >&2 + return 1 + } case "$br" in .|..) @@ -378,7 +384,11 @@ cmd_add() { git worktree add --no-track -b "$br" "$dir" "$base" >&2 || return 1 fi - cmd_track "$dir" >&2 || return 1 + if [ "$no_push" -eq 1 ]; then + cmd_track "$dir" --no-push >&2 || return 1 + else + cmd_track "$dir" >&2 || return 1 + fi if [ "$print_path" -eq 1 ]; then echo "$dir" @@ -390,7 +400,26 @@ cmd_add() { # --- track ------------------------------------------------------------------- cmd_track() { - local path="${1:-.}" br + local path="" no_push=0 br + + # Global opt-out for setups (agent fleets, forks without push rights) that + # never want a branch created on the remote as a side effect. + if [ -n "$TREES_NO_PUSH" ]; then + no_push=1 + fi + + while [ $# -gt 0 ]; do + case "$1" in + --no-push) no_push=1; shift ;; + -*) echo "git trees track: unknown option $1" >&2; return 1 ;; + *) + if [ -z "$path" ]; then path="$1"; shift + else echo "git trees track: unexpected argument $1" >&2; return 1 + fi ;; + esac + done + : "${path:=.}" + br=$(git -C "$path" symbolic-ref --short HEAD 2>/dev/null) || { echo "git trees track: detached HEAD or not a worktree: $path" >&2 return 1 @@ -403,7 +432,13 @@ cmd_track() { if git -C "$path" ls-remote --exit-code --heads origin "$br" >/dev/null 2>&1; then git -C "$path" fetch origin "$br" \ && git -C "$path" branch --set-upstream-to="origin/$br" "$br" + elif [ "$no_push" -eq 1 ]; then + # Leaving the upstream unset is the point of --no-push, so this is success. + echo "git trees track: origin/$br does not exist — upstream not set (--no-push)" >&2 + echo " create it with: cd \"$path\" && git push -u origin HEAD" >&2 + return 0 else + # Creates the branch on the remote. See --no-push / TREES_NO_PUSH. git -C "$path" push -u origin HEAD fi } @@ -494,11 +529,17 @@ usage: git trees [args] init [--host h] [--dir d] create bare repo + worktree layout root [dir] [--agents] print project root; link .git if missing - add [base] [--print-path] create a worktree (sets upstream) - track [path] ensure branch has an upstream + add [base] [--print-path] [--no-push] + create a worktree (sets upstream) + track [path] [--no-push] ensure branch has an upstream list [--json] worktrees + branches without one -env (optional): TREES_ORG, TREES_HOST, TREES_AGENTS_TEMPLATE — see README +note: a branch that does not exist on origin is created there by `add`/`track` + via `git push -u origin HEAD`. Pass --no-push (or set TREES_NO_PUSH) to skip + that and leave the upstream unset. + +env (optional): TREES_ORG, TREES_HOST, TREES_AGENTS_TEMPLATE, TREES_NO_PUSH + — see README note: `add` prints the worktree path but cannot cd your shell. For that: trees() { From 8794b485e61087eb1fea68231cdd43c7ac249c9d Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 25 Jul 2026 15:01:12 -0400 Subject: [PATCH 4/8] Docs: quickstart that works, a concepts primer, and honest install docs (#38) --- AGENTS.md | 7 +++ AGENTS.md.template | 6 ++- README.md | 123 +++++++++++++++++++++++++++++++++++++++------ install.sh | 39 +++++++++++--- 4 files changed, 151 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0fd1dd6..03cec95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,13 @@ leaving both. bare-repo + worktrees layout. Read `README.md` first for behavior; this file covers constraints on changing it. +**This file is contributor guidance for `git-trees` itself** (`CLAUDE.md` is a +symlink to it). Do not confuse it with `AGENTS.md.template`, which is a +*product artifact*: `install.sh` copies it to `~/.config/git-trees/AGENTS.md`, +and `init` seeds it into the root of containers built with this tool, for an +entirely different audience. Nothing project-specific to this repo belongs in +the template. + ## Constraints **Single file.** `git-trees` must stay one self-contained script installable by diff --git a/AGENTS.md.template b/AGENTS.md.template index 99279af..88357ad 100644 --- a/AGENTS.md.template +++ b/AGENTS.md.template @@ -19,8 +19,10 @@ indexes, and HEADs. 1. Stay inside your assigned worktree. Do not `cd` into a sibling worktree or above the repo root. -2. Do not `git checkout` a different branch. To work on another branch, request a - new worktree. +2. Do not `git checkout` a different branch. To work on another branch, create + its worktree with `git trees add ` from the repo root — that gives it + its own directory and leaves yours untouched. If you cannot run that, request + the worktree instead. 3. Never touch `trees-bare.git/`. 4. Push with `git push -u origin HEAD` — upstream tracking may not be set. 5. Other agents may be working in sibling worktrees concurrently. Do not `git gc`, diff --git a/README.md b/README.md index 1ac9bc2..a820e14 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,30 @@ A `git` subcommand for managing a **bare repo + worktrees** layout. Pure git — no external CLI dependencies, no forge integration. -``` -git trees init brightdigit/some-repo +```bash +git trees init brightdigit/some-repo # creates the container cd some-repo -git trees add feature-x -git trees list +git trees add main # first worktree — your files live here +cd main +``` + +`init` creates the *container*: a shared object store plus a `.git` pointer, and +nothing else. `add` creates a *working copy*. Between the two, the container root +has no files checked out and `git status` there reports +`fatal: this operation must be run in a work tree` — that is expected, not a +broken install. + +From then on, one directory per branch: + +```bash +git trees add feature-x # sibling worktree, its own working copy +git trees list # every branch, with or without a worktree ``` +`add` prints the new path but cannot `cd` your shell — it runs in its own +process. The optional [`trees()` wrapper](#shell-wrapper-optional) below makes +`trees add feature-x` drop you straight into it. + ## Why Working with multiple concurrent branches (particularly with parallel coding @@ -24,39 +41,117 @@ support, but the built-in porcelain leaves gaps: `git-trees` covers those. It's deliberately small and readable — one bash file you can audit in a sitting. +## Concepts + +New to `git worktree`? Five terms cover everything below. + +- **Worktree** — a checked-out working copy: files on disk, its own index and + its own `HEAD`. Plain git gives you one per clone; `git worktree` lets one repo + have several, each on a different branch, all editable at once. +- **Bare store** (`trees-bare.git/`) — the repository's objects and refs with no + working copy attached. Every worktree shares this one store, which is why + adding a tenth worktree costs a checkout, not a tenth copy of your history. + Never modify it directly. +- **Container** — the directory holding the bare store and all the worktrees. It + is the thing `init` creates and what `git trees root` prints. It is *not* + itself a checkout. +- **Gitdir pointer** (`.git`) — a one-line file reading + `gitdir: ./trees-bare.git`. Without it, plain `git` commands run from the + container root would walk up and find some unrelated parent repo, or nothing. + With it, `git fetch` and friends work from the root even though no files are + checked out there. +- **Upstream** — the remote branch a local branch pushes to and compares + against (`origin/feature-x`). Setting it correctly on new branches is most of + what this tool does — see the `git trees add` section under **Commands**. + +`git-trees` is a thin layer that keeps this layout consistent. Everything it +does, you could do with `git worktree` by hand. + ## Layout -`git trees init` produces: +`init` and `add` produce different things — the container first, working copies +after: ``` -/ -├── trees-bare.git/ bare git repo — never modified directly -├── .git file containing "gitdir: ./trees-bare.git" -├── AGENTS.md seeded from template, if configured — root only -└── / one worktree per branch +After `git trees init`: After `git trees add feature-x`: + +some-repo/ some-repo/ +├── trees-bare.git/ ├── trees-bare.git/ +├── .git ├── .git +└── AGENTS.md ├── AGENTS.md + └── feature-x/ ← your working copy + no files checked out yet ``` +- `trees-bare.git/` — the bare store; never modified directly +- `.git` — a file containing `gitdir: ./trees-bare.git` +- `AGENTS.md` — seeded from a template if one is configured; container root only +- `feature-x/` — one directory per branch, created by `add`, never by `init` + Worktrees share a single object store but have independent working trees, indexes, and HEADs. Every worktree is a direct child of the container root, so a branch's `/` becomes a `-` in the directory name: `feature/x` checks out into `feature-x/` while the branch keeps its real name. +## Prerequisites + +- **Git.** +- **Bash** — not POSIX `sh`; the script uses process substitution. macOS's + built-in `/bin/bash` 3.2 is fine, as long as that is what `env bash` resolves + to. No Homebrew bash needed. +- **Network access** for `init` against a remote host, and **forge credentials** + for the clone. `add` and `track` also push by default, which needs push + rights — see the `git trees add` section under **Commands**. +- Optionally, an **agents template** at `~/.config/git-trees/AGENTS.md`, which + `./install.sh` puts there for you. + ## Install +**Recommended — clone and run the installer.** This is the full install: it +places the script *and* seeds the agents template that +`TREES_AGENTS_TEMPLATE` defaults to. + +```bash +git clone https://github.com/brightdigit/git-trees.git +cd git-trees && ./install.sh # → ~/.local/bin +./install.sh /usr/local/bin # or anywhere else +``` + +**Convenience — one-line curl.** Fetches only the script: + ```bash curl -o ~/.local/bin/git-trees \ https://raw.githubusercontent.com/brightdigit/git-trees/main/git-trees chmod +x ~/.local/bin/git-trees ``` -Or clone and run the installer (also seeds `~/.config/git-trees/AGENTS.md`): +Two things to know about this path. It does **not** create +`~/.config/git-trees/AGENTS.md`, so the documented default for +`TREES_AGENTS_TEMPLATE` points at a file you don't have — seeding is simply +skipped, which is harmless, but `init` will not write an `AGENTS.md`. And it +tracks `main`, which moves: what you get today is not necessarily what you got +last week. Use the installer if you want a known state. + +Either way, make sure the destination is on your `PATH`: + +```bash +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`. + +### Uninstall ```bash -git clone https://github.com/brightdigit/git-trees.git -cd git-trees && ./install.sh +./install.sh --uninstall # clears ~/.local/bin and /usr/local/bin +./install.sh --uninstall /opt/bin # or the prefix you installed to ``` -Anything on `PATH` named `git-trees` becomes `git trees`. Requires bash 4+. +Pass the same directory you installed to — without it, a custom-prefix install is +left behind. Uninstall does **not** remove `~/.config/git-trees/AGENTS.md`; it +says so, and you can delete it yourself. ## Configuration diff --git a/install.sh b/install.sh index ad7ce4d..f1bdd27 100755 --- a/install.sh +++ b/install.sh @@ -1,32 +1,55 @@ #!/usr/bin/env bash # install.sh — install git-trees onto PATH # -# ./install.sh install to ~/.local/bin -# ./install.sh /usr/local/bin install elsewhere -# ./install.sh --uninstall remove +# ./install.sh install to ~/.local/bin +# ./install.sh /usr/local/bin install elsewhere +# ./install.sh --uninstall remove from the two default locations +# ./install.sh --uninstall /opt/bin remove from the prefix you installed to set -uo pipefail SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEST="${1:-$HOME/.local/bin}" +CFG="$HOME/.config/git-trees" if [ "${1:-}" = "--uninstall" ]; then - for d in "$HOME/.local/bin" /usr/local/bin; do + # Honor the same DEST as install. Without an argument, sweep the two defaults; + # a custom-prefix install would otherwise be left behind silently. + if [ -n "${2:-}" ]; then + set -- "$2" + else + set -- "$HOME/.local/bin" /usr/local/bin + fi + + removed=0 + for d in "$@"; do if [ -e "$d/git-trees" ]; then - rm -f "$d/git-trees" && echo "removed $d/git-trees" + if rm -f "$d/git-trees"; then + echo "removed $d/git-trees" + removed=1 + fi fi done + + if [ "$removed" -eq 0 ]; then + echo "install.sh: no git-trees found in $*" >&2 + echo " if you installed elsewhere, pass it: ./install.sh --uninstall " >&2 + fi + + if [ -e "$CFG/AGENTS.md" ]; then + echo "left $CFG/AGENTS.md in place — remove it yourself if you want it gone" + fi exit 0 fi +DEST="${1:-$HOME/.local/bin}" + [ -f "$SRC/git-trees" ] || { echo "install.sh: git-trees not found in $SRC" >&2; exit 1; } mkdir -p "$DEST" || exit 1 install -m 0755 "$SRC/git-trees" "$DEST/git-trees" || exit 1 echo "installed $DEST/git-trees" -# AGENTS.md template -CFG="$HOME/.config/git-trees" +# AGENTS.md template — the curl install does not get this. if [ -f "$SRC/AGENTS.md.template" ] && [ ! -f "$CFG/AGENTS.md" ]; then mkdir -p "$CFG" cp "$SRC/AGENTS.md.template" "$CFG/AGENTS.md" From c58e26307eb1a16a50336d22ecf93f8786addf4e Mon Sep 17 00:00:00 2001 From: leogdion Date: Sat, 25 Jul 2026 15:09:47 -0400 Subject: [PATCH 5/8] CI: test the failure modes, not just the happy paths (#39) --- .github/workflows/ci.yml | 177 +-------------- AGENTS.md | 88 +++++--- tests/smoke.sh | 459 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 520 insertions(+), 204 deletions(-) create mode 100755 tests/smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a11558a..c9e390d 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 + run: bash -n git-trees && bash -n install.sh && bash -n tests/smoke.sh - name: Install ShellCheck run: | @@ -27,178 +27,7 @@ jobs: fi - name: ShellCheck - run: shellcheck -s bash git-trees install.sh + run: shellcheck -s bash git-trees install.sh tests/smoke.sh - name: Smoke tests - run: | - set -e - git config --global user.email ci@example.com - git config --global user.name CI - git config --global init.defaultBranch main - - mkdir -p /tmp/tt/origin && cd /tmp/tt/origin - git init -q -b main . - echo hi > a.txt && git add . && git commit -qm init - git branch feature-x - git branch slug/one # pre-existing slash branch, as a real repo has - - cd /tmp/tt && git clone -q --bare /tmp/tt/origin proj/trees-bare.git - echo "gitdir: ./trees-bare.git" > proj/.git - cd proj - git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' - git fetch -q origin - git remote set-head origin --auto >/dev/null - - T="$GITHUB_WORKSPACE/git-trees" - chmod +x "$T" - - bash "$T" help - - bash "$T" add feature-x - up=$(git -C feature-x rev-parse --abbrev-ref '@{upstream}') - [ "$up" = "origin/feature-x" ] || { echo "FAIL: upstream was $up"; exit 1; } - - # A slash branch checks out into a slugged directory; the ref keeps - # its slash. Uses a name of its own so it cannot claim feature-x above. - bash "$T" add slug/one - [ -d slug-one ] || { echo "FAIL: slug/one did not create slug-one"; exit 1; } - b=$(git -C slug-one symbolic-ref --short HEAD) - [ "$b" = "slug/one" ] || { echo "FAIL: branch was $b, expected slug/one"; exit 1; } - up=$(git -C slug-one rev-parse --abbrev-ref '@{upstream}') - [ "$up" = "origin/slug/one" ] || { echo "FAIL: upstream was $up"; exit 1; } - - # Two branches slugging to one directory: refuse, and name the owner. - if bash "$T" add slug-one 2>/tmp/slug-err; then - echo "FAIL: slug-one should collide with slug/one"; exit 1 - fi - grep -q "slug/one" /tmp/slug-err || { - echo "FAIL: collision message did not name the owning branch" - cat /tmp/slug-err; exit 1 - } - - # New slash branch: every component slugged, and --no-track still - # applies so it does not inherit origin/main. - bash "$T" add deep/new/branch - [ -d deep-new-branch ] || { echo "FAIL: deep/new/branch did not slug"; exit 1; } - up=$(git -C deep-new-branch rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none) - [ "$up" = "origin/deep/new/branch" ] || { - echo "FAIL: new slash branch upstream was $up"; exit 1 - } - - mkdir leftover-dir - if bash "$T" add leftover-dir 2>/tmp/collide-err; then - echo "FAIL: add should reject an existing directory"; exit 1 - fi - grep -q "directory already exists" /tmp/collide-err || { - echo "FAIL: missing collision message"; cat /tmp/collide-err; exit 1 - } - rmdir leftover-dir - - bash "$T" add brandnew - up=$(git -C brandnew rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none) - [ "$up" != "origin/main" ] || { echo "FAIL: new branch inherited origin/main"; exit 1; } - - p=$(bash "$T" add another --print-path 2>/dev/null) - [ -d "$p" ] || { echo "FAIL: --print-path returned '$p'"; exit 1; } - # stdout must be only the path (no banners mixed in). - # Note: $(...) strips one trailing newline, so wc -l is 0 for a single line. - case $p in *$'\n'*) echo "FAIL: --print-path stdout was multi-line: '$p'"; exit 1 ;; esac - - bash "$T" list - bash "$T" list --json | python3 -c 'import json,sys; json.load(sys.stdin)' - - # `clean` is not a command (#17). Assert it is *unrecognized* — a bare - # nonzero exit would also pass if it were dispatched and merely failed. - if bash "$T" clean 2>/tmp/clean-err; then - echo "FAIL: clean should be an unknown command"; exit 1 - fi - grep -q "unknown command 'clean'" /tmp/clean-err || { - echo "FAIL: clean failed, but not as an unknown command" - cat /tmp/clean-err; exit 1 - } - # Whole word: the usage text legitimately contains "clean/dirty". - if bash "$T" help 2>&1 | grep -qw clean; then - echo "FAIL: help still advertises clean"; exit 1 - fi - - # --- #25: --no-push must not create the branch on origin --- - # Remote branch absent: nothing pushed, upstream unset, exit 0. - bash "$T" add nopush --no-push 2>/tmp/nopush-err - if git ls-remote --exit-code --heads origin nopush >/dev/null 2>&1; then - echo "FAIL: --no-push created origin/nopush"; exit 1 - fi - if git -C nopush rev-parse --abbrev-ref '@{upstream}' >/dev/null 2>&1; then - echo "FAIL: --no-push set an upstream"; exit 1 - fi - grep -q 'git push -u origin HEAD' /tmp/nopush-err || { - echo "FAIL: --no-push did not print the push command"; cat /tmp/nopush-err; exit 1 - } - - # Remote branch present: --no-push must still set the upstream. - git push -q origin feature-x:nopush-existing - git fetch -q origin - bash "$T" add nopush-existing --no-push >/dev/null 2>&1 - up=$(git -C nopush-existing rev-parse --abbrev-ref '@{upstream}') - [ "$up" = "origin/nopush-existing" ] || { - echo "FAIL: --no-push upstream was $up"; exit 1 - } - - # TREES_NO_PUSH does the same without the flag. - TREES_NO_PUSH=1 bash "$T" add envnopush >/dev/null 2>&1 - if git ls-remote --exit-code --heads origin envnopush >/dev/null 2>&1; then - echo "FAIL: TREES_NO_PUSH created origin/envnopush"; exit 1 - fi - - # Outside a repo → nonzero exit - outside=$(mktemp -d) - if (cd "$outside" && bash "$T" list) >/dev/null 2>&1; then - echo "FAIL: expected nonzero exit outside a repo"; exit 1 - fi - rmdir "$outside" - - # --- #18: an option missing its value must fail, not spin forever --- - # Bounded, because a regression hangs the runner until the job timeout. - for opt in --host --dir; do - bash "$T" init "$opt" >/dev/null 2>&1 & - pid=$! - hung=1 - for _ in 1 2 3 4 5 6; do - if ! kill -0 $pid 2>/dev/null; then hung=0; break; fi - sleep 0.5 - done - if [ "$hung" -eq 1 ]; then - kill -9 $pid 2>/dev/null || true - echo "FAIL: 'init $opt' with no value hung"; exit 1 - fi - if wait $pid; then - echo "FAIL: 'init $opt' with no value should exit nonzero"; exit 1 - fi - done - - # --- #31: invalid branch names report the branch, not a collision --- - for bad in . ..; do - if bash "$T" add "$bad" 2>/tmp/badname-err; then - echo "FAIL: add '$bad' should fail"; exit 1 - fi - if grep -q "directory already exists" /tmp/badname-err; then - echo "FAIL: add '$bad' reported a directory collision"; cat /tmp/badname-err; exit 1 - fi - done - if bash "$T" add 'has space' 2>/tmp/space-err; then - echo "FAIL: add 'has space' should fail"; exit 1 - fi - # Must be rejected before git starts building the worktree. - if grep -q 'Preparing worktree' /tmp/space-err; then - echo "FAIL: 'has space' rejected only after worktree add ran"; cat /tmp/space-err; exit 1 - fi - - # --- #23: --json must escape backslashes, not just quotes --- - jf=$(mktemp -d)/'back\slash "quoted"' - git worktree add -b jsonesc "$jf" origin/main >/dev/null 2>&1 - bash "$T" list --json | python3 -c 'import json,sys; json.load(sys.stdin)' || { - echo "FAIL: list --json invalid with a backslash in a path"; exit 1 - } - git worktree remove --force "$jf" - git branch -D jsonesc >/dev/null - - echo "smoke tests passed" + run: tests/smoke.sh ./git-trees diff --git a/AGENTS.md b/AGENTS.md index 03cec95..98c0620 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,40 +84,68 @@ repo. ## Testing -No test framework. Verify by building a throwaway repo pair: +Run the suite: ```bash -rm -rf /tmp/tt && mkdir -p /tmp/tt/origin && cd /tmp/tt/origin -git init -q -b main . && git config user.email t@t && git config user.name t -echo hi > a.txt && git add . && git commit -qm init -git branch feature-x - -cd /tmp/tt && git clone -q --bare /tmp/tt/origin proj/trees-bare.git -echo "gitdir: ./trees-bare.git" > proj/.git -cd proj -git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' -git config user.email t@t && git config user.name t -git fetch -q origin && git remote set-head origin --auto >/dev/null +tests/smoke.sh # tests ./git-trees +tests/smoke.sh /path/to/git-trees ``` -Then exercise the paths. Things worth checking after any change: - -- `add feature/x` — creates `feature-x/`; the branch keeps its slash and its - upstream is `origin/feature/x` -- `add feature-x` when `feature/x` already owns `feature-x/` — must fail, and the - error must name `feature/x` -- `add feature-x` — remote branch exists; upstream must be `origin/feature-x` -- `add brandnew` — no remote branch; upstream must be `origin/brandnew`, **not** - `origin/main`; failed track/push must exit nonzero -- `add` into an existing directory — clear collision error, nonzero exit -- `add x --print-path` — stdout must be *only* the path -- `list --json` — valid JSON, includes branches with no worktree -- `git trees` outside a repo — clean error, nonzero exit - -Automated coverage lives in `.github/workflows/ci.yml` (ubuntu + macOS). Also run -`bash -n git-trees` for syntax and `shellcheck git-trees` if available. - -`init` needs network and is not covered by the above. +It builds its own fixtures under `mktemp -d` from `file://` remotes — no +network, no `jq`, nothing written outside the temp directory — and runs every +check to completion rather than stopping at the first failure. CI runs exactly +this, on `ubuntu-latest` and `macos-latest` +(`.github/workflows/ci.yml`), plus `bash -n` and +`shellcheck -s bash git-trees install.sh tests/smoke.sh`. + +`tests/smoke.sh` is a test harness, not part of the tool. The single-file +constraint above governs `git-trees`; it does not forbid a test script. + +What the suite covers: + +- **help/dispatch** — `help`, `--help`, unknown command, and that the removed + `clean` is *unrecognized* rather than merely unsuccessful +- **outside a repo** — `list` and `root` both exit nonzero +- **init** — happy path over `file://`; the gitdir pointer, bare store, seeded + `AGENTS.md`, and `origin/*` refs it must produce; the container root having no + work tree; refusal on an existing directory; **rollback when the post-clone + fetch fails**, and that a retry then works; `--host`/`--dir` with a missing + value exiting promptly rather than hanging +- **root** — printing the container, adopting a bare container by writing the + `.git` pointer under the store's own name, rejecting a plain directory +- **root --agents** — seeds when absent; never overwrites a regular file; treats + a **broken symlink** as occupied; no-ops without a template; keeps stdout to + the path alone +- **add** — `.`, `..` and `'has space'` reported as bad branch names rather than + directory collisions, and rejected before `Preparing worktree`; upstream + 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 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 + directory is refused with the owning branch named +- **track** — idempotent on an already-tracked worktree; fails on a non-worktree +- **list** — text output, branches with no worktree shown as `(none)`, + `--json` parsing, and a worktree whose path contains `\` and `"` round-tripping + through `json.load` + +Bug-shaped assertions carry a comment saying which bug they pin. Two are worth +knowing about: + +- The missing-option-value checks are **bounded** (`run_bounded`). The + regression is an infinite loop, so a plain assertion would hang the runner + until the job timeout instead of failing. `timeout` is not installed on macOS, + hence the background-PID and `kill -0` dance. +- `add brandnew` asserts the upstream is **exactly** `origin/brandnew`. The + older `!= origin/main` form also passed on an empty or otherwise wrong + upstream, which is the same bug wearing a different hat. + +ShellCheck is not a safety net here — it passes clean on code containing both +the argument-parsing hang and the `_seed_agents` precedence bug. Linting is not +coverage. + +Not covered: `init` against a real network host. ## Style diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100755 index 0000000..c6ee05f --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,459 @@ +#!/usr/bin/env bash +# tests/smoke.sh — end-to-end smoke tests for git-trees. +# +# tests/smoke.sh [/path/to/git-trees] +# +# Runs the same suite CI runs. Everything happens in a throwaway directory built +# from `file://` fixtures: no network, no jq, no state outside $TMP. +# +# Style matches git-trees itself: `set -uo pipefail` but deliberately not `-e`, +# so every check runs and the summary lists all failures rather than the first. + +set -uo pipefail + +T="${1:-$(cd "$(dirname "$0")/.." && pwd)/git-trees}" +[ -f "$T" ] || { echo "smoke: no git-trees at $T" >&2; exit 1; } +T=$(cd "$(dirname "$T")" && pwd)/$(basename "$T") + +REAL_GIT=$(command -v git) || { echo "smoke: git not on PATH" >&2; exit 1; } +command -v python3 >/dev/null || { echo "smoke: python3 not on PATH" >&2; exit 1; } + +TMP=$(mktemp -d) +trap 'chmod -R u+w "$TMP" 2>/dev/null; rm -rf "$TMP"' EXIT + +FAILED=0 + +section() { printf '\n== %s ==\n' "$1"; } +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n' "$1" >&2; FAILED=1; } + +# `env -C` is GNU-only; macOS env has no such flag. Use a subshell instead. +# Called indirectly, as the command argument to the assert_* helpers — hence the +# suppressions. ShellCheck 0.9 reports that as SC2317 and 0.11 as SC2329, and CI +# runs whichever the runner image ships, so both codes are listed. +# shellcheck disable=SC2317,SC2329 +in_dir() { local d="$1"; shift; ( cd "$d" && "$@" ); } + +assert_eq() { # assert_eq