Skip to content
Open
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
56 changes: 44 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,24 +221,56 @@ These are the things worth knowing up front; the rest is discoverable from
main worktree. It refuses the primary worktree and honors the same `--force`
guard.
- **Bulk-clean stale branches.** `wt prune --all` (`-a`) deletes every local branch
you can drop without losing a commit, meaning each of its commits is also on a
remote or on the default branch. It also removes worktrees whose work is
finished. You can pick the modes one at a time:
you can drop without losing work. Each of its commits must be on a remote or on
the default branch, or its changes must already be in the default branch under
other commits (a squash or rebase merge). It also removes worktrees whose work is
finished, including detached-HEAD ones. You can pick the modes one at a time:
- `--merged`: worktrees and branches merged into the default branch, either the
local copy or `origin`'s.
local copy or `origin`'s. A branch counts as merged when it is an ancestor of
the default branch, or when merging it would change nothing. That covers
squash merges, rebase merges, work split across several PRs, and branches made
only of merges. A branch whose own commits add up to no change is never
merged by content. Custom merge drivers from `.gitattributes` (such as
`merge=ours`) are disabled for this check, so a file one governs reads as not
merged. `merge=union` files and `merge.default` are merged as plain text.
The content check needs git ≥ 2.38. On older git only ancestry counts.
- `--gone`: worktrees and branches whose upstream was deleted, plus any missing
worktrees.
- `--pushed`: branches (never worktrees) whose every commit is already on a
remote, such as an open PR's branch.
- `--pushed`: branches without a worktree, and detached worktrees, whose every
commit is already on a remote, such as an open PR's branch. A worktree with a
branch checked out counts as active work and is left alone.

Every mode deletes matching **local branches that have no worktree**, so a pile of
old feature branches gets cleaned up too. Under `--all`, the branch of a removed
worktree goes with it. A branch holding commits that exist nowhere else is
skipped unless you pass `--force`. This includes a `--gone` branch that was never
merged. The modes that read remote state (`--gone`, `--pushed`, `--all`) run
`git fetch --all --prune` first, so "still on the remote" means the remote now.
Pass `--no-fetch` to trust the last fetch. Preview with `--dry-run`. The current
and default branches are never touched.
worktree goes with it. The modes that read remote state (`--gone`, `--pushed`,
`--all`) run `git fetch --all --prune` first, so "still on the remote" means the
remote now. Pass `--no-fetch` to trust the last fetch. Preview with `--dry-run`.

Anything that qualifies but is kept is listed as `skipping <name>: <why>`:
- A worktree with uncommitted changes, or a branch or detached worktree whose
work exists nowhere else, is skipped unless you pass `--force`. A missing
detached worktree counts, because its registration holds its only HEAD.
Untracked files always count as uncommitted changes here, whatever
`remove.untracked_blocks` says. Ignored files (build output) do not.
- A locked worktree is skipped unless you pass `--locked`. Agent harnesses often
lock their worktrees.
- These are never removed: the worktree you're in, a worktree in the middle of a
rebase, merge, cherry-pick, revert, or bisect (and the branch it will return
to), and the current and default branches. If a worktree's git directory
cannot be found, prune cannot tell which branch it holds, so no branch
without a worktree is deleted on that run.

Prune removes only what it lists. It no longer runs a blanket
`git worktree prune`, so a missing worktree that no mode selects stays
registered.

A detached worktree is judged by where its HEAD is now. If you committed in
one and then moved HEAD back (say, `git checkout --detach main`), it qualifies,
and removing it deletes its HEAD reflog: commits reachable only from that
reflog are lost. Branch or tag them first.

`--json` prints one line per item that would be removed, each with a `reason`
(`merged`, `merged by content`, `upstream gone`, `missing`, or `pushed`).

## Using wt as a library

Expand Down
27 changes: 20 additions & 7 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,28 +354,37 @@ pub(crate) struct DropArgs {
/// Arguments for `wt prune`.
#[derive(Debug, Args)]
pub(crate) struct PruneArgs {
/// Include worktrees and branches merged into the default branch (local or
/// its `origin` tracking ref).
/// Include worktrees (detached ones too) and branches merged into the default
/// branch (local or its `origin` tracking ref), by ancestry or by content — a
/// squash or rebase merge counts.
#[arg(long)]
pub(crate) merged: bool,
/// Include worktrees and branches whose upstream is gone, and missing worktrees.
#[arg(long)]
pub(crate) gone: bool,
/// Include branches (not worktrees) whose every commit is on a remote or the
/// default branch.
/// Include branches without a worktree, and detached worktrees, whose every
/// commit is on a remote or the default branch.
#[arg(long)]
pub(crate) pushed: bool,
/// Include everything deletable without losing a commit: merged and gone
/// worktrees, plus every merged, pushed, or gone branch whose commits survive.
/// Include everything deletable without losing work: merged, gone, and pushed
/// candidates, where a branch counts as safe when its commits are on a remote
/// or its changes are already in the default branch.
#[arg(short = 'a', long)]
pub(crate) all: bool,
/// Also remove locked worktrees (`git worktree remove --force --force`).
/// Without it they are listed and skipped.
#[arg(long)]
pub(crate) locked: bool,
/// Trust the last fetch instead of running `git fetch --all --prune` first.
#[arg(long = "no-fetch")]
pub(crate) no_fetch: bool,
/// Report candidates without removing anything.
#[arg(long = "dry-run")]
pub(crate) dry_run: bool,
/// Include dirty worktrees and force-delete unmerged branches (implies `--yes`).
/// Include dirty worktrees, and remove branches and detached worktrees whose
/// work exists nowhere else, orphaning those commits (implies `--yes`).
/// Never overrides a lock or an in-progress rebase, merge, cherry-pick,
/// revert, or bisect.
#[arg(long)]
pub(crate) force: bool,
}
Expand Down Expand Up @@ -884,6 +893,10 @@ mod tests {
assert!(!prune(&["prune", "--merged"]).needs_fetch());
assert!(!prune(&["prune", "--all", "--no-fetch"]).needs_fetch());
assert!(!prune(&["prune", "--pushed", "--no-fetch"]).needs_fetch());
// `--locked` is its own opt-in, separate from `--force`.
let locked = prune(&["prune", "-a", "--locked"]);
assert!(locked.locked && !locked.force);
assert!(!prune(&["prune", "-a"]).locked);
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use crate::worktree::{CreatedWorktree, HookOutcome, Workspace, WorkspaceParts, b

// Path/template helpers shared with the service layer, re-exported so command
// modules keep their historical import paths.
pub(crate) use crate::worktree::{resolve_target, rollback_worktree, run_best_effort, same_path};
pub(crate) use crate::worktree::{resolve_target, rollback_worktree, same_path};

/// A discovered repository plus its resolved configuration, set up once per
/// repo-scoped command.
Expand Down
Loading
Loading