use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::cli::PruneArgs;
use crate::error::Result;
use crate::git::aheadbehind::recoverable_tips;
use crate::git::cli::GitCli;
use crate::git::discover::Repo;
use crate::git::merged::ContentCheck;
use crate::git::porcelain::RawWorktree;
use crate::git::worktrees::{admin_dir, in_progress_op_in};
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 mut seen: HashSet<String> = HashSet::new();
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_and(|id| seen.insert(id)))
.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, content: &ContentCheck<'_>, tip: &str) -> bool {
self.refs
.iter()
.any(|target| content.is_merged(tip, target))
}
pub(super) fn is_merged(&self, content: &ContentCheck<'_>, repo: &Repo, tip: &str) -> bool {
self.has_ancestor_merge(repo, tip) || self.has_content_merge(content, 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,
Moved,
}
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 could not confirm its changes are in \
the default branch; use --force"
.into(),
Block::Moved => "moved since it was checked; run prune again".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,
pub(super) content: ContentCheck<'a>,
recoverable: RefCell<HashMap<String, bool>>,
admin_dirs: RefCell<HashMap<PathBuf, PathBuf>>,
}
impl<'a> Assessor<'a> {
pub(super) fn new(
git: &'a dyn GitCli,
root: &'a Path,
repo: &'a Repo,
args: &'a PruneArgs,
targets: &'a MergeTargets,
) -> Self {
Assessor {
git,
root,
repo,
args,
targets,
content: ContentCheck::new(git, root),
recoverable: RefCell::new(HashMap::new()),
admin_dirs: RefCell::new(HashMap::new()),
}
}
pub(super) fn prefetch_recoverable<'t>(&self, tips: impl IntoIterator<Item = &'t str>) {
let ids: Vec<String> = tips
.into_iter()
.filter_map(|tip| self.commit_of(tip))
.filter(|id| !self.recoverable.borrow().contains_key(id))
.collect::<HashSet<_>>()
.into_iter()
.collect();
let answers = self.recoverable_now(&ids);
let mut cache = self.recoverable.borrow_mut();
for id in ids {
let recoverable = answers.contains(&id);
cache.insert(id, recoverable);
}
}
pub(super) fn recoverable_now(&self, ids: &[String]) -> HashSet<String> {
if ids.is_empty() {
return HashSet::new();
}
let keep: Vec<&str> = self.targets.local_default_ref().into_iter().collect();
let tips: Vec<&str> = ids.iter().map(String::as_str).collect();
recoverable_tips(self.git, self.root, &tips, &keep).unwrap_or_else(|e| {
tracing::warn!(tips = tips.len(), error = %e, "prune: batched recoverability check failed; checking each");
tips.iter()
.filter(|tip| {
recoverable_tips(self.git, self.root, &[tip], &keep)
.inspect_err(|e| tracing::warn!(tip, error = %e, "prune: recoverability check failed"))
.is_ok_and(|recoverable| recoverable.contains(**tip))
})
.map(|tip| tip.to_string())
.collect()
})
}
fn commit_of(&self, tip: &str) -> Option<String> {
resolve_hex(self.repo.gix(), &format!("{tip}^{{commit}}"))
}
pub(super) fn admin_dir(&self, worktree: &Worktree) -> Result<PathBuf> {
if let Some(dir) = self.admin_dirs.borrow().get(&worktree.path) {
return Ok(dir.clone());
}
let dir = admin_dir(self.git, self.root, &worktree.path, worktree.is_missing)?;
self.admin_dirs
.borrow_mut()
.insert(worktree.path.clone(), dir.clone());
Ok(dir)
}
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.content, 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.content, 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 self.admin_dir(worktree).map(|dir| in_progress_op_in(&dir)) {
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.content, &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.content, &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,
})
}
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 Some(id) = self.commit_of(tip) else {
tracing::warn!(tip, "prune: recoverability check on an unknown tip");
return false;
};
self.prefetch_recoverable([id.as_str()]);
self.recoverable.borrow().get(&id).copied().unwrap_or(false)
}
}