Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
61 changes: 61 additions & 0 deletions git-trees
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# git trees list [--json] (alias: ls)
# git trees rm <branch|path> [--apply]
# git trees clean [--merged|--gone] [--apply]
# git trees prune [--dry-run]
#
# Env (all optional):
# TREES_HOST default host for init (default: github.com)
Expand Down Expand Up @@ -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/<name>/ 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() {
Expand All @@ -764,6 +823,7 @@ usage: git trees <command> [args]
list [--json] worktrees + branches without one
rm <branch|path> [--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



Expand Down Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ assert_contains "help lists add" "$out" "add <branch>"
assert_contains "help lists list" "$out" "list [--json]"
assert_contains "help lists rm" "$out" "rm <branch|path>"
assert_contains "help lists clean" "$out" "clean [--merged|--gone]"
assert_contains "help lists prune" "$out" "prune [--dry-run]"


section "outside a repo"
Expand Down Expand Up @@ -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
Expand Down
Loading