pub mod auth_backoff;
pub mod driver;
mod landed;
pub mod plan;
mod recheck;
pub mod registry;
pub mod sweep;
#[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 use plan::{PrView, StepLine, StepStatus};
pub use registry::{CleanupRegistry, CleanupScope, OpenedPr};
pub use sweep::{SweepDecision, sweep_decision};
use crate::session_manager::DirtyWorktree;
use crate::session_manager::worktree_ownership::{
restore_worktree_sentinel, take_worktree_sentinel,
};
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 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));
step_worktrees(git, claims, landing, probe_dirt, req, &merged, &mut lines).await;
lines.push(step_local_branches(git, req, &merged));
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 repo_mismatch<T: Git>(git: &T, req: &CleanupRequest) -> Option<String> {
let stated = req
.repo
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty())?;
let url = match git.run(
&req.repo_root,
&owned(&["config", "--get", "remote.origin.url"]),
) {
Ok(out) if out.success && !out.stdout.trim().is_empty() => out.stdout.trim().to_string(),
Ok(out) => {
return Some(format!(
"the registry names `{stated}`, and the `origin` remote at {} could not be read \
to confirm it: {} — refusing rather than running destructive git in a \
repository this cleanup may not be for (#7275)",
req.repo_root.display(),
out.stderr.trim()
));
}
Err(e) => {
return Some(format!(
"the registry names `{stated}`, and the `origin` remote at {} could not be read \
to confirm it: {e:#} (#7275)",
req.repo_root.display()
));
}
};
let actual = match crate::session_manager::worktree_repo_slug::parse_repo_slug(
&url,
&crate::session_manager::ssh_host_alias::SshHostAliases::for_current_user(),
) {
Ok(slug) => slug,
Err(_) => {
return Some(format!(
"the registry names `{stated}`, and the `origin` URL at {} ({url}) names no \
repository this can compare it against — refusing (#7275)",
req.repo_root.display()
));
}
};
let agrees = actual.eq_ignore_ascii_case(stated)
|| actual
.to_ascii_lowercase()
.ends_with(&format!("/{}", stated.to_ascii_lowercase()));
if agrees {
return None;
}
Some(format!(
"the registry names `{stated}` but the checkout at {} is `{actual}` — refusing: a merged \
pull request in one repository must never authorise branch deletion or worktree removal \
in another (#7275)",
req.repo_root.display()
))
}
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,
}
async fn step_worktrees<T: Git, C: ClaimEnder>(
git: &T,
claims: &C,
landing: &dyn Landing,
probe_dirt: DirtProbe<'_>,
req: &CleanupRequest,
merged: &MergedPr<'_>,
lines: &mut Vec<StepLine>,
) {
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;
}
};
let entries = plan::parse_worktree_list(&porcelain);
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)));
}
targets = named;
}
if targets.is_empty() {
lines.push(StepLine::ok(
STEP,
format!("no worktree holds {}", view.head_ref_name),
));
return;
}
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)
{
lines.push(StepLine::failed(
STEP,
format!("{}: {refusal}", t.path.display()),
));
continue;
}
lines.push(remove_one(git, claims, landing, probe_dirt, req, &merged.merge, t).await);
}
}
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()
}
async fn remove_one<T: Git, C: ClaimEnder>(
git: &T,
claims: &C,
landing: &dyn Landing,
probe_dirt: DirtProbe<'_>,
req: &CleanupRequest,
merge: &landed::Merge,
entry: &plan::WorktreeEntry,
) -> StepLine {
const STEP: &str = "worktree";
let path = entry.path.as_path();
let shown = path.display().to_string();
let mut landed_note = String::new();
let dirt = probe_dirt(path);
let before = recheck::first(dirt.as_ref(), &entry.head);
if let Some(dirt) = &dirt {
if !landed::ahead_only(dirt) {
return StepLine::failed(
STEP,
format!(
"{shown} holds unsaved work ({}) — refusing to remove it; cleanup never \
forces (#7275)",
dirt.reason
),
);
}
match landed::landed(git, landing, req, merge, entry) {
Ok(pr) => {
landed_note = format!(
" ({} landed: #{pr} merged it and re-merging changes nothing)",
dirt.reason
);
}
Err(why) => {
return StepLine::failed(STEP, landed::ahead_refusal(&shown, &dirt.reason, &why));
}
}
}
let holders = match claims.claims_on(path).await {
Ok(h) => h,
Err(e) => return StepLine::failed(STEP, format!("{e:#}")),
};
if req.dry_run {
return StepLine::ok(
STEP,
format!(
"would remove {shown}{}{landed_note}",
claim_note(&holders, "end ")
),
);
}
for id in &holders {
if let Err(e) = claims.end_claim(id).await {
return StepLine::failed(
STEP,
format!(
"{shown} is claimed by session {id} and the claim could not be ended: {e:#}"
),
);
}
}
match recheck::again(git, probe_dirt, path) {
Ok(now) => {
if let Some(changed) = recheck::drift(&before, &now) {
return StepLine::failed(
STEP,
format!(
"{shown} changed while cleanup was checking it ({changed}) — refusing to \
remove it (#7275)"
),
);
}
}
Err(why) => {
return StepLine::failed(
STEP,
format!("{shown} could not be re-read before removal: {why} (#7275)"),
);
}
}
let marker = match take_worktree_sentinel(path) {
Ok(bytes) => bytes,
Err(e) => {
return StepLine::failed(
STEP,
format!("{shown}: the harness ownership marker could not be cleared: {e} (#7185)"),
);
}
};
match run_git(git, req, &["worktree", "remove", &shown]) {
Ok(_) => StepLine::ok(
STEP,
format!(
"removed {shown}{}{landed_note}",
claim_note(&holders, "ended ")
),
),
Err(e) => StepLine::failed(STEP, format!("{e:#}{}", restore_marker(path, &marker))),
}
}
fn restore_marker(path: &Path, marker: &Option<Vec<u8>>) -> String {
let Some(bytes) = marker.as_deref() else {
return String::new();
};
match restore_worktree_sentinel(path, bytes) {
Ok(()) => String::new(),
Err(e) => format!(
" — and its harness ownership marker could not be restored ({e}), so the tree is \
now unattributed and no sweep will reclaim it (#7185)"
),
}
}
fn claim_note(holders: &[String], verb: &str) -> String {
if holders.is_empty() {
return String::new();
}
format!(" ({verb}claim held by {})", holders.join(", "))
}
fn step_local_branches<T: Git>(git: &T, req: &CleanupRequest, merged: &MergedPr<'_>) -> StepLine {
const STEP: &str = "local-branch";
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);
}
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)
}
fn delete_branches<T: Git>(git: &T, req: &CleanupRequest, wanted: Vec<String>) -> StepLine {
const STEP: &str = "local-branch";
if wanted.is_empty() {
return StepLine::ok(STEP, "no local branch left to delete");
}
if req.dry_run {
return StepLine::ok(STEP, format!("would delete {}", wanted.join(", ")));
}
let mut deleted = Vec::new();
for b in &wanted {
if let Err(e) = run_git(git, req, &["branch", "-D", b]) {
return StepLine::failed(
STEP,
format!(
"deleted [{}]; `git branch -D {b}` failed: {e:#}",
deleted.join(", ")
),
);
}
deleted.push(b.clone());
}
StepLine::ok(STEP, format!("deleted {}", deleted.join(", ")))
}
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)
}