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