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
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,13 +220,25 @@ These are the things worth knowing up front; the rest is discoverable from
current directory (from any depth), keeps the branch, and `cd`s you back to the
main worktree. It refuses the primary worktree and honors the same `--force`
guard.
- **Bulk-clean stale branches.** `wt prune --merged` removes worktrees whose branch
is merged into the default branch, and `wt prune --gone` removes worktrees whose
upstream was deleted (plus any missing worktrees). Both also delete matching
**local branches that no longer have a worktree** — so a repo left with a pile of
merged feature branches gets cleaned up too. Preview with `--dry-run`. A `--gone`
branch that isn't also merged may hold unmerged commits, so it is skipped unless
you pass `--force`. The current and default branches are never touched.
- **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:
- `--merged`: worktrees and branches merged into the default branch, either the
local copy or `origin`'s.
- `--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.

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.

## Using wt as a library

Expand Down
81 changes: 79 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,12 +354,24 @@ pub(crate) struct DropArgs {
/// Arguments for `wt prune`.
#[derive(Debug, Args)]
pub(crate) struct PruneArgs {
/// Include worktrees whose branch is merged into the default branch.
/// Include worktrees and branches merged into the default branch (local or
/// its `origin` tracking ref).
#[arg(long)]
pub(crate) merged: bool,
/// Include worktrees whose upstream is gone, and missing worktrees.
/// 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.
#[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.
#[arg(short = 'a', long)]
pub(crate) all: 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,
Expand All @@ -368,6 +380,29 @@ pub(crate) struct PruneArgs {
pub(crate) force: bool,
}

impl PruneArgs {
/// Whether merged candidates are selected (`--merged` or `--all`).
pub(crate) fn includes_merged(&self) -> bool {
self.merged || self.all
}

/// Whether gone candidates are selected (`--gone` or `--all`).
pub(crate) fn includes_gone(&self) -> bool {
self.gone || self.all
}

/// Whether pushed branch candidates are selected (`--pushed` or `--all`).
pub(crate) fn includes_pushed(&self) -> bool {
self.pushed || self.all
}

/// Whether prune must fetch before selecting: a mode that reads remote state
/// is on (`--gone`, `--pushed`, or `--all`) and `--no-fetch` is not.
pub(crate) fn needs_fetch(&self) -> bool {
!self.no_fetch && (self.gone || self.pushed || self.all)
}
}

/// Arguments for `wt pr`.
#[derive(Debug, Args)]
pub(crate) struct PrArgs {
Expand Down Expand Up @@ -809,6 +844,48 @@ mod tests {
assert!(parse(&["drop", "somequery"]).is_err());
}

#[test]
fn prune_all_parses_short_and_long() {
for flag in ["-a", "--all"] {
match parse(&["prune", flag]).unwrap().command {
Some(Command::Prune(a)) => {
assert!(a.all);
assert!(a.includes_merged() && a.includes_gone() && a.includes_pushed());
assert!(a.needs_fetch());
}
_ => panic!("expected prune for {flag}"),
}
}
// `--all` alongside a mode flag is redundant, not an error.
assert!(parse(&["prune", "-a", "--merged"]).is_ok());
// A single mode flag selects only that mode.
match parse(&["prune", "--gone"]).unwrap().command {
Some(Command::Prune(a)) => {
assert!(!a.includes_merged());
assert!(a.includes_gone());
assert!(!a.includes_pushed());
}
_ => panic!("expected prune"),
}
}

#[test]
fn prune_pushed_and_no_fetch_parse() {
let prune = |args: &[&str]| match parse(args).unwrap().command {
Some(Command::Prune(a)) => a,
_ => panic!("expected prune for {args:?}"),
};
let pushed = prune(&["prune", "--pushed"]);
assert!(pushed.includes_pushed());
assert!(!pushed.includes_merged() && !pushed.includes_gone());
// Remote-reading modes fetch unless told not to; `--merged` stays offline.
assert!(pushed.needs_fetch());
assert!(prune(&["prune", "--gone"]).needs_fetch());
assert!(!prune(&["prune", "--merged"]).needs_fetch());
assert!(!prune(&["prune", "--all", "--no-fetch"]).needs_fetch());
assert!(!prune(&["prune", "--pushed", "--no-fetch"]).needs_fetch());
}

#[test]
fn pr_forms_parse_distinctly() {
// `pr list` -> the list sub-form.
Expand Down
Loading
Loading