From 64921d5809435606e1b76ba78a11e2c75ea9eb24 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 9 Sep 2026 12:37:09 -0400 Subject: [PATCH 1/3] feat(lib): add repo-level branch signal helpers --- git-workon-lib/src/branch.rs | 128 ++++++++++++++ git-workon-lib/src/config.rs | 20 +++ git-workon-lib/src/fetch.rs | 28 ++- git-workon-lib/src/lib.rs | 2 + git-workon-lib/src/worktree.rs | 97 ++--------- git-workon-lib/tests/suite/branch.rs | 247 +++++++++++++++++++++++++++ git-workon-lib/tests/suite/main.rs | 1 + 7 files changed, 443 insertions(+), 80 deletions(-) create mode 100644 git-workon-lib/src/branch.rs create mode 100644 git-workon-lib/tests/suite/branch.rs diff --git a/git-workon-lib/src/branch.rs b/git-workon-lib/src/branch.rs new file mode 100644 index 00000000..3440a48d --- /dev/null +++ b/git-workon-lib/src/branch.rs @@ -0,0 +1,128 @@ +//! Repo-level signal helpers for branches with no worktree. +//! +//! `prune`'s existing signals (`has_gone_upstream`, `is_merged_into`, `is_at_or_behind`) +//! all live on [`crate::WorktreeDescriptor`] and open the worktree path to get at the +//! repository. A branch with no worktree has no path to open, so a branch-only prune +//! row needs the same checks against a `&git2::Repository` directly. +//! +//! [`crate::WorktreeDescriptor`]'s methods delegate to these functions. + +use crate::error::{Result, WorktreeError}; + +/// Returns true if `name`'s upstream tracking branch is gone (deleted on remote). +/// +/// Mirrors [`crate::WorktreeDescriptor::has_gone_upstream`] for a branch with no +/// worktree. Returns false if: +/// - The branch doesn't exist +/// - The branch has no upstream configured (`branch..remote` unset) +/// - The upstream branch reference exists +/// +/// Returns true if upstream is configured but the upstream reference can't be found. +pub fn branch_has_gone_upstream(repo: &git2::Repository, name: &str) -> Result { + let branch = match repo.find_branch(name, git2::BranchType::Local) { + Ok(b) => b, + Err(_) => return Ok(false), // Branch doesn't exist + }; + + let config = repo.config()?; + let remote_key = format!("branch.{}.remote", name); + + match config.get_string(&remote_key) { + Ok(_) => { + // Upstream is configured - check if the reference exists + match branch.upstream() { + Ok(_) => Ok(false), // Upstream exists + Err(_) => Ok(true), // Upstream configured but ref is gone + } + } + Err(_) => Ok(false), // No upstream configured + } +} + +/// Returns true if `name` has been merged into `target_branch`. +/// +/// Mirrors [`crate::WorktreeDescriptor::is_merged_into`] for a branch with no +/// worktree. A branch is merged if its tip equals the target's tip, or is an +/// ancestor of it. Returns false if: +/// - `name` equals `target_branch` (a branch is never merged into itself) +/// - Either branch doesn't exist +/// - `name` has commits not in `target_branch`'s history +pub fn branch_is_merged_into( + repo: &git2::Repository, + name: &str, + target_branch: &str, +) -> Result { + if name == target_branch { + return Ok(false); + } + + let branch = match repo.find_branch(name, git2::BranchType::Local) { + Ok(b) => b, + Err(_) => return Ok(false), // Branch doesn't exist + }; + + let target = match repo.find_branch(target_branch, git2::BranchType::Local) { + Ok(b) => b, + Err(_) => return Ok(false), // Target branch doesn't exist + }; + + let branch_oid = branch + .get() + .target() + .ok_or(WorktreeError::NoCurrentBranchTarget)?; + let target_oid = target.get().target().ok_or(WorktreeError::NoBranchTarget)?; + + if branch_oid == target_oid { + return Ok(true); + } + + // target is a descendant of (or equal to) branch + Ok(repo.graph_descendant_of(target_oid, branch_oid)?) +} + +/// Returns true if `name`'s tip is at or behind `oid`. +/// +/// Mirrors [`crate::WorktreeDescriptor::is_at_or_behind`] for a branch with no +/// worktree. "At or behind" means `oid` equals the branch's tip, or `oid` is a +/// descendant of the tip. Returns false if: +/// - The branch doesn't exist or has no target +/// - `oid` does not parse as a commit hash +/// - `oid` does not resolve to a commit in this repository +pub fn branch_tip_at_or_behind(repo: &git2::Repository, name: &str, oid: &str) -> Result { + let branch = match repo.find_branch(name, git2::BranchType::Local) { + Ok(b) => b, + Err(_) => return Ok(false), // Branch doesn't exist + }; + + let tip_oid = match branch.get().target() { + Some(o) => o, + None => return Ok(false), + }; + + tip_at_or_behind(repo, tip_oid, oid) +} + +/// Returns true if `tip` is at or behind `oid`. +/// +/// The tip-level check shared by [`branch_tip_at_or_behind`] (a branch's tip) and +/// [`crate::WorktreeDescriptor::is_at_or_behind`] (HEAD, which may be detached and +/// so has no branch name to resolve). "At or behind" means `oid` equals `tip`, or +/// `oid` is a descendant of `tip`. Returns false if: +/// - `oid` does not parse as a commit hash +/// - `oid` does not resolve to a commit in this repository +pub fn tip_at_or_behind(repo: &git2::Repository, tip: git2::Oid, oid: &str) -> Result { + if tip.to_string() == oid { + return Ok(true); + } + + let target_oid = match git2::Oid::from_str(oid) { + Ok(o) => o, + Err(_) => return Ok(false), + }; + + if repo.find_commit(target_oid).is_err() { + return Ok(false); + } + + Ok(repo.graph_descendant_of(target_oid, tip)?) +} diff --git a/git-workon-lib/src/config.rs b/git-workon-lib/src/config.rs index fd6c8cbd..1ef0023d 100644 --- a/git-workon-lib/src/config.rs +++ b/git-workon-lib/src/config.rs @@ -254,6 +254,26 @@ impl<'repo> WorkonConfig<'repo> { } } + /// Get whether to also consider local branches with no worktree as prune candidates. + /// + /// Precedence: CLI override > workon.pruneBranches config > true + /// + /// When true (the default), `prune` also scans local branches that have no + /// worktree checked out, applying the same signals as worktree rows. This is how + /// a merged PR stack's sibling branches (left behind once their worktree's branch + /// is pruned) get cleaned up. Set `workon.pruneBranches = false` or pass + /// `--no-branches` to scope `prune` back to worktrees only. + pub fn prune_branches(&self, cli_override: Option) -> Result { + if let Some(override_val) = cli_override { + return Ok(override_val); + } + let config = self.repo.config()?; + match config.get_bool("workon.pruneBranches") { + Ok(val) => Ok(val), + Err(_) => Ok(true), + } + } + /// Check if a given branch name is protected from pruning. /// /// Returns true if the branch name matches any of the protected patterns. diff --git a/git-workon-lib/src/fetch.rs b/git-workon-lib/src/fetch.rs index 7dec3a99..b0884dd2 100644 --- a/git-workon-lib/src/fetch.rs +++ b/git-workon-lib/src/fetch.rs @@ -5,10 +5,11 @@ //! after a *prune-fetch* — a fetch that also removes stale `refs/remotes//*` //! entries. Nothing in the codebase performed such a fetch before this module. //! -//! Two public functions are provided: +//! Public functions: //! //! - [`remotes_tracked_by_worktrees`] — discover which remotes are relevant (deduplicated //! list of `branch..remote` values across all worktrees). +//! - [`remotes_tracked_by_branches`] — the same discovery for branches with no worktree. //! - [`prune_fetch`] — run the equivalent of `git fetch --prune ` for a single //! remote, deleting stale remote-tracking refs and then fetching. @@ -51,6 +52,31 @@ pub fn remotes_tracked_by_worktrees<'a>( Ok(remotes) } +/// Returns the deduplicated list of remote names tracked by the given branch names. +/// +/// Sibling of [`remotes_tracked_by_worktrees`] for branches with no worktree: reads +/// `branch..remote` from git config for each name. Results are deduplicated and +/// returned in stable (first-seen) order. Branches with no upstream configured are +/// silently skipped. +pub fn remotes_tracked_by_branches<'a>( + repo: &git2::Repository, + names: impl IntoIterator, +) -> Result> { + let config = repo.config()?; + let mut remotes: Vec = Vec::new(); + + for name in names { + let remote_key = format!("branch.{}.remote", name); + if let Ok(remote) = config.get_string(&remote_key) { + if !remotes.contains(&remote) { + remotes.push(remote); + } + } + } + + Ok(remotes) +} + /// Prune-fetch from a single remote: remove stale remote-tracking refs, then fetch. /// /// Equivalent to `git fetch --prune `. After this call, any diff --git a/git-workon-lib/src/lib.rs b/git-workon-lib/src/lib.rs index c7a285a3..c3fc426e 100644 --- a/git-workon-lib/src/lib.rs +++ b/git-workon-lib/src/lib.rs @@ -45,6 +45,7 @@ //! # } //! ``` +mod branch; mod changeset; mod checkout; mod clone; @@ -68,6 +69,7 @@ mod workon_root; mod worktree; mod worktree_name; +pub use crate::branch::*; pub use crate::changeset::*; pub use crate::checkout::*; pub use crate::clone::*; diff --git a/git-workon-lib/src/worktree.rs b/git-workon-lib/src/worktree.rs index 95643a8a..34f99be4 100644 --- a/git-workon-lib/src/worktree.rs +++ b/git-workon-lib/src/worktree.rs @@ -43,6 +43,7 @@ use git2::WorktreeAddOptions; use git2::{Repository, Worktree}; use log::debug; +use crate::branch::{branch_has_gone_upstream, branch_is_merged_into, tip_at_or_behind}; use crate::error::{Result, WorktreeError}; use crate::workon_root; use crate::worktree_name::{encode_worktree_name, relative_worktree_path}; @@ -307,30 +308,8 @@ impl WorktreeDescriptor { None => return Ok(false), // Detached HEAD, no branch to check }; - // Open the repository - let repo = Repository::open(self.path())?; - - // Find the local branch - let branch = match repo.find_branch(&branch_name, git2::BranchType::Local) { - Ok(b) => b, - Err(_) => return Ok(false), // Branch doesn't exist - }; - - // Check if upstream is configured via git config - let config = repo.config()?; - let remote_key = format!("branch.{}.remote", branch_name); - - // If no upstream is configured, it's not "gone" - match config.get_string(&remote_key) { - Ok(_) => { - // Upstream is configured - check if the reference exists - match branch.upstream() { - Ok(_) => Ok(false), // Upstream exists - Err(_) => Ok(true), // Upstream configured but ref is gone - } - } - Err(_) => Ok(false), // No upstream configured - } + let repo = self.commondir_repo()?; + branch_has_gone_upstream(&repo, &branch_name) } /// Returns true if the worktree's branch has been merged into the target branch. @@ -352,44 +331,8 @@ impl WorktreeDescriptor { None => return Ok(false), // Detached HEAD, no branch to check }; - // Don't consider the target branch as merged into itself - if branch_name == target_branch { - return Ok(false); - } - - // Open the bare repository (not the worktree) to check actual branch states - // The worktree's .git points to the commondir (bare repo) - let worktree_repo = Repository::open(self.path())?; - let commondir = worktree_repo.commondir(); - let repo = Repository::open(commondir)?; - - // Find the current branch - let current_branch = match repo.find_branch(&branch_name, git2::BranchType::Local) { - Ok(b) => b, - Err(_) => return Ok(false), // Branch doesn't exist - }; - - // Find the target branch - let target = match repo.find_branch(target_branch, git2::BranchType::Local) { - Ok(b) => b, - Err(_) => return Ok(false), // Target branch doesn't exist - }; - - // Get commit OIDs - let current_oid = current_branch - .get() - .target() - .ok_or(WorktreeError::NoCurrentBranchTarget)?; - let target_oid = target.get().target().ok_or(WorktreeError::NoBranchTarget)?; - - // If they point to the same commit, the branch is merged - if current_oid == target_oid { - return Ok(true); - } - - // Check if current branch's commit is reachable from target - // This means target is a descendant of (or equal to) current - Ok(repo.graph_descendant_of(target_oid, current_oid)?) + let repo = self.commondir_repo()?; + branch_is_merged_into(&repo, &branch_name, target_branch) } /// Returns true if the worktree's HEAD is at or behind `oid`. @@ -411,31 +354,27 @@ impl WorktreeDescriptor { Some(h) => h, None => return Ok(false), }; - - if head_oid_str == oid { - return Ok(true); - } - - let target_oid = match git2::Oid::from_str(oid) { - Ok(o) => o, - Err(_) => return Ok(false), - }; let head_oid = match git2::Oid::from_str(&head_oid_str) { Ok(o) => o, Err(_) => return Ok(false), }; - // Open the bare repository (not the worktree) to check the target OID; it may - // only exist under refs/remotes/ there, the same as is_merged_into does. + let repo = self.commondir_repo()?; + tip_at_or_behind(&repo, head_oid, oid) + } + + /// Opens the commondir repository backing this worktree. + /// + /// `self.path()` is the worktree's own checkout, whose `.git` file points at the + /// commondir (the shared, non-worktree-specific repo). Signal checks open it + /// directly rather than the worktree repo because it holds refs the worktree may + /// not see under `refs/remotes/` (a target OID reported by `gh` or a merged PR + /// head, for example), and it reads the same `branch.*` config either way. + fn commondir_repo(&self) -> Result { let worktree_repo = Repository::open(self.path())?; let commondir = worktree_repo.commondir(); let repo = Repository::open(commondir)?; - - if repo.find_commit(target_oid).is_err() { - return Ok(false); - } - - Ok(repo.graph_descendant_of(target_oid, head_oid)?) + Ok(repo) } /// Returns the commit hash (SHA) of the worktree's current HEAD. diff --git a/git-workon-lib/tests/suite/branch.rs b/git-workon-lib/tests/suite/branch.rs new file mode 100644 index 00000000..fa656aa8 --- /dev/null +++ b/git-workon-lib/tests/suite/branch.rs @@ -0,0 +1,247 @@ +//! Tests for the repo-level branch signal helpers (`branch_has_gone_upstream`, +//! `branch_is_merged_into`, `branch_tip_at_or_behind`), the branch-only-row +//! counterparts to the `WorktreeDescriptor` methods they mirror. + +use git_workon_fixture::prelude::*; +use workon::{branch_has_gone_upstream, branch_is_merged_into, branch_tip_at_or_behind}; + +#[test] +fn branch_has_gone_upstream_false_without_upstream() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("feature") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_has_gone_upstream(repo, "feature")?); + + Ok(()) +} + +#[test] +fn branch_has_gone_upstream_false_when_upstream_exists() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .remote("origin", "/dev/null") + .branch("feature") + .upstream("feature", "origin/feature") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_has_gone_upstream(repo, "feature")?); + + Ok(()) +} + +#[test] +fn branch_has_gone_upstream_true_when_upstream_ref_deleted( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .remote("origin", "/dev/null") + .branch("feature") + .upstream("feature", "origin/feature") + .build()?; + + let repo = fixture.repo()?; + repo.find_reference("refs/remotes/origin/feature")? + .delete()?; + + assert!(branch_has_gone_upstream(repo, "feature")?); + + Ok(()) +} + +#[test] +fn branch_has_gone_upstream_false_for_missing_branch() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_has_gone_upstream(repo, "nonexistent")?); + + Ok(()) +} + +#[test] +fn branch_is_merged_into_true_at_same_commit() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("feature") + .build()?; + + let repo = fixture.repo()?; + + assert!(branch_is_merged_into(repo, "feature", "main")?); + + Ok(()) +} + +#[test] +fn branch_is_merged_into_false_with_additional_commits() -> Result<(), Box> { + // A worktree is needed to write the extra commit; the helper itself doesn't care + // whether "feature" has a worktree. + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .worktree("feature") + .build()?; + + fixture + .commit("feature") + .file("test.txt", "test") + .create("Feature commit")?; + + let repo = fixture.repo()?; + + assert!(!branch_is_merged_into(repo, "feature", "main")?); + + Ok(()) +} + +#[test] +fn branch_is_merged_into_true_after_fast_forward() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .worktree("feature") + .build()?; + + let feature_commit_oid = fixture + .commit("feature") + .file("feature.txt", "feature") + .create("Feature commit")?; + + fixture.update_branch("main", feature_commit_oid)?; + + let repo = fixture.repo()?; + + assert!(branch_is_merged_into(repo, "feature", "main")?); + + Ok(()) +} + +#[test] +fn branch_is_merged_into_false_when_target_missing() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("feature") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_is_merged_into(repo, "feature", "nonexistent")?); + + Ok(()) +} + +#[test] +fn branch_is_merged_into_false_for_same_branch() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_is_merged_into(repo, "main", "main")?); + + Ok(()) +} + +#[test] +fn branch_tip_at_or_behind_true_when_equal() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("feature") + .build()?; + + let repo = fixture.repo()?; + let tip = repo + .find_branch("feature", git2::BranchType::Local)? + .get() + .target() + .unwrap() + .to_string(); + + assert!(branch_tip_at_or_behind(repo, "feature", &tip)?); + + Ok(()) +} + +#[test] +fn branch_tip_at_or_behind_true_when_oid_is_descendant() -> Result<(), Box> { + // "old" is a local branch with no worktree, sitting at the initial commit; + // "feature" is a worktree branched from the same commit and then advanced. The + // feature worktree's new commit is a descendant of "old"'s tip, so "old" reads as + // at-or-behind it. + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("old") + .worktree("feature") + .build()?; + + let ahead_oid = fixture + .commit("feature") + .file("later.txt", "later") + .create("Later commit")?; + + let repo = fixture.repo()?; + + assert!(branch_tip_at_or_behind( + repo, + "old", + &ahead_oid.to_string() + )?); + + Ok(()) +} + +#[test] +fn branch_tip_at_or_behind_false_when_oid_unknown() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("feature") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_tip_at_or_behind( + repo, + "feature", + "0000000000000000000000000000000000000000" + )?); + + Ok(()) +} + +#[test] +fn branch_tip_at_or_behind_false_for_missing_branch() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .build()?; + + let repo = fixture.repo()?; + + assert!(!branch_tip_at_or_behind( + repo, + "nonexistent", + "0000000000000000000000000000000000000000" + )?); + + Ok(()) +} diff --git a/git-workon-lib/tests/suite/main.rs b/git-workon-lib/tests/suite/main.rs index 77fe389a..64c4bfa0 100644 --- a/git-workon-lib/tests/suite/main.rs +++ b/git-workon-lib/tests/suite/main.rs @@ -3,6 +3,7 @@ //! a `mod` here merges them into one binary (one link instead of one per file), cutting build //! time for the crate's suite. +mod branch; mod changeset; mod clone; mod config; From ae85d358ec906f50da6ec10e4719f37a7f3d8dd8 Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 9 Sep 2026 12:57:53 -0400 Subject: [PATCH 2/3] feat(cli): prune branch-only rows --- git-workon/src/cli.rs | 26 ++ git-workon/src/cmd/prune.rs | 412 ++++++++++++++++++++++++----- git-workon/tests/suite/prune.rs | 442 ++++++++++++++++++++++++++++++++ 3 files changed, 809 insertions(+), 71 deletions(-) diff --git a/git-workon/src/cli.rs b/git-workon/src/cli.rs index 37e1b706..f37c919c 100644 --- a/git-workon/src/cli.rs +++ b/git-workon/src/cli.rs @@ -290,6 +290,18 @@ pub struct Prune { help = "Skip the prune-fetch step, even if workon.pruneFetch is true" )] pub no_fetch: bool, + #[arg( + long, + conflicts_with = "no_branches", + help = "Also consider local branches with no worktree (default; see workon.pruneBranches)" + )] + pub branches: bool, + #[arg( + long, + conflicts_with = "branches", + help = "Do not consider local branches with no worktree, even if workon.pruneBranches is true" + )] + pub no_branches: bool, #[arg( long, value_name = "BRANCH", @@ -351,6 +363,20 @@ impl Prune { None } } + + /// Returns the CLI override for including branch-only rows. + /// + /// `Some(true)` if `--branches` was passed, `Some(false)` if `--no-branches` was + /// passed, `None` to fall back to the `workon.pruneBranches` config (default true). + pub fn branches_override(&self) -> Option { + if self.branches { + Some(true) + } else if self.no_branches { + Some(false) + } else { + None + } + } } /// Find a worktree to work on. diff --git a/git-workon/src/cmd/prune.rs b/git-workon/src/cmd/prune.rs index ad9a2ade..765c262d 100644 --- a/git-workon/src/cmd/prune.rs +++ b/git-workon/src/cmd/prune.rs @@ -70,7 +70,24 @@ //! The unmerged check is skipped entirely for any row carrying a signal //! (`BranchDeleted`, `RemoteGone`, `Merged`, or `PrMerged`) — the signal already //! implies the work was handled. - +//! +//! ## Branch-Only Rows +//! +//! By default (opt out with `--no-branches` / `workon.pruneBranches = false`), the +//! candidate pool also includes every local branch with no worktree checked out +//! (minus the default branch, protected globs, and any branch checked out in a +//! worktree, including the default one). `gh-stack` never sets +//! `branch..remote`, so `RemoteGone` never fires on its branches, and squash +//! merges defeat `Merged`; `PrMerged` is usually the only signal that reaches a +//! merged gh-stack's sibling branches, left behind once the worktree on the stack's +//! other branch is pruned. Branch rows carry the same signals as worktree rows, +//! computed against the repo directly (see [`workon::branch`]) since there's no +//! worktree path to open; `locked` and `dirty` are always false. `--force` still +//! overrides protection, same as a worktree row. `--keep-branch` drops branch-only +//! rows entirely, since there's nothing to keep-branch about a row whose only action +//! is deleting the branch. + +use std::collections::HashSet; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -78,9 +95,10 @@ use dialoguer::Confirm; use miette::{IntoDiagnostic, Report, Result, WrapErr}; use serde_json::json; use workon::{ - check_gh_available, find_merged_pr, get_default_branch, get_repo, get_worktrees, - has_github_remote, prune_fetch as remote_prune_fetch, remotes_tracked_by_worktrees, PruneError, - WorktreeDescriptor, + branch_has_gone_upstream, branch_is_merged_into, branch_tip_at_or_behind, check_gh_available, + find_merged_pr, get_default_branch, get_repo, get_worktrees, has_github_remote, + prune_fetch as remote_prune_fetch, remotes_tracked_by_branches, remotes_tracked_by_worktrees, + PruneError, WorktreeDescriptor, }; use crate::cli::Prune; @@ -108,6 +126,9 @@ impl Run for Prune { let effective_fetch = config .prune_fetch(self.fetch_override()) .into_diagnostic()?; + let effective_branches = config + .prune_branches(self.branches_override()) + .into_diagnostic()?; let worktrees = get_worktrees(&repo)?; let default_branch = get_default_branch(&repo).ok(); @@ -122,40 +143,81 @@ impl Run for Prune { }) .collect(); + // Branch-only candidates: every local branch with no worktree, minus the + // default branch. `checked_out` spans every worktree (not just `pool`), so + // the default worktree's branch is excluded even when it isn't the default + // branch itself. `--keep-branch` makes the whole feature moot (there's + // nothing to keep-branch when there's no worktree to prune), so it's folded + // in here rather than filtered out later. + let checked_out: HashSet = worktrees + .iter() + .filter_map(|wt| wt.branch().ok().flatten()) + .collect(); + let branch_pool: Vec = if effective_branches && !self.keep_branch { + repo.branches(Some(git2::BranchType::Local)) + .into_diagnostic()? + .filter_map(|b| b.ok()) + .filter_map(|(branch, _)| branch.name().ok().flatten().map(str::to_string)) + .filter(|name| !checked_out.contains(name)) + .filter(|name| default_branch.as_deref() != Some(name.as_str())) + .collect() + } else { + Vec::new() + }; + let named = !self.names.is_empty(); let mut scope: Vec<&WorktreeDescriptor> = Vec::new(); + let mut branch_scope: Vec = Vec::new(); if named { let mut misses: Vec = Vec::new(); for name in &self.names { - match pool.iter().find(|wt| { + if let Some(wt) = pool.iter().find(|wt| { wt.name() == Some(name.as_str()) || matches!(wt.branch(), Ok(Some(ref b)) if b == name) }) { // Dedupe: repeated names (or a worktree named once by name and // once by branch) resolve to the same worktree; pruning it twice // would fail after the first pass deregistered it. - Some(wt) if scope.iter().any(|s| s.name() == wt.name()) => {} - Some(wt) => scope.push(wt), - None => misses.push(name.clone()), + if !scope.iter().any(|s| s.name() == wt.name()) { + scope.push(wt); + } + continue; + } + if let Some(bname) = branch_pool.iter().find(|b| b.as_str() == name.as_str()) { + if !branch_scope.contains(bname) { + branch_scope.push(bname.clone()); + } + continue; } + misses.push(name.clone()); } if !misses.is_empty() { return Err(Report::from(PruneError::NamesNotFound { names: misses })); } } else { scope = pool.clone(); + branch_scope = branch_pool.clone(); } let pb = output::create_spinner(); // Phase 0 (optional): prune-fetch so gone-upstream detection reflects the // actual remote state. Named mode narrows the fetch to remotes tracked by the - // named worktrees only. Failure is non-fatal: a stale fetch can only + // named worktrees/branches only. Failure is non-fatal: a stale fetch can only // under-prune, never cause a false prune. if effective_fetch { let fetch_scope = if named { &scope } else { &pool }; - let remotes = remotes_tracked_by_worktrees(&repo, fetch_scope.iter().copied()) + let mut remotes = remotes_tracked_by_worktrees(&repo, fetch_scope.iter().copied()) .into_diagnostic()?; + let branch_fetch_scope: &[String] = if named { &branch_scope } else { &branch_pool }; + let branch_remotes = + remotes_tracked_by_branches(&repo, branch_fetch_scope.iter().map(String::as_str)) + .into_diagnostic()?; + for remote in branch_remotes { + if !remotes.contains(&remote) { + remotes.push(remote); + } + } for remote_name in &remotes { pb.set_message(format!("Fetching {}...", remote_name)); if let Err(e) = remote_prune_fetch(&repo, remote_name) { @@ -176,6 +238,9 @@ impl Run for Prune { }; let merged_active = self.merged.is_some(); + // Worktree rows always come first: the gh pass below visits rows in order, + // and existing bound tests count `gh pr list` calls assuming worktree rows + // are exhausted before branch rows begin. let mut rows: Vec = scope .iter() .map(|wt| { @@ -188,6 +253,15 @@ impl Run for Prune { ) }) .collect(); + rows.extend(branch_scope.iter().map(|name| { + build_branch_row( + &repo, + name, + default_branch.as_deref(), + merged_target.as_deref(), + &protected_patterns, + ) + })); // Second pass: PR-merged status only comes from a network call, so it can't // live in build_row alongside the offline signals. `gh` can't resolve a PR @@ -201,7 +275,7 @@ impl Run for Prune { if has_github_remote(&repo) && check_gh_available().is_ok() { let mut consecutive_failures = 0; for row in rows.iter_mut() { - if fill_pr_merged(row) { + if fill_pr_merged(&repo, row) { consecutive_failures = 0; } else { consecutive_failures += 1; @@ -255,7 +329,7 @@ impl Run for Prune { if !skipped.is_empty() { output::notice("Skipped worktrees (unsafe to prune):"); for (row, reason) in &skipped { - output::detail(&format!(" {} ({})", row.wt.path().display(), reason)); + output::detail(&format!(" {} ({})", row_path_display(row), reason)); } eprintln!(); } @@ -269,21 +343,14 @@ impl Run for Prune { for row in &to_prune { output::detail(&format!( " {} (branch: {}, reason: {})", - row.wt.path().display(), + row_path_display(row), row.branch, reason_display(row) )); } if !self.yes { - let prompt = if !self.keep_branch { - format!( - "Prune {} worktree(s) and delete their branches?", - to_prune.len() - ) - } else { - format!("Prune {} worktree(s)?", to_prune.len()) - }; + let prompt = prune_prompt(&to_prune, self.keep_branch); let confirmed = Confirm::new() .with_prompt(prompt) .default(false) @@ -298,18 +365,31 @@ impl Run for Prune { let delete_branch = !self.keep_branch; let force_locked = self.force || self.include_locked; + let mut worktrees_pruned = 0usize; + let mut branches_deleted = 0usize; for row in &to_prune { - let candidate = to_candidate(row); - for label in collect_orphaned_stashes(&candidate) { - output::warn(&format!( - "pruning '{}' orphans shelved changes: {} (not restorable)", - candidate.worktree_name, label - )); + match to_candidate(row) { + Some(candidate) => { + for label in collect_orphaned_stashes(&candidate) { + output::warn(&format!( + "pruning '{}' orphans shelved changes: {} (not restorable)", + candidate.worktree_name, label + )); + } + if prune_worktree(&repo, &candidate, delete_branch, force_locked)? { + branches_deleted += 1; + } + worktrees_pruned += 1; + } + None => { + if prune_branch(&repo, &row.branch)? { + branches_deleted += 1; + } + } } - prune_worktree(&repo, &candidate, delete_branch, force_locked)?; } - output::success(&format!("Pruned {} worktree(s)", to_prune.len())); + output::success(&prune_success_line(worktrees_pruned, branches_deleted)); Ok(None) } } @@ -367,8 +447,11 @@ impl Overrides { /// A single worktree's full analysis: every signal it carries, plus raw safety state /// (protected/locked/dirty/unmerged) with no override flags baked in. Override flags /// (`--force`, `--allow-dirty`, etc.) are applied later, at classification time. +/// +/// `wt` is `None` for a branch-only row: a local branch with no worktree, matched by +/// the same signals but with no working tree to be dirty or locked in. struct PruneRow<'a> { - wt: &'a WorktreeDescriptor, + wt: Option<&'a WorktreeDescriptor>, name: String, branch: String, signals: Vec, @@ -378,6 +461,21 @@ struct PruneRow<'a> { unmerged: bool, } +impl PruneRow<'_> { + /// True for a row with no worktree — a local branch pruned by name alone. + fn is_branch_only(&self) -> bool { + self.wt.is_none() + } +} + +/// Display the row's path, or `(no worktree)` for a branch-only row. +fn row_path_display(row: &PruneRow) -> String { + match row.wt { + Some(wt) => wt.path().display().to_string(), + None => "(no worktree)".to_string(), + } +} + fn build_row<'a>( repo: &git2::Repository, wt: &'a WorktreeDescriptor, @@ -431,7 +529,7 @@ fn build_row<'a>( }; PruneRow { - wt, + wt: Some(wt), name: wt.name().unwrap_or("").to_string(), branch, signals, @@ -442,6 +540,52 @@ fn build_row<'a>( } } +/// Build a prune row for a local branch with no worktree. Mirrors `build_row`'s +/// signal table (`RemoteGone`, `Merged`, protected, unmerged) against the repo-level +/// helpers in [`workon::branch`] instead of the descriptor methods, since there's no +/// worktree path to open. `locked` and `dirty` are always false — there's no working +/// tree to lock or dirty. +fn build_branch_row<'a>( + repo: &git2::Repository, + name: &str, + default_branch: Option<&str>, + merged_target: Option<&str>, + protected_patterns: &[glob::Pattern], +) -> PruneRow<'a> { + let mut signals = Vec::new(); + if branch_has_gone_upstream(repo, name).unwrap_or(false) { + signals.push(Signal::RemoteGone); + } + if let Some(target) = merged_target { + if branch_is_merged_into(repo, name, target).unwrap_or(false) { + signals.push(Signal::Merged(target.to_string())); + } + } + + let protected = is_protected(name, protected_patterns); + + // Same rule as build_row: any signal already implies the work is handled, so the + // unmerged check is only meaningful for signal-less rows. + let unmerged = if !signals.is_empty() { + false + } else if let Some(db) = default_branch { + matches!(branch_is_merged_into(repo, name, db), Ok(false)) + } else { + false + }; + + PruneRow { + wt: None, + name: name.to_string(), + branch: name.to_string(), + signals, + protected, + locked: false, + dirty: false, + unmerged, + } +} + /// Raise `Signal::PrMerged` for a row with no other signal, if `gh` reports a merged /// PR for its branch whose head covers the worktree's current tip. Skips rows that /// already carry a signal (no local branch to look up, or the network call would just @@ -462,13 +606,19 @@ fn build_row<'a>( /// itself failing is. The caller tracks consecutive failures and gives up after /// enough of them in a row that `gh` is probably unusable here, rather than aborting /// on the first one and blanking the signal for every row after it. -fn fill_pr_merged(row: &mut PruneRow) -> bool { +fn fill_pr_merged(repo: &git2::Repository, row: &mut PruneRow) -> bool { if !row.signals.is_empty() || row.branch.starts_with('(') { return true; } match find_merged_pr(&row.branch) { Ok(Some(merged)) => { - if row.wt.is_at_or_behind(&merged.head_oid).unwrap_or(false) { + let covers = match row.wt { + Some(wt) => wt.is_at_or_behind(&merged.head_oid).unwrap_or(false), + None => { + branch_tip_at_or_behind(repo, &row.branch, &merged.head_oid).unwrap_or(false) + } + }; + if covers { row.signals.push(Signal::PrMerged(merged.number)); // A merged PR is unambiguous evidence the work landed, so the unmerged // check (only meaningful for signal-less rows) no longer applies. @@ -617,7 +767,7 @@ fn render_dry_run(rows: &[&PruneRow], active: ActiveCriteria, overrides: Overrid output::detail(&format!( " [{}] {} (branch: {}, {})", status, - row.wt.path().display(), + row_path_display(row), row.branch, annotate(row) )); @@ -625,10 +775,64 @@ fn render_dry_run(rows: &[&PruneRow], active: ActiveCriteria, overrides: Overrid output::notice("\nDry run - no changes made"); } +/// True if a worktree row's branch delete step would be skipped: the branch is +/// already gone, or the row is a detached/error sentinel with no branch to delete. +/// Only meaningful for worktree rows — a branch-only row is never skipped, since +/// naming or surfacing it is the only reason it's a candidate at all. +fn worktree_skip_branch_delete(row: &PruneRow) -> bool { + row.signals.contains(&Signal::BranchDeleted) || row.branch.starts_with('(') +} + +/// Worktree count and estimated branch-delete count for a set of rows about to be +/// pruned, used to word the confirm prompt and (once actually pruned) the success +/// line. `--keep-branch` means no branches get deleted at all. +fn prune_counts(rows: &[&PruneRow], keep_branch: bool) -> (usize, usize) { + let worktree_count = rows.iter().copied().filter(|r| r.wt.is_some()).count(); + if keep_branch { + return (worktree_count, 0); + } + let branch_count = rows + .iter() + .copied() + .filter(|r| match r.wt { + Some(_) => !worktree_skip_branch_delete(r), + None => true, + }) + .count(); + (worktree_count, branch_count) +} + +fn prune_prompt(rows: &[&PruneRow], keep_branch: bool) -> String { + let (worktree_count, branch_count) = prune_counts(rows, keep_branch); + if branch_count > 0 { + format!( + "Prune {} worktree(s) and delete {} branch(es)?", + worktree_count, branch_count + ) + } else { + format!("Prune {} worktree(s)?", worktree_count) + } +} + +fn prune_success_line(worktree_count: usize, branch_count: usize) -> String { + if branch_count > 0 { + format!( + "Pruned {} worktree(s) and deleted {} branch(es)", + worktree_count, branch_count + ) + } else { + format!("Pruned {} worktree(s)", worktree_count) + } +} + /// Build aligned display rows + prune annotations for a set of picker rows. /// /// Falls back to a minimal row when the descriptor can't be fully read, so the /// output stays parallel to the input — picker indices must map 1:1 back to rows. +/// Branch-only rows always take the fallback (there's no descriptor to read), with +/// the branch name standing in for the directory name and no branch/activity +/// annotation. Their trailing prune annotation is prefixed with "no worktree, " so +/// the mixed list reads correctly. fn build_picker_rows( rows: &[&PruneRow], root: &Path, @@ -637,18 +841,42 @@ fn build_picker_rows( ) -> (Vec, Vec) { let display_rows = rows .iter() - .map(|row| { - worktree_display_row(row.wt, root, current_dir).unwrap_or_else(|_| WorktreeDisplayRow { + .map(|row| match row.wt { + Some(wt) => { + worktree_display_row(wt, root, current_dir).unwrap_or_else(|_| WorktreeDisplayRow { + is_active: false, + dir_name: row.name.clone(), + branch_annotation: (row.branch != row.name).then(|| row.branch.clone()), + indicators: vec![], + last_activity: String::new(), + activity_epoch: None, + }) + } + None => WorktreeDisplayRow { is_active: false, - dir_name: row.name.clone(), - branch_annotation: (row.branch != row.name).then(|| row.branch.clone()), + dir_name: row.branch.clone(), + branch_annotation: None, indicators: vec![], last_activity: String::new(), activity_epoch: None, - }) + }, + }) + .collect(); + let annotations = rows + .iter() + .map(|row| { + let base = annotation(row); + if row.is_branch_only() { + if base.is_empty() { + "no worktree".to_string() + } else { + format!("no worktree, {}", base) + } + } else { + base + } }) .collect(); - let annotations = rows.iter().map(|row| annotation(row)).collect(); (display_rows, annotations) } @@ -726,7 +954,7 @@ fn run_interactive( for row in &selected { output::detail(&format!( " {} (branch: {})", - row.wt.path().display(), + row_path_display(row), row.branch )); if row.dirty { @@ -737,23 +965,17 @@ fn run_interactive( } } for row in &selected { - let candidate = to_candidate(row); - for label in collect_orphaned_stashes(&candidate) { - output::warn(&format!( - "pruning '{}' orphans shelved changes: {} (not restorable)", - candidate.worktree_name, label - )); + if let Some(candidate) = to_candidate(row) { + for label in collect_orphaned_stashes(&candidate) { + output::warn(&format!( + "pruning '{}' orphans shelved changes: {} (not restorable)", + candidate.worktree_name, label + )); + } } } - let prompt = if !cmd.keep_branch { - format!( - "Prune {} worktree(s) and delete their branches?", - selected.len() - ) - } else { - format!("Prune {} worktree(s)?", selected.len()) - }; + let prompt = prune_prompt(&selected, cmd.keep_branch); let confirmed = Confirm::new() .with_prompt(prompt) .default(false) @@ -767,12 +989,25 @@ fn run_interactive( let delete_branch = !cmd.keep_branch; let force_locked = cmd.force || cmd.include_locked; + let mut worktrees_pruned = 0usize; + let mut branches_deleted = 0usize; for row in &selected { - let candidate = to_candidate(row); - prune_worktree(repo, &candidate, delete_branch, force_locked)?; + match to_candidate(row) { + Some(candidate) => { + if prune_worktree(repo, &candidate, delete_branch, force_locked)? { + branches_deleted += 1; + } + worktrees_pruned += 1; + } + None => { + if prune_branch(repo, &row.branch)? { + branches_deleted += 1; + } + } + } } - output::success(&format!("Pruned {} worktree(s)", selected.len())); + output::success(&prune_success_line(worktrees_pruned, branches_deleted)); Ok(None) } @@ -790,12 +1025,18 @@ fn emit_json( let mut pruned_rows: Vec<(&PruneRow, bool, Vec)> = Vec::new(); for row in to_prune { - let candidate = to_candidate(row); - let orphaned = collect_orphaned_stashes(&candidate); - let branch_deleted = if dry_run { - false + let (branch_deleted, orphaned) = if dry_run { + (false, Vec::new()) } else { - prune_worktree(repo, &candidate, delete_branch, force_locked)? + match to_candidate(row) { + Some(candidate) => { + let orphaned = collect_orphaned_stashes(&candidate); + let branch_deleted = + prune_worktree(repo, &candidate, delete_branch, force_locked)?; + (branch_deleted, orphaned) + } + None => (prune_branch(repo, &row.branch)?, Vec::new()), + } }; pruned_rows.push((row, branch_deleted, orphaned)); } @@ -803,7 +1044,8 @@ fn emit_json( let result = json!({ "pruned": pruned_rows.iter().map(|(row, branch_deleted, orphaned)| json!({ "name": row.name, - "path": row.wt.path().to_str(), + "kind": if row.wt.is_some() { "worktree" } else { "branch" }, + "path": row.wt.and_then(|wt| wt.path().to_str()), "branch": row.branch, "reason": reason_display(row), "signals": row.signals.iter().map(|s| s.to_string()).collect::>(), @@ -812,7 +1054,8 @@ fn emit_json( })).collect::>(), "skipped": skipped.iter().map(|(row, reason)| json!({ "name": row.name, - "path": row.wt.path().to_str(), + "kind": if row.wt.is_some() { "worktree" } else { "branch" }, + "path": row.wt.and_then(|wt| wt.path().to_str()), "branch": row.branch, "reason": reason, "signals": row.signals.iter().map(|s| s.to_string()).collect::>(), @@ -834,14 +1077,16 @@ struct PruneCandidate { skip_branch_delete: bool, } -fn to_candidate(row: &PruneRow) -> PruneCandidate { - PruneCandidate { +/// Build the worktree-prune candidate for a row, or `None` for a branch-only row +/// (there's no worktree to remove; see [`prune_branch`] instead). +fn to_candidate(row: &PruneRow) -> Option { + let wt = row.wt?; + Some(PruneCandidate { worktree_name: row.name.clone(), - worktree_path: row.wt.path().to_path_buf(), + worktree_path: wt.path().to_path_buf(), branch_name: row.branch.clone(), - skip_branch_delete: row.signals.contains(&Signal::BranchDeleted) - || row.branch.starts_with('('), - } + skip_branch_delete: worktree_skip_branch_delete(row), + }) } fn prune_worktree( @@ -899,6 +1144,31 @@ fn prune_worktree( Ok(branch_deleted) } +/// Delete a branch-only row's local branch ref. Sibling of `prune_worktree`'s branch +/// delete step, minus the worktree removal and its "prune the worktree before the +/// branch" ordering (libgit2 refuses to delete a checked-out branch, but a +/// branch-only row is never checked out — that's what excludes it from `checked_out` +/// during candidate enumeration). Returns `false`, without warning, if the branch is +/// already gone by the time this runs. +fn prune_branch(repo: &git2::Repository, name: &str) -> Result { + let deleted = match repo.find_branch(name, git2::BranchType::Local) { + Ok(mut branch) => match branch.delete() { + Ok(()) => true, + Err(e) => { + output::warn(&format!("could not delete branch '{}': {}", name, e)); + false + } + }, + Err(_) => false, + }; + + if deleted { + output::success(&format!(" Deleted branch '{}'", name)); + } + + Ok(deleted) +} + /// Best-effort cleanup of namespace directories left empty by a prune: ascend from /// the removed worktree's parent toward the workon root, removing each directory /// until one is non-empty or the root is reached. Never fails the prune — a diff --git a/git-workon/tests/suite/prune.rs b/git-workon/tests/suite/prune.rs index 1390c7cc..eefe6763 100644 --- a/git-workon/tests/suite/prune.rs +++ b/git-workon/tests/suite/prune.rs @@ -2864,3 +2864,445 @@ fn prune_dry_run_ignores_merged_pr_without_head_oid() -> Result<(), Box Result> { + let repo = fixture.repo()?; + let head_commit = repo + .find_branch(branch, git2::BranchType::Local)? + .get() + .peel_to_commit()?; + let sig = git2::Signature::now("Test User", "test@example.com")?; + let new_oid = repo.commit( + None, + &sig, + &sig, + message, + &head_commit.tree()?, + &[&head_commit], + )?; + repo.find_branch(branch, git2::BranchType::Local)? + .get_mut() + .set_target(new_oid, message)?; + Ok(new_oid) +} + +#[test] +fn prune_stack_deletes_worktree_and_sibling_branch_via_pr_merged( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .remote("origin", "https://github.com/test/test.git") + .branch("feat-1") + .worktree("feat-2") + .build()?; + + let feat_1_tip = advance_branch_without_worktree(&fixture, "feat-1", "feat-1 commit")?; + let feat_2_tip = fixture + .commit("feat-2") + .file("feat-2.txt", "feat-2") + .create("feat-2 commit")?; + + let stub = PathStub::new()?.binary( + "gh", + &gh_stub_pr_outcomes(&[ + ("feat-1", GhOutcome::Merged(1, feat_1_tip.to_string())), + ("feat-2", GhOutcome::Merged(2, feat_2_tip.to_string())), + ]), + )?; + + let feat_2_dir = fixture.cwd()?; + feat_2_dir.assert(predicate::path::is_dir()); + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("PATH", stub.path()) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains( + "Pruned 1 worktree(s) and deleted 2 branch(es)", + )); + + feat_2_dir.assert(predicate::path::missing()); + fixture.assert(predicate::repo::has_branch("feat-1").not()); + fixture.assert(predicate::repo::has_branch("feat-2").not()); + + Ok(()) +} + +#[test] +fn prune_branch_only_remote_gone_requires_gone_flag() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .remote("origin", "/dev/null") + .branch("feat") + .upstream("feat", "origin/feat") + .build()?; + + fixture + .repo()? + .find_reference("refs/remotes/origin/feat")? + .delete()?; + + // Without --gone, the row is visible (RemoteGone is a real signal) but not + // active, so it's merely selectable in the dry-run analysis. + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--dry-run") + .assert() + .success() + .stderr(predicate::str::contains("[selectable]")) + .stderr(predicate::str::contains("(no worktree)")) + .stderr(predicate::str::contains("remote gone")); + + fixture.assert(predicate::repo::has_branch("feat")); + + // With --gone, it's active and gets deleted. + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--gone") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains("deleted 1 branch(es)")); + + fixture.assert(predicate::repo::has_branch("feat").not()); + + Ok(()) +} + +#[test] +fn prune_branch_only_merged_removes_fast_forwarded_branch() -> Result<(), Box> +{ + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("feat") + .build()?; + + // Diverge feat from main, then fast-forward main onto it — the same + // fast-forward-merge shape the existing worktree-level Merged tests use. + let feat_tip = advance_branch_without_worktree(&fixture, "feat", "feat commit")?; + fixture.update_branch("main", feat_tip)?; + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--merged") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains("deleted 1 branch(es)")) + .stderr(predicate::str::contains("merged into main")); + + fixture.assert(predicate::repo::has_branch("feat").not()); + + Ok(()) +} + +#[test] +fn prune_branch_only_row_excludes_default_branch() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .worktree("feature") + .build()?; + + // "main" has no worktree of its own here, so it can only ever be reached as a + // branch-only row — and it's excluded from the branch-only candidate pool + // entirely, so naming it is a hard miss rather than a healthy/locked row. + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .arg("prune") + .arg("main") + .arg("--dry-run") + .assert() + .failure() + .stderr(predicate::str::contains("worktree(s) not found: main")); + + Ok(()) +} + +#[test] +fn prune_branch_only_row_protected_glob_locked_out_and_survives_yes( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("release/v1") + .config("workon.pruneProtectedBranches", "release/*") + .build()?; + + // release/v1 sits at the same commit as main, so it carries a Merged signal + // unconditionally — bare mode only hides signal-less rows — making it visible + // in dry-run without needing --merged at all. + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--dry-run") + .assert() + .success() + .stderr(predicate::str::contains("[locked out]")) + .stderr(predicate::str::contains("(no worktree)")); + + // Even an active --merged --yes run leaves a protected branch alone. + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--merged") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains( + "protected by workon.pruneProtectedBranches", + )); + + fixture.assert(predicate::repo::has_branch("release/v1")); + + Ok(()) +} + +#[test] +fn prune_no_branches_flag_ignores_branch_only_rows() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("merged-branch") + .build()?; + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--no-branches") + .arg("--merged") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains("No worktrees to prune")); + + fixture.assert(predicate::repo::has_branch("merged-branch")); + + Ok(()) +} + +#[test] +fn prune_branches_config_false_ignores_branch_only_rows() -> Result<(), Box> +{ + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("merged-branch") + .config("workon.pruneBranches", "false") + .build()?; + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--merged") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains("No worktrees to prune")); + + fixture.assert(predicate::repo::has_branch("merged-branch")); + + Ok(()) +} + +#[test] +fn prune_keep_branch_drops_branch_only_rows_from_dry_run() -> Result<(), Box> +{ + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("merged-branch") + .build()?; + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--keep-branch") + .arg("--dry-run") + .assert() + .success() + .stderr(predicate::str::contains("No worktrees to prune")) + .stderr(predicate::str::contains("merged-branch").not()); + + Ok(()) +} + +#[test] +fn prune_named_healthy_branch_only_row_skipped_without_force( +) -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("healthy") + .build()?; + + // `--merged=` keeps the Merged signal from firing against main + // (the branch sits at main's tip, which would otherwise always count as merged), + // while the unmerged check still compares against the real default branch and + // finds nothing wrong — the same trick the worktree-level healthy-row test uses. + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .arg("prune") + .arg("healthy") + .arg("--merged=develop-does-not-exist") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains("not prunable")) + .stderr(predicate::str::contains("No worktrees to prune")); + + fixture.assert(predicate::repo::has_branch("healthy")); + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .arg("prune") + .arg("healthy") + .arg("--merged=develop-does-not-exist") + .arg("--force") + .arg("--yes") + .assert() + .success() + .stderr(predicate::str::contains("deleted 1 branch(es)")); + + fixture.assert(predicate::repo::has_branch("healthy").not()); + + Ok(()) +} + +/// The consecutive-gh-failure bound (see the PR-merged signal tests above) has to +/// trip before the worktree rows are exhausted, so branch rows appended after them +/// never get looked up at all in this shape. +#[test] +fn prune_gh_bound_holds_with_branch_rows_appended() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .remote("origin", "https://github.com/test/test.git") + .worktree("feature-one") + .worktree("feature-two") + .worktree("feature-three") + .worktree("feature-four") + .worktree("feature-five") + .branch("branch-one") + .branch("branch-two") + .build()?; + + for branch in [ + "feature-one", + "feature-two", + "feature-three", + "feature-four", + "feature-five", + ] { + fixture + .commit(branch) + .file(&format!("{branch}.txt"), branch) + .create("Feature commit")?; + } + advance_branch_without_worktree(&fixture, "branch-one", "branch-one commit")?; + advance_branch_without_worktree(&fixture, "branch-two", "branch-two commit")?; + + let stub = PathStub::new()?.binary("gh", &gh_stub_pr_list_fails())?; + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + prune_cmd + .current_dir(&fixture) + .env("PATH", stub.path()) + .env("NO_COLOR", "1") + .arg("prune") + .arg("--dry-run") + .assert() + .success(); + + let invocations = stub.invocations("gh"); + let pr_list_calls = invocations + .iter() + .filter(|line| line.starts_with("pr list")) + .count(); + assert_eq!( + pr_list_calls, 3, + "worktree rows should exhaust the bound before any branch row is looked up, got {pr_list_calls}" + ); + assert!( + !invocations + .iter() + .any(|line| line.contains("branch-one") || line.contains("branch-two")), + "no gh call should reference a branch-only row once the bound has tripped: {invocations:?}" + ); + + Ok(()) +} + +#[test] +fn prune_branch_row_json_reports_kind_and_null_path() -> Result<(), Box> { + let fixture = FixtureBuilder::new() + .bare(true) + .default_branch("main") + .branch("merged-branch") + .build()?; + + let mut prune_cmd = cargo_bin_cmd!("git-workon"); + let output = prune_cmd + .current_dir(&fixture) + .arg("prune") + .arg("--merged") + .arg("--yes") + .arg("--json") + .output()?; + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout)?; + let json: serde_json::Value = serde_json::from_str(&stdout)?; + let pruned = json["pruned"].as_array().expect("pruned should be array"); + assert_eq!(pruned.len(), 1); + assert_eq!(pruned[0]["kind"], serde_json::json!("branch")); + assert_eq!(pruned[0]["path"], serde_json::json!(null)); + assert_eq!(pruned[0]["branch_deleted"], serde_json::json!(true)); + + fixture.assert(predicate::repo::has_branch("merged-branch").not()); + + Ok(()) +} From 8d269575aa0a79f4940eb39db90d59a471cd51df Mon Sep 17 00:00:00 2001 From: Eric Eldredge Date: Wed, 9 Sep 2026 13:05:25 -0400 Subject: [PATCH 3/3] docs(cli): document prune branch-only rows --- CONTEXT.md | 7 +++++ README.md | 10 +++++-- docs/adr/014-prune-three-phase-safety.md | 15 +++++++--- docs/diagrams/prune-flow.md | 37 ++++++++++++++---------- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f7899f94..1e018ddc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,4 +20,11 @@ Terms used throughout the `git-workon` codebase. Implementation details do not b **Merged(target)** — the worktree's branch has been merged into `target`. Only a candidate when `--merged` is passed. +**PrMerged(number)** — `gh` reports a merged pull request whose head covers the branch's current tip. Always a candidate, gated only on a GitHub remote and `gh` being usable. + **Explicit** — the worktree was named directly as a positional argument to `prune`. + +**Branch-only row** — a local branch with no worktree, evaluated by `prune` for the same +candidate reasons as a worktree row (default on; `--no-branches` / `workon.pruneBranches = +false` opts out). It never has a working tree, so status filters on `list`/`find` are +unaffected. diff --git a/README.md b/README.md index 2f99cd0e..88415ebb 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ git workon list --dirty --ahead # filters combine with AND logic `prune` always analyzes every worktree in scope for every signal (branch deleted, remote gone, merged into target, PR merged) — `--gone`/`--merged` don't hide anything, they just decide what counts as an *active* criterion for pre-checking and auto-pruning. A branch-deleted worktree and a worktree whose branch tip is the head of a merged PR are always active. By default, pruning deletes the local branch ref along with the worktree; use `--keep-branch` to preserve it. +Local branches with no worktree checked out are candidates too (default on; `--no-branches` / `workon.pruneBranches = false` opts out), evaluated for the same signals. This is what cleans up a merged stack's sibling branches once the worktree on the stack's other branch is pruned. + ```sh git workon prune # interactive: multi-select picker, pre-checked with the safe default git workon prune --yes # skip the picker; prune exactly the pre-checked set (for scripting) @@ -121,6 +123,7 @@ git workon prune --gone # treat gone-upstream worktrees as active (pre- git workon prune --gone --fetch # fetch --prune from remotes first so gone status is fresh git workon prune --merged # treat merged-into-default worktrees as active git workon prune --merged=release/v2 # merge target other than the default branch +git workon prune --no-branches # only consider worktrees, skip branches with no worktree git workon prune --keep-branch # prune worktrees but keep local branch refs git workon prune --allow-dirty # prune even with uncommitted changes git workon prune --allow-unmerged # prune even with unmerged commits @@ -128,11 +131,13 @@ git workon prune --include-locked # include locked worktrees git workon prune --force # override all safety checks (protection, dirty, unmerged, locked) ``` -Naming a worktree strictly narrows the scope — it's never additive with `--gone`/`--merged`. An unmatched name is a hard error listing every miss, before anything is deleted. A named worktree with nothing wrong with it (no signal, not dirty, not unmerged) still shows up — annotated "not prunable" — but needs `--force` to actually be pruned; naming is how a healthy worktree gets pulled into view, not how it gets deleted. The default worktree never appears, even when named with `--force`. +Naming a worktree or a local branch with no worktree strictly narrows the scope — it's never additive with `--gone`/`--merged`. An unmatched name is a hard error listing every miss, before anything is deleted. A named row with nothing wrong with it (no signal, not dirty, not unmerged) still shows up — annotated "not prunable" — but needs `--force` to actually be pruned; naming is how a healthy row gets pulled into view, not how it gets deleted. The default branch never appears, even when named with `--force`, whether or not it has a worktree. In an interactive terminal, `prune` opens a checkbox picker (pre-checked rows match the same "safe default" `--yes` would prune) followed by one summary confirm. Non-interactively (`--yes`, `--json`, or no TTY), it prunes the pre-checked set directly. -Safety checks (skipped with `--force`): protected branches (`workon.pruneProtectedBranches`), locked worktrees (`--include-locked`), uncommitted changes (tracked files only when the only signal is a gone upstream; `--allow-dirty`), unmerged commits (skipped when any signal is present; `--allow-unmerged`). +Safety checks (skipped with `--force`): protected branches (`workon.pruneProtectedBranches`), locked worktrees (`--include-locked`), uncommitted changes (tracked files only when the only signal is a gone upstream; `--allow-dirty`), unmerged commits (skipped when any signal is present; `--allow-unmerged`). A branch-only row has no working tree, so it's never dirty or locked. + +`--json` reports `"kind": "worktree"` or `"kind": "branch"` on every `pruned`/`skipped` entry, with `"path": null` for a branch-only row. ### Rename a worktree @@ -248,6 +253,7 @@ man git-workon pruneProtectedBranches = release/* pruneGone = false # prune gone-upstream worktrees by default pruneFetch = false # fetch from remotes before evaluating gone status + pruneBranches = true # also consider local branches with no worktree # Stacked diffs (Graphite or gh-stack) stackModel = auto # "auto", "graphite", "gh-stack", "git", or "none" diff --git a/docs/adr/014-prune-three-phase-safety.md b/docs/adr/014-prune-three-phase-safety.md index 88243b8e..1dfa5d47 100644 --- a/docs/adr/014-prune-three-phase-safety.md +++ b/docs/adr/014-prune-three-phase-safety.md @@ -4,6 +4,9 @@ > `--gone`/`--merged`, list+confirm, and a gone-upstream hint). The safety-check > ordering and override flags are unchanged; what changed is *visibility* and the > interaction model. +> +> Amended 2026-09-09: the candidate pool now also includes local branches with no +> worktree, evaluated for the same signals. See "Branch-only rows" below. ## Context @@ -25,7 +28,9 @@ v2 collapses this into one analysis pipeline that always runs in full, with `--g - **`--dry-run`**: prints the same annotated analysis — pre-checked / selectable / locked-out, each with its signals — and exits. No picker, no deletion. - **Non-interactive** (`--yes`, `--json`, or no TTY): bare mode prunes exactly the pre-checked set (unchanged from the old `--yes` behavior). Named mode prunes named worktrees when safe, per the rules above. -`--json` extends the existing envelope (`pruned`/`skipped`/`dry_run`) with a `signals` array per entry; `--dry-run --json` populates `pruned` with the would-be-pruned set and leaves `dry_run: true` without deleting anything, same as before. +`--json` extends the existing envelope (`pruned`/`skipped`/`dry_run`) with a `signals` array per entry; `--dry-run --json` populates `pruned` with the would-be-pruned set and leaves `dry_run: true` without deleting anything, same as before. Each entry also carries `"kind": "worktree"` or `"kind": "branch"`, with `"path": null` for a branch-only row. + +**Branch-only rows** (added 2026-09-09): the candidate pool also includes every local branch with no worktree (minus the default branch, protected globs, and any branch checked out in a worktree), on by default (`--no-branches` / `workon.pruneBranches = false` opts out). This exists because a merged stack's sibling branches are left behind once the worktree on the stack's other branch is pruned: gh-stack never sets `branch..remote`, so `RemoteGone` can't fire on those branches, and a squash merge defeats `Merged`. `PrMerged` is often the only signal that can reach them. Branch rows are evaluated for the same signals as worktree rows, checked against the repo directly (there's no worktree path to open) rather than through `WorktreeDescriptor`. `locked` and `dirty` are always false for a branch-only row (there's no working tree to lock or dirty), so only the protected and unmerged safety checks apply, and `--force` overrides protection the same as it would for a worktree row. `--keep-branch` drops branch-only rows entirely before they're shown: there's nothing to keep-branch about a row whose only action is deleting the branch. ## Consequences @@ -35,11 +40,13 @@ v2 collapses this into one analysis pipeline that always runs in full, with `--g - Breaking UX change: `prune ` combined with `--gone`/`--merged` used to also sweep in filter-matched worktrees; naming now strictly narrows, never adds. - Fetch narrows to remotes tracked by named worktrees when names are given, reducing unnecessary network calls. - The interactive experience moves from "read a static list, type y/n" to "toggle checkboxes, confirm once" — more control, at the cost of one more keystroke for the default case (still just Enter, Enter). +- Breaking UX change: `--json` entries now carry a `kind` field, and a branch-only entry's `path` is `null` instead of a string. ## References - `docs/diagrams/prune-flow.md` — full flow diagram -- `git-workon/src/cmd/prune.rs` — `Signal`, `PruneRow`, `classify`, `run_interactive` -- `git-workon-lib/src/fetch.rs` — `remotes_tracked_by_worktrees`, `prune_fetch` -- `git-workon-lib/src/config.rs` — `WorkonConfig::prune_gone`, `WorkonConfig::prune_fetch` +- `git-workon/src/cmd/prune.rs` — `Signal`, `PruneRow`, `classify`, `run_interactive`, `build_branch_row` +- `git-workon-lib/src/branch.rs` — `branch_has_gone_upstream`, `branch_is_merged_into`, `branch_tip_at_or_behind` (branch-only-row signal checks) +- `git-workon-lib/src/fetch.rs` — `remotes_tracked_by_worktrees`, `remotes_tracked_by_branches`, `prune_fetch` +- `git-workon-lib/src/config.rs` — `WorkonConfig::prune_gone`, `WorkonConfig::prune_fetch`, `WorkonConfig::prune_branches` - `git-workon-lib/src/error.rs` — `PruneError::NamesNotFound` diff --git a/docs/diagrams/prune-flow.md b/docs/diagrams/prune-flow.md index 7fe73a11..a738b171 100644 --- a/docs/diagrams/prune-flow.md +++ b/docs/diagrams/prune-flow.md @@ -1,31 +1,34 @@ # Prune Command (Always-On Analysis + Picker) -`prune` runs one analysis pass over every worktree in scope, then dispatches to one of three interaction modes. `--gone`/`--merged` only decide which signals are "active" (pre-checked / auto-pruned); they never hide a row from the analysis. +`prune` runs one analysis pass over every worktree in scope, plus (by default) every local branch with no worktree, then dispatches to one of three interaction modes. `--gone`/`--merged` only decide which signals are "active" (pre-checked / auto-pruned); they never hide a row from the analysis. `--no-branches` / `workon.pruneBranches = false` drops the branch-only rows before analysis even starts. ```mermaid flowchart TD - START([git workon prune]) --> SETUP["get_repo()\nget_worktrees()\nload WorkonConfig + pruneProtectedBranches\nresolve effective_gone / effective_fetch"] - - SETUP --> SCOPE{names given?} - SCOPE -->|no| POOL["scope = every worktree\nexcept the default one"] - SCOPE -->|yes| MATCH["match each name by\nworktree name or branch name\n(against the same pool, default excluded)"] + START([git workon prune]) --> SETUP["get_repo()\nget_worktrees()\nload WorkonConfig + pruneProtectedBranches\nresolve effective_gone / effective_fetch / effective_branches"] + + SETUP --> BPOOL{effective_branches\n&& !keep_branch?} + BPOOL -->|yes| BRANCHPOOL["branch_pool = every local branch\nwith no worktree, minus checked_out\n(every worktree's branch, not just the pool)\nand the default branch"] + BPOOL -->|no| SCOPE + BRANCHPOOL --> SCOPE{names given?} + SCOPE -->|no| POOL["scope = every worktree\nexcept the default one\nbranch_scope = branch_pool"] + SCOPE -->|yes| MATCH["match each name against the worktree pool\nfirst, then branch_pool by branch name\n(default branch excluded from both)"] MATCH --> MISS{any name\nunmatched?} MISS -->|yes| ERR["hard error: PruneError::NamesNotFound\nlists ALL misses — nothing touched\nnonzero exit"] - MISS -->|no| NAMED["scope = exactly the matched worktrees"] + MISS -->|no| NAMED["scope = matched worktrees\nbranch_scope = matched branches"] POOL --> FETCH NAMED --> FETCH subgraph FETCH0["Phase 0 (optional) — Prune-fetch"] FETCH{effective_fetch?} - FETCH -->|yes| REMOTES["remotes tracked by scope\n(narrowed to named worktrees\nwhen names given)"] + FETCH -->|yes| REMOTES["remotes tracked by scope + branch_scope\n(narrowed to named worktrees/branches\nwhen names given)"] REMOTES --> DOFETCH["git fetch --prune per remote\nfailure: warn + continue on cached refs"] FETCH -->|no| ANALYZE DOFETCH --> ANALYZE end subgraph ANALYSIS["Analysis — every row in scope, always"] - ANALYZE["build_row() per worktree:\nsignals: BranchDeleted | RemoteGone | Merged(target) | PrMerged(number)\n+ protected / locked / dirty / unmerged"] + ANALYZE["build_row() per worktree, then\nbuild_branch_row() per branch_scope entry\n(worktree rows always first — the gh pass\nbelow visits rows in order)\nsignals: BranchDeleted | RemoteGone | Merged(target) | PrMerged(number)\n+ protected / locked / dirty / unmerged\n(branch rows: locked/dirty always false)"] ANALYZE --> VISIBLE{bare mode?} VISIBLE -->|yes| FILTERSIG["keep only rows with >=1 signal"] VISIBLE -->|no named| KEEPALL["keep every named row\n(signal or not)"] @@ -44,7 +47,7 @@ flowchart TD subgraph PICKER_BLOCK["Interactive picker"] PICKER["locked-out rows (protected/locked,\nnot overridden) -> printed list, not selectable"] PICKER --> MULTI["picker::multi_select over selectable rows\n(find/list row style + dim prune annotation;\nspace: toggle, a: all, enter: confirm)\ndefaults = pre-checked per active-criteria + safety"] - MULTI --> SUMMARY["one summary confirm:\n'N worktree(s) and their branches will be deleted'\n+ dirty/unmerged + orphaned-stash warnings"] + MULTI --> SUMMARY["one summary confirm:\n'N worktree(s) and their branches will be deleted'\n(branch rows annotated 'no worktree, ...')\n+ dirty/unmerged + orphaned-stash warnings"] SUMMARY -->|confirmed| EXEC SUMMARY -->|declined| CANCEL(["Cancelled"]) end @@ -65,12 +68,12 @@ flowchart TD end subgraph EXEC_BLOCK["Execution"] - EXEC["for each: remove_dir_all\nworktree.prune()\ndelete local branch ref\n(unless --keep-branch or BranchDeleted signal)"] - EXEC --> ORPHAN["warn per orphaned stash\n(collect_orphaned_stashes)"] + EXEC["worktree row: remove_dir_all -> worktree.prune()\n-> delete local branch ref\n(unless --keep-branch or BranchDeleted signal)\nbranch-only row: prune_branch() deletes\nthe branch ref directly, no ordering guard needed"] + EXEC --> ORPHAN["warn per orphaned stash\n(collect_orphaned_stashes — worktree rows only)"] end TOPRUNE --> JSONQ{--json?} - JSONQ -->|yes| JSONOUT["emit {pruned, skipped, dry_run}\neach entry includes 'signals'\ndry-run: list without deleting"] + JSONQ -->|yes| JSONOUT["emit {pruned, skipped, dry_run}\neach entry includes 'signals' and 'kind'\n('worktree' or 'branch'; branch rows: 'path': null)\ndry-run: list without deleting"] JSONQ -->|no| CONFIRM{--yes?} CONFIRM -->|no| DIALOG["dialoguer::Confirm\n(only reachable non-TTY, no --yes)"] DIALOG -->|confirmed| EXEC @@ -96,11 +99,15 @@ flowchart TD A row can carry more than one signal (e.g. a fresh worktree off the default branch is always trivially `Merged(default)`). `reason_display()`/`annotate()` join every signal present, not just the active ones. +Branch-only rows (a local branch with no worktree) carry the same three real signals (`RemoteGone`, `Merged(target)`, `PrMerged(number)`), checked against the branch directly via `git-workon-lib/src/branch.rs` instead of through `WorktreeDescriptor`. `BranchDeleted` is moot for a branch-only row: the branch enumeration only ever considers branches that still exist, so there's no "branch ref no longer exists" state to detect. + ## Key files -- `git-workon/src/cmd/prune.rs` — `Signal`, `PruneRow`, `build_row`, `classify`, `is_prechecked`, `run_interactive`, `render_dry_run`, `emit_json` +- `git-workon/src/cmd/prune.rs` — `Signal`, `PruneRow`, `build_row`, `build_branch_row`, `classify`, `is_prechecked`, `run_interactive`, `render_dry_run`, `emit_json`, `prune_branch` - `git-workon/src/picker.rs` — `multi_select` (checkbox pick loop shared with the `find` picker's terminal handling) - `git-workon/src/display.rs` — `worktree_display_row`, `format_aligned_rows_annotated` (find/list row style + trailing prune annotation) - `git-workon-lib/src/worktree.rs` — `is_dirty()`, `has_tracked_changes()`, `is_merged_into()`, `has_gone_upstream()`, `is_locked()` -- `git-workon-lib/src/config.rs` — `prune_protected_branches()`, `prune_gone()`, `prune_fetch()` +- `git-workon-lib/src/branch.rs` — `branch_has_gone_upstream()`, `branch_is_merged_into()`, `branch_tip_at_or_behind()` (the same three checks, against a branch with no worktree) +- `git-workon-lib/src/fetch.rs` — `remotes_tracked_by_worktrees()`, `remotes_tracked_by_branches()` +- `git-workon-lib/src/config.rs` — `prune_protected_branches()`, `prune_gone()`, `prune_fetch()`, `prune_branches()` - `git-workon-lib/src/error.rs` — `PruneError::NamesNotFound`