pub mod auth_backoff;
pub mod driver;
mod landed;
mod ownership;
pub mod plan;
mod recheck;
pub mod registry;
mod remove;
mod repo_check;
pub mod sweep;
mod tip_relation;
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
use std::path::{Path, PathBuf};
use tracing::info;
pub use driver::{
ClaimEnder, CmdOut, Gh, Git, Landing, RealGh, RealGit, RealLanding, UnavailableClaims,
};
pub(crate) use ownership::{
CallerOwnership, ClaimOwnership, HolderState, holder_state_of, host_tree_gate,
};
pub use plan::{PrView, StepLine, StepStatus};
pub use registry::{CleanupRegistry, CleanupScope, OpenedPr};
use repo_check::repo_mismatch;
pub use sweep::{SweepDecision, sweep_decision};
use crate::session_manager::DirtyWorktree;
const VIEW_FIELDS: &str = "state,headRefName,headRefOid,mergeCommit,baseRefName";
const FALLBACK_BASE: &str = "main";
#[derive(Debug, Clone)]
pub struct CleanupRequest {
pub pr: u64,
pub repo: Option<String>,
pub repo_root: PathBuf,
pub dry_run: bool,
pub head_only: bool,
}
#[derive(Debug, Clone)]
pub struct CleanupReport {
pub pr: u64,
pub lines: Vec<StepLine>,
}
impl CleanupReport {
pub fn failed(&self) -> bool {
self.lines.iter().any(StepLine::is_failure)
}
pub fn render(&self) -> String {
self.lines
.iter()
.map(StepLine::render)
.collect::<Vec<_>>()
.join("\n")
}
}
pub type DirtProbe<'a> = &'a dyn Fn(&Path) -> Option<DirtyWorktree>;
pub async fn run<G: Gh, T: Git, C: ClaimEnder>(
gh: &G,
git: &T,
claims: &C,
landing: &dyn Landing,
probe_dirt: DirtProbe<'_>,
req: &CleanupRequest,
) -> CleanupReport {
let ownership = CallerOwnership::from_env().with_claude(std::sync::Arc::new(
crate::session_manager::worktree_claude_registry::host_claude_registry,
));
run_with(gh, git, claims, landing, probe_dirt, req, &ownership).await
}
pub(crate) async fn run_with<G: Gh, T: Git, C: ClaimEnder>(
gh: &G,
git: &T,
claims: &C,
landing: &dyn Landing,
probe_dirt: DirtProbe<'_>,
req: &CleanupRequest,
ownership: &dyn ClaimOwnership,
) -> CleanupReport {
let mut lines = Vec::new();
let view = match view_pr(gh, req) {
Ok(v) => v,
Err(e) => {
lines.push(StepLine::failed("pr", format!("{e:#}")));
return CleanupReport { pr: req.pr, lines };
}
};
if let Some(reason) = plan::merge_refusal(&view, req.pr) {
lines.push(StepLine::failed("pr", reason));
return CleanupReport { pr: req.pr, lines };
}
let merged_at = view
.merge_commit
.as_ref()
.map(|c| c.oid.trim())
.filter(|o| !o.is_empty())
.unwrap_or("<no merge commit reported>")
.to_string();
lines.push(StepLine::ok(
"pr",
format!(
"#{} is MERGED (head {} at {}, merge commit {merged_at})",
req.pr,
view.head_ref_name,
short(&view.head_ref_oid)
),
));
if let Some(reason) = repo_mismatch(git, req) {
lines.push(StepLine::failed("repo", reason));
return CleanupReport { pr: req.pr, lines };
}
let merged = MergedPr {
merge: landed::Merge::new(&view),
view: &view,
};
lines.push(step_remote_branch(git, req, &view));
let standing = step_worktrees(
git, claims, ownership, landing, probe_dirt, req, &merged, &mut lines,
)
.await;
lines.push(step_local_branches(git, req, &merged, standing.as_deref()));
lines.push(step_prune(git, req));
let report = CleanupReport { pr: req.pr, lines };
info!(
pr = req.pr,
dry_run = req.dry_run,
failed = report.failed(),
"pr cleanup finished"
);
report
}
fn view_pr<G: Gh>(gh: &G, req: &CleanupRequest) -> anyhow::Result<PrView> {
let n = req.pr.to_string();
let mut a = vec!["pr".to_string(), "view".to_string(), n];
if let Some(repo) = req.repo.as_deref().map(str::trim).filter(|r| !r.is_empty()) {
a.push("--repo".to_string());
a.push(repo.to_string());
}
a.push("--json".to_string());
a.push(VIEW_FIELDS.to_string());
let stdout = gh.run(&a)?.stdout_ok(&format!("gh {}", a.join(" ")))?;
serde_json::from_str(&stdout)
.map_err(|e| anyhow::anyhow!("cannot parse `gh pr view {}` JSON: {e}", req.pr))
}
fn step_remote_branch<T: Git>(git: &T, req: &CleanupRequest, view: &PrView) -> StepLine {
const STEP: &str = "remote-branch";
let branch = view.head_ref_name.trim();
if branch.is_empty() {
return StepLine::ok(STEP, "the PR names no head branch; nothing to delete");
}
let listing = match run_git(git, req, &["ls-remote", "--heads", "origin", branch]) {
Ok(s) => s,
Err(e) => return StepLine::failed(STEP, format!("{e:#}")),
};
if listing.trim().is_empty() {
return StepLine::ok(STEP, format!("origin/{branch} is already gone"));
}
if req.dry_run {
return StepLine::ok(STEP, format!("would delete origin/{branch}"));
}
match run_git(git, req, &["push", "origin", "--delete", branch]) {
Ok(_) => StepLine::ok(STEP, format!("deleted origin/{branch}")),
Err(e) => StepLine::failed(STEP, format!("{e:#}")),
}
}
struct MergedPr<'a> {
view: &'a PrView,
merge: landed::Merge,
}
#[allow(clippy::too_many_arguments)]
async fn step_worktrees<T: Git, C: ClaimEnder>(
git: &T,
claims: &C,
ownership: &dyn ClaimOwnership,
landing: &dyn Landing,
probe_dirt: DirtProbe<'_>,
req: &CleanupRequest,
merged: &MergedPr<'_>,
lines: &mut Vec<StepLine>,
) -> Option<Vec<plan::WorktreeEntry>> {
let view = merged.view;
const STEP: &str = "worktree";
let porcelain = match run_git(git, req, &["worktree", "list", "--porcelain"]) {
Ok(s) => s,
Err(e) => {
lines.push(StepLine::failed(STEP, format!("{e:#}")));
return None;
}
};
let entries = plan::parse_worktree_list(&porcelain);
if entries.is_empty() {
lines.push(StepLine::failed(
STEP,
plan::inconclusive_listing(&view.head_ref_name),
));
return None;
}
let mut kept = plan::holders_run_from(
&entries,
&view.head_ref_name,
&view.head_ref_oid,
&req.repo_root,
);
if req.head_only {
kept = plan::split_head_only(kept, &view.head_ref_name).0;
}
for k in &kept {
let line = plan::kept_run_from(k, req.pr, req.head_only);
lines.push(StepLine::failed(STEP, line));
}
let mut targets = plan::worktree_targets(
&entries,
&view.head_ref_name,
&view.head_ref_oid,
&req.repo_root,
);
if req.head_only {
let (named, left) = plan::split_head_only(targets, &view.head_ref_name);
if !left.is_empty() {
lines.push(StepLine::ok(STEP, plan::left_in_place(&left, req.pr)));
}
if named.is_empty() && !left.is_empty() {
return Some(entries);
}
targets = named;
}
if targets.is_empty() {
if kept.is_empty() {
lines.push(StepLine::ok(
STEP,
format!("no worktree holds {}", view.head_ref_name),
));
}
return Some(entries);
}
let mut removed = Vec::new();
for t in targets {
if !is_merged_head(view, t)
&& let Some(branch) = t.branch.as_deref()
&& let Some(refusal) = landed::unlanded(git, req, &merged.merge, branch)
{
let line = StepLine::failed(STEP, format!("{}: {refusal}", t.path.display()));
push_kept(git, req, view, t, line, lines);
continue;
}
let line = remove::remove_one(
git,
claims,
ownership,
landing,
probe_dirt,
req,
&merged.merge,
t,
)
.await;
if line.is_failure() {
push_kept(git, req, view, t, line, lines);
} else {
removed.push(t.path.clone());
lines.push(line);
}
}
Some(
entries
.into_iter()
.filter(|e| !removed.contains(&e.path))
.collect(),
)
}
fn push_kept<T: Git>(
git: &T,
req: &CleanupRequest,
view: &PrView,
entry: &plan::WorktreeEntry,
mut line: StepLine,
lines: &mut Vec<StepLine>,
) {
let (tip, head) = (entry.head.as_str(), view.head_ref_oid.as_str());
let rel = tip_relation::relation(git, req, tip, head);
line.detail.push_str(&tip_relation::note(&rel, tip, head));
lines.push(line);
let shown = entry.path.display().to_string();
if let Some(warning) = tip_relation::unpushed_warning(&shown, &rel, tip, head) {
lines.push(StepLine::failed("worktree", warning));
}
}
fn is_merged_head(view: &PrView, entry: &plan::WorktreeEntry) -> bool {
let head = view.head_ref_name.trim();
let oid = view.head_ref_oid.trim();
(!head.is_empty() && entry.branch.as_deref().map(str::trim) == Some(head))
|| (!oid.is_empty() && entry.head.eq_ignore_ascii_case(oid))
}
fn owned(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| (*s).to_string()).collect()
}
fn step_local_branches<T: Git>(
git: &T,
req: &CleanupRequest,
merged: &MergedPr<'_>,
standing: Option<&[plan::WorktreeEntry]>,
) -> StepLine {
const STEP: &str = "local-branch";
let Some(standing) = standing else {
return StepLine::failed(STEP, plan::HOLDERS_UNKNOWN);
};
let view = merged.view;
let listing = match run_git(
git,
req,
&["branch", "--format=%(refname:short) %(objectname)"],
) {
Ok(s) => s,
Err(e) => return StepLine::failed(STEP, format!("{e:#}")),
};
let head = view.head_ref_name.trim();
if req.head_only {
let wanted = plan::pr_branches(&listing, head)
.into_iter()
.filter(|b| b == head)
.collect();
return delete_branches(git, req, wanted, standing);
}
let siblings = plan::pr_branches(&listing, head);
let mut refusals = Vec::new();
let mut wanted: Vec<String> = siblings
.into_iter()
.filter(|b| {
if b == head {
return true;
}
match landed::unlanded(git, req, &merged.merge, b) {
None => true,
Some(reason) => {
refusals.push(reason);
false
}
}
})
.collect();
for agent in plan::agent_branches_at(&listing, &view.head_ref_oid) {
if !wanted.contains(&agent) {
wanted.push(agent);
}
}
if !refusals.is_empty() {
return StepLine::failed(STEP, refusals.join("; "));
}
delete_branches(git, req, wanted, standing)
}
fn delete_branches<T: Git>(
git: &T,
req: &CleanupRequest,
wanted: Vec<String>,
standing: &[plan::WorktreeEntry],
) -> StepLine {
const STEP: &str = "local-branch";
if wanted.is_empty() {
return StepLine::ok(STEP, "no local branch left to delete");
}
let (mut free, mut problems) = (Vec::new(), Vec::new());
for b in wanted {
match plan::holder_of(standing, &b) {
Some(holder) => problems.push(plan::kept_branch(&b, holder)),
None => free.push(b),
}
}
let mut parts = Vec::new();
if req.dry_run {
if !free.is_empty() {
parts.push(format!("would delete {}", free.join(", ")));
}
} else {
let mut deleted = Vec::new();
for b in free {
match run_git(git, req, &["branch", "-D", &b]) {
Ok(_) => deleted.push(b),
Err(e) => problems.push(format!("`git branch -D {b}` failed: {e:#}")),
}
}
if !deleted.is_empty() {
parts.push(format!("deleted {}", deleted.join(", ")));
}
}
let failed = !problems.is_empty();
parts.extend(problems);
let detail = parts.join("; ");
if failed {
StepLine::failed(STEP, detail)
} else {
StepLine::ok(STEP, detail)
}
}
fn step_prune<T: Git>(git: &T, req: &CleanupRequest) -> StepLine {
const STEP: &str = "prune";
if req.dry_run {
return StepLine::ok(
STEP,
"would run `git worktree prune` and `git fetch --prune origin`",
);
}
if let Err(e) = run_git(git, req, &["worktree", "prune"]) {
return StepLine::failed(STEP, format!("{e:#}"));
}
match run_git(git, req, &["fetch", "--prune", "origin"]) {
Ok(_) => StepLine::ok(STEP, "pruned worktree records and fetched with --prune"),
Err(e) => StepLine::failed(STEP, format!("{e:#}")),
}
}
fn run_git<T: Git>(git: &T, req: &CleanupRequest, args: &[&str]) -> anyhow::Result<String> {
let owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
git.run(&req.repo_root, &owned)?
.stdout_ok(&format!("git {}", args.join(" ")))
}
fn short(oid: &str) -> &str {
let o = oid.trim();
o.get(..8).unwrap_or(o)
}