use std::collections::HashSet;
use std::path::Path;
use crate::cli::PruneArgs;
use crate::error::Result;
use crate::git::aheadbehind::is_recoverable;
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::git::merged::is_content_merged;
use crate::git::porcelain::RawWorktree;
use crate::git::worktrees::in_progress_op;
use crate::git::{
branch_ref, default_branch, default_tracking_ref, is_ancestor, is_clean_for_removal,
local_branches, resolve_hex, upstream_of,
};
use crate::model::Worktree;
pub(super) struct MergeTargets {
pub(super) default: Option<String>,
pub(super) refs: Vec<String>,
}
impl MergeTargets {
pub(super) fn resolve(repo: &Repo) -> Self {
let default = default_branch(repo.gix());
let refs = default
.as_deref()
.map(branch_ref)
.into_iter()
.chain(default_tracking_ref(repo.gix()))
.filter(|r| resolve_hex(repo.gix(), r).is_some())
.collect();
MergeTargets { default, refs }
}
pub(super) fn is_default(&self, branch: &str) -> bool {
self.default.as_deref() == Some(branch)
}
fn has_ancestor_merge(&self, repo: &Repo, tip: &str) -> bool {
self.refs
.iter()
.any(|target| is_ancestor(repo.gix(), tip, target))
}
fn has_content_merge(&self, git: &dyn GitCli, root: &Path, tip: &str) -> bool {
self.refs
.iter()
.any(|target| is_content_merged(git, root, tip, target))
}
pub(super) fn is_merged(&self, git: &dyn GitCli, root: &Path, repo: &Repo, tip: &str) -> bool {
self.has_ancestor_merge(repo, tip) || self.has_content_merge(git, root, tip)
}
fn local_default_ref(&self) -> Option<&str> {
let local = branch_ref(self.default.as_deref()?);
self.refs.iter().find(|r| **r == local).map(String::as_str)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Reason {
Merged,
MergedByContent,
UpstreamGone,
Missing,
Pushed,
}
impl Reason {
pub(super) fn label(self) -> &'static str {
match self {
Reason::Merged => "merged",
Reason::MergedByContent => "merged by content",
Reason::UpstreamGone => "upstream gone",
Reason::Missing => "missing",
Reason::Pushed => "pushed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Block {
Current,
InProgress(&'static str),
Unreadable,
HolderUnreadable(String),
Held {
activity: &'static str,
worktree: String,
},
Locked(Option<String>),
Dirty,
Unsafe,
}
impl Block {
pub(super) fn message(&self) -> String {
match self {
Block::Current => "it is the current worktree".into(),
Block::InProgress(op) => format!("{op} in progress"),
Block::Unreadable => "cannot read its git state".into(),
Block::HolderUnreadable(worktrees) => {
format!("cannot read the rebase or bisect state of {worktrees}")
}
Block::Held { activity, worktree } => format!("{activity} in {worktree}"),
Block::Locked(Some(reason)) => format!("locked ({reason}); use --locked"),
Block::Locked(None) => "locked; use --locked".into(),
Block::Dirty => "uncommitted changes; use --force".into(),
Block::Unsafe => {
"has work on no remote and not in the default branch; use --force".into()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Subject {
Worktree {
index: usize,
locked: bool,
head: Option<String>,
},
Branch {
name: String,
merged: bool,
safe: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct Verdict {
pub(super) subject: Subject,
pub(super) reason: Reason,
pub(super) block: Option<Block>,
}
pub(super) struct Assessor<'a> {
pub(super) git: &'a dyn GitCli,
pub(super) root: &'a Path,
pub(super) repo: &'a Repo,
pub(super) args: &'a PruneArgs,
pub(super) targets: &'a MergeTargets,
}
impl Assessor<'_> {
pub(super) fn worktree(
&self,
index: usize,
worktree: &Worktree,
raw: Option<&RawWorktree>,
) -> Option<Verdict> {
if worktree.is_main {
return None;
}
let tip = match &worktree.branch {
Some(branch) => Some(branch_ref(branch)),
None => raw.and_then(|r| r.head.clone()),
};
let reason = self.worktree_reason(worktree, tip.as_deref())?;
let locked = raw.is_some_and(|r| r.is_locked);
let block = self
.worktree_block(worktree, raw)
.or_else(|| self.detached_unsafe(worktree, tip.as_deref()));
tracing::trace!(target_wt = %worktree.path.display(), ?reason, ?block, "prune: worktree classified");
let head = if worktree.branch.is_none() { tip } else { None };
Some(Verdict {
subject: Subject::Worktree {
index,
locked,
head,
},
reason,
block,
})
}
fn detached_unsafe(&self, worktree: &Worktree, tip: Option<&str>) -> Option<Block> {
if self.args.force || worktree.branch.is_some() {
return None;
}
let safe = tip.is_some_and(|tip| {
self.recoverable(tip) || self.targets.is_merged(self.git, self.root, self.repo, tip)
});
(!safe).then_some(Block::Unsafe)
}
fn worktree_reason(&self, worktree: &Worktree, tip: Option<&str>) -> Option<Reason> {
let args = self.args;
let mergeable = tip.filter(|_| {
!worktree
.branch
.as_deref()
.is_some_and(|b| self.targets.is_default(b))
});
if args.includes_merged()
&& let Some(tip) = mergeable
&& self.targets.has_ancestor_merge(self.repo, tip)
{
return Some(Reason::Merged);
}
if args.includes_gone() {
if worktree.is_missing {
return Some(Reason::Missing);
}
if self.upstream_gone(worktree.branch.as_deref()) {
return Some(Reason::UpstreamGone);
}
}
if args.includes_merged()
&& let Some(tip) = mergeable
&& self.targets.has_content_merge(self.git, self.root, tip)
{
return Some(Reason::MergedByContent);
}
if args.includes_pushed()
&& worktree.is_detached
&& let Some(tip) = tip
&& self.recoverable(tip)
{
return Some(Reason::Pushed);
}
None
}
fn worktree_block(&self, worktree: &Worktree, raw: Option<&RawWorktree>) -> Option<Block> {
if worktree.is_current {
return Some(Block::Current);
}
let Some(raw) = raw else {
tracing::warn!(target_wt = %worktree.path.display(), "prune: no worktree record");
return Some(Block::Unreadable);
};
match in_progress_op(self.git, self.root, &worktree.path, worktree.is_missing) {
Ok(Some(op)) => return Some(Block::InProgress(op)),
Ok(None) => {}
Err(error) => {
tracing::warn!(target_wt = %worktree.path.display(), %error, "prune: cannot read worktree state");
return Some(Block::Unreadable);
}
}
if !self.args.locked && raw.is_locked {
return Some(Block::Locked(raw.lock_reason.clone()));
}
if !self.args.force
&& !worktree.is_missing
&& !is_clean_for_removal(self.git, &worktree.path)
{
return Some(Block::Dirty);
}
None
}
pub(super) fn branches(
&self,
current: Option<&str>,
excluded: &HashSet<String>,
) -> Result<Vec<Verdict>> {
let mut out = Vec::new();
for name in local_branches(self.repo.gix())? {
if excluded.contains(&name)
|| self.targets.is_default(&name)
|| current == Some(name.as_str())
{
continue;
}
if let Some(verdict) = self.branch(name) {
out.push(verdict);
}
}
Ok(out)
}
fn branch(&self, name: String) -> Option<Verdict> {
let args = self.args;
let tip = branch_ref(&name);
let ancestor = self.targets.has_ancestor_merge(self.repo, &tip);
let gone = self.upstream_gone(Some(&name));
let content = !ancestor
&& args.includes_merged()
&& self.targets.has_content_merge(self.git, self.root, &tip);
let by_mode = if args.includes_merged() && ancestor {
Some(Reason::Merged)
} else if content {
Some(Reason::MergedByContent)
} else if args.includes_gone() && gone {
Some(Reason::UpstreamGone)
} else {
None
};
if by_mode.is_none() && !args.includes_pushed() {
return None;
}
let recoverable = self.recoverable(&tip);
let reason = by_mode.or_else(|| recoverable.then_some(Reason::Pushed))?;
let merged = ancestor
|| content
|| (!args.includes_merged()
&& self.targets.has_content_merge(self.git, self.root, &tip));
let safe = recoverable || merged;
let block = (!safe && !args.force).then_some(Block::Unsafe);
tracing::trace!(branch = %name, ?reason, ancestor, content, gone, recoverable, safe, "prune: branch classified");
Some(Verdict {
subject: Subject::Branch { name, merged, safe },
reason,
block,
})
}
pub(super) fn branch_is_safe(&self, name: &str) -> bool {
let tip = branch_ref(name);
self.recoverable(&tip) || self.targets.is_merged(self.git, self.root, self.repo, &tip)
}
fn upstream_gone(&self, branch: Option<&str>) -> bool {
branch
.and_then(|b| upstream_of(self.repo.gix(), b))
.is_some_and(|u| u.is_gone)
}
pub(super) fn recoverable(&self, tip: &str) -> bool {
let keep: Vec<&str> = self.targets.local_default_ref().into_iter().collect();
is_recoverable(self.git, self.root, tip, &keep).unwrap_or_else(|e| {
tracing::warn!(tip, error = %e, "prune: recoverability check failed");
false
})
}
}