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/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/.github/workflows/homebrew-tap.yml b/.github/workflows/homebrew-tap.yml new file mode 100644 index 0000000..9d7c792 --- /dev/null +++ b/.github/workflows/homebrew-tap.yml @@ -0,0 +1,127 @@ +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 + +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 + # 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: | + 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 + # 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="$INPUT_TAG" ;; + release) tag="$RELEASE_TAG" ;; + *) + 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[0-9]*) ;; + *) + 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 + echo "tag=$tag" >> "$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: | + 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" + + # 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 "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 $TAG; nothing to commit" + else + git commit -m "homebrew: git-trees $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/AGENTS.md b/AGENTS.md index 4e6e039..310f7e2 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 @@ -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 — @@ -81,6 +96,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 +159,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 @@ -139,7 +173,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 @@ -150,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 e5588cf..d51941a 100644 --- a/README.md +++ b/README.md @@ -119,25 +119,46 @@ 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. +**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 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`: ```bash @@ -145,12 +166,61 @@ 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 + +`install.sh` copies both completion files to `~/.config/git-trees/completions/` +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: + +```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`): +Every variable in [**Environment**](#environment) is optional. Add to +`~/.zshrc` (or `~/.bashrc`): ```zsh export TREES_ORG=your-org @@ -212,6 +282,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 @@ -273,6 +348,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-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 +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. @@ -289,6 +418,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 @@ -322,6 +469,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/completions/_git-trees b/completions/_git-trees new file mode 100644 index 0000000..7915d1a --- /dev/null +++ b/completions/_git-trees @@ -0,0 +1,125 @@ +#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` 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() { + 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/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..bb18898 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,104 @@ +# 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 +``` + +## 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 786496a..eccbd1d 100755 --- a/git-trees +++ b/git-trees @@ -9,7 +9,9 @@ # 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] # # Env (all optional): # TREES_HOST default host for init (default: github.com) @@ -139,11 +141,54 @@ _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" } +# 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 @@ -377,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 @@ -449,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 @@ -670,6 +734,136 @@ cmd_rm() { } +# --- sync -------------------------------------------------------------------- + +# 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="" + + 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) + if [ -n "$path" ] \ + && ! git worktree list --porcelain | grep -qxF "worktree $path"; then + path="" + fi + 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() { @@ -750,6 +944,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() { @@ -763,7 +1015,10 @@ 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 @@ -795,7 +1050,9 @@ 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 ;; *) echo "git trees: unknown command '$cmd'" >&2; usage; return 1 ;; esac 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..8bac26f --- /dev/null +++ b/homebrew-tap/Formula/git-trees.rb @@ -0,0 +1,42 @@ +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" + # 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. + 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 point the + variable at the bundled template from your shell rc: + + export TREES_AGENTS_TEMPLATE=#{pkgshare}/AGENTS.md.template + + 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: + + mkdir -p ~/.config/git-trees + cp #{pkgshare}/AGENTS.md.template ~/.config/git-trees/AGENTS.md + 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. diff --git a/install.sh b/install.sh index 98363a4..311e3cf 100755 --- a/install.sh +++ b/install.sh @@ -3,27 +3,82 @@ # # ./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)" 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 +87,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 63d4f59..c005092 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -144,6 +144,8 @@ 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]" section "outside a repo" @@ -361,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 @@ -480,12 +532,253 @@ 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" + +# 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 ---------------------------------------------------------------------- @@ -550,6 +843,207 @@ 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 + +# 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 ------------------------------------------------------------------- + +# 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