From 6cb61397555879067ad3f5770728a9297fd5a0ac Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 21 Aug 2026 11:56:41 -0400 Subject: [PATCH] Add `git trees prune` subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces `git worktree prune` as a user-facing command. When a worktree directory is deleted by hand (`rm -rf feature-x`) instead of through `git trees rm`, git's administrative entry lingers in `git worktree list` and keeps the branch locked against a fresh checkout. `prune` acts immediately with only a `--dry-run` preview, deliberately exempt from the `--apply` rule: it unlinks metadata only for directories already gone from disk, and leaves the branch untouched, so there is no work to lose. AGENTS.md is amended to scope that rule to operations that can lose work and to name prune as the exception. The report is captured before acting, from stderr (`git worktree prune --verbose` writes there, not stdout), and parsed with a whole-line regex so a name containing a space is not truncated by field splitting. `cmd_clean` is left unmodified — it already prunes internally. Closes #55 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- README.md | 18 +++++++++++++++ git-trees | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ tests/smoke.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4e6e039..9d518de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ consequence is that `feature/x` and `feature-x` compete for one directory; the directory (`_branch_at`). Do not "fix" that by inventing a suffixed variant: a directory whose name the user cannot predict is worse than an error. -**Nothing destructive without `--apply`.** `rm` and `clean` report by default and modify state only when `--apply` is explicitly passed. Local branch deletions use `git branch -d` (falling back to `-D` on `clean` once confirmed gone/merged, or on `rm` when `--apply` is passed), and worktree directory removals route through `TREES_RM_CMD` when configured (defaulting to `git worktree remove`). +**Nothing that can lose work without `--apply`.** `rm` and `clean` report by default and modify state only when `--apply` is explicitly passed. Local branch deletions use `git branch -d` (falling back to `-D` on `clean` once confirmed gone/merged, or on `rm` when `--apply` is passed), and worktree directory removals route through `TREES_RM_CMD` when configured (defaulting to `git worktree remove`). `prune` is the deliberate exception: it only unlinks metadata for worktree directories already gone from disk, leaving the branch intact, so there is nothing to lose and it acts immediately with only a `--dry-run` preview. **`TREES_RM_CMD` is the one place the safety net comes off.** `git worktree remove` refuses a worktree with uncommitted changes or untracked files; a custom diff --git a/README.md b/README.md index e5588cf..89421aa 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,24 @@ By default (without `--apply`), `clean` operates in dry-run mode and prints matc on stderr, and exits nonzero if any of them did. +### `git trees prune [--dry-run]` + +Drops git's administrative entries for worktree directories that are no longer on disk. + +When a worktree directory is deleted by hand (`rm -rf feature-x`) instead of through `git trees rm`, git keeps its bookkeeping under the bare store. The stale entry keeps showing up in `git worktree list` and holds the branch locked against a fresh checkout. `prune` clears those entries. + +Stale worktree names are printed to stdout, one per line; git's reason for each goes to stderr. With nothing to prune it prints a notice on stderr and exits 0. + +Pass `--dry-run` to list what would be dropped without touching anything. + +> **Unlike `rm` and `clean`, `prune` acts immediately — there is no `--apply`.** It only removes metadata for directories that are *already gone*; a worktree still on disk is never a candidate, and the branch a pruned entry held is left alone. There is no work to lose. + +```bash +git trees prune --dry-run # list stale entries, change nothing +git trees prune # drop them +``` + + ## Removing worktrees diff --git a/git-trees b/git-trees index 786496a..e2cd8af 100755 --- a/git-trees +++ b/git-trees @@ -10,6 +10,7 @@ # git trees list [--json] (alias: ls) # git trees rm [--apply] # git trees clean [--merged|--gone] [--apply] +# git trees prune [--dry-run] # # Env (all optional): # TREES_HOST default host for init (default: github.com) @@ -750,6 +751,64 @@ cmd_clean() { +# --- prune ------------------------------------------------------------------- + +cmd_prune() { + local dry=0 report names failed=0 + + while [ $# -gt 0 ]; do + case "$1" in + --dry-run) dry=1; shift ;; + -*) echo "git trees prune: unknown option $1" >&2; return 1 ;; + *) echo "usage: git trees prune [--dry-run]" >&2; return 1 ;; + esac + done + + _root >/dev/null || { echo "git trees prune: not in a git repo" >&2; return 1; } + + # Exempt from the "nothing destructive without --apply" rule on purpose: this + # only unlinks $GIT_COMMON_DIR/worktrees// metadata for directories that + # are ALREADY GONE from disk. A directory still present is never a candidate, + # and the branch the entry held is left untouched — there is no work to lose. + # Gating it behind --apply would make the common case a no-op plus a nag. + # + # 2>&1 is required: `git worktree prune --verbose` writes its report to stderr, + # so a plain command substitution captures nothing. Capture before acting so + # the list survives a prune that fails partway, and so --dry-run can report it. + report=$(git worktree prune --dry-run --verbose 2>&1) + + if [ -z "$report" ]; then + echo "git trees prune: nothing to prune" >&2 + return 0 + fi + + # Whole-line match rather than $2: a worktree name may contain a space, which + # field splitting would truncate. + names=$(printf '%s\n' "$report" | awk ' + /^Removing worktrees\// { + n = $0 + sub(/^Removing worktrees\//, "", n) + sub(/: .*$/, "", n) + print n + }') + + [ -n "$names" ] && printf '%s\n' "$names" + printf '%s\n' "$report" >&2 + + if [ "$dry" -eq 1 ]; then + echo "(dry run — no metadata was removed)" >&2 + return 0 + fi + + if ! git worktree prune; then + echo "git trees prune: git worktree prune failed" >&2 + failed=1 + fi + + return "$failed" +} + + # --- usage / dispatch -------------------------------------------------------- usage() { @@ -764,6 +823,7 @@ usage: git trees [args] list [--json] worktrees + branches without one rm [--apply] remove worktree and delete branch clean [--merged|--gone] [--apply] report/remove merged or gone branches + prune [--dry-run] drop metadata for deleted worktree dirs @@ -796,6 +856,7 @@ main() { list|ls) cmd_list "$@" ;; rm) cmd_rm "$@" ;; clean) cmd_clean "$@" ;; + prune) cmd_prune "$@" ;; help|--help|-h) usage; return 0 ;; *) echo "git trees: unknown command '$cmd'" >&2; usage; return 1 ;; esac diff --git a/tests/smoke.sh b/tests/smoke.sh index 63d4f59..90161e4 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -144,6 +144,7 @@ assert_contains "help lists add" "$out" "add " assert_contains "help lists list" "$out" "list [--json]" assert_contains "help lists rm" "$out" "rm " assert_contains "help lists clean" "$out" "clean [--merged|--gone]" +assert_contains "help lists prune" "$out" "prune [--dry-run]" section "outside a repo" @@ -550,6 +551,62 @@ assert_fail "rm with no argument" bash "$T" rm assert_fail "rm with nonexistent target" bash "$T" rm nonexistent +# --- prune ------------------------------------------------------------------- + +# Fixtures here must not mutate the shared $ORIGIN — everything stays inside +# this container, so the section is safe to run before clean. +section "prune" +PR_C=$(new_container prune-c) +cd "$PR_C" || exit 1 + +# A container with every worktree present has nothing to prune. +out=$(bash "$T" prune 2>/dev/null) +assert_eq "prune on a clean container prints nothing to stdout" "$out" "" +assert_ok "prune on a clean container exits 0" bash "$T" prune +out=$(bash "$T" prune 2>&1) +assert_contains "prune reports nothing to prune on stderr" "$out" "nothing to prune" + +# Delete a worktree directory behind git's back, the way a user would. +assert_ok "create worktree to prune" bash "$T" add prune-target --no-push +assert_ok "prune target directory exists" test -d prune-target +rm -rf prune-target +assert_ok "stale entry still registered before prune" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-target" + +out=$(bash "$T" prune --dry-run 2>/dev/null) +assert_eq "dry run names the stale worktree on stdout" "$out" "prune-target" +assert_ok "dry run leaves the metadata intact" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-target" +out=$(bash "$T" prune --dry-run 2>&1) +assert_contains "dry run says it was a dry run" "$out" "dry run" + +out=$(bash "$T" prune 2>/dev/null) +assert_eq "prune names the stale worktree on stdout" "$out" "prune-target" +assert_fail "prune removed the stale metadata" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-target" +# The branch is the whole reason prune is safe without --apply: it survives. +assert_ok "prune left the branch alone" \ + git show-ref --verify --quiet refs/heads/prune-target +assert_not_contains "pruned worktree is gone from git worktree list" \ + "$(git worktree list)" "prune-target" + +# Idempotent: a second run finds nothing and still succeeds. +assert_ok "prune is idempotent" bash "$T" prune +out=$(bash "$T" prune 2>/dev/null) +assert_eq "second prune prints nothing to stdout" "$out" "" + +# A worktree still on disk is never a candidate. +assert_ok "create a live worktree" bash "$T" add prune-live --no-push +assert_ok "prune with a live worktree exits 0" bash "$T" prune +assert_ok "prune left the live worktree directory" test -d prune-live +assert_ok "prune left the live worktree registered" \ + test -d "$PR_C/trees-bare.git/worktrees/prune-live" + +assert_fail "prune unknown option" bash "$T" prune --nope +assert_fail "prune rejects a positional argument" bash "$T" prune extra +assert_fail "prune outside a repo" in_dir "$TMP/plain" bash "$T" prune + + # --- clean ------------------------------------------------------------------- # KEEP THIS SECTION LAST. Its fixtures mutate the shared $ORIGIN — deleting a