use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub const AGENT_BRANCH_PREFIX: &str = "worktree-agent-";
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct PrView {
#[serde(default)]
pub state: String,
#[serde(default, rename = "headRefName")]
pub head_ref_name: String,
#[serde(default, rename = "headRefOid")]
pub head_ref_oid: String,
#[serde(default, rename = "mergeCommit")]
pub merge_commit: Option<MergeCommit>,
#[serde(default, rename = "baseRefName")]
pub base_ref_name: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct MergeCommit {
#[serde(default)]
pub oid: String,
}
pub fn merge_refusal(view: &PrView, pr: u64) -> Option<String> {
let state = view.state.trim();
if !state.eq_ignore_ascii_case("MERGED") {
let seen = if state.is_empty() { "<absent>" } else { state };
return Some(format!(
"#{pr} is {seen}, not MERGED — cleanup deletes branches and worktrees and runs only \
after a merge is confirmed"
));
}
if view.head_ref_name.trim().is_empty() && view.head_ref_oid.trim().is_empty() {
return Some(format!(
"#{pr} reports MERGED but names neither a head branch nor a head commit, so nothing \
identifies what to clean up"
));
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorktreeEntry {
pub path: PathBuf,
pub head: String,
pub branch: Option<String>,
}
pub fn parse_worktree_list(porcelain: &str) -> Vec<WorktreeEntry> {
let mut out = Vec::new();
let mut path: Option<PathBuf> = None;
let mut head = String::new();
let mut branch: Option<String> = None;
let mut bare = false;
let flush = |path: &mut Option<PathBuf>,
head: &mut String,
branch: &mut Option<String>,
bare: &mut bool,
out: &mut Vec<WorktreeEntry>| {
if let Some(p) = path.take()
&& !*bare
{
out.push(WorktreeEntry {
path: p,
head: std::mem::take(head),
branch: branch.take(),
});
}
head.clear();
*branch = None;
*bare = false;
};
for line in porcelain.lines() {
let line = line.trim_end();
if line.is_empty() {
flush(&mut path, &mut head, &mut branch, &mut bare, &mut out);
} else if let Some(p) = line.strip_prefix("worktree ") {
flush(&mut path, &mut head, &mut branch, &mut bare, &mut out);
path = Some(PathBuf::from(p.trim()));
} else if let Some(oid) = line.strip_prefix("HEAD ") {
head = oid.trim().to_ascii_lowercase();
} else if let Some(r) = line.strip_prefix("branch ") {
branch = Some(short_branch(r.trim()).to_string());
} else if line == "bare" {
bare = true;
}
}
flush(&mut path, &mut head, &mut branch, &mut bare, &mut out);
out
}
fn short_branch(r: &str) -> &str {
r.strip_prefix("refs/heads/").unwrap_or(r)
}
pub fn strip_round_suffix(name: &str) -> &str {
let Some((stem, round)) = name.rsplit_once("-r") else {
return name;
};
if stem.is_empty() || round.is_empty() || !round.bytes().all(|b| b.is_ascii_digit()) {
return name;
}
stem
}
pub fn is_pr_branch(head: &str, name: &str) -> bool {
let head = head.trim();
let name = name.trim();
if head.is_empty() || name.is_empty() {
return false;
}
strip_round_suffix(head) == strip_round_suffix(name)
}
pub fn worktree_targets<'a>(
entries: &'a [WorktreeEntry],
branch: &str,
head_oid: &str,
repo_root: &Path,
) -> Vec<&'a WorktreeEntry> {
let branch = branch.trim();
let oid = head_oid.trim();
entries
.iter()
.filter(|e| !same_tree(&e.path, repo_root))
.filter(|e| {
let by_branch = e.branch.as_deref().is_some_and(|b| is_pr_branch(branch, b));
let by_head = !oid.is_empty() && e.head.eq_ignore_ascii_case(oid);
by_branch || by_head
})
.collect()
}
fn same_tree(a: &Path, b: &Path) -> bool {
a == b || trusty_common::identifies_same_path(a, b)
}
pub(crate) fn holders_run_from<'a>(
entries: &'a [WorktreeEntry],
branch: &str,
head_oid: &str,
repo_root: &Path,
) -> Vec<&'a WorktreeEntry> {
let oid = head_oid.trim();
entries
.iter()
.filter(|e| same_tree(&e.path, repo_root))
.filter(|e| {
e.branch.as_deref().is_some_and(|b| {
is_pr_branch(branch, b)
|| (b.starts_with(AGENT_BRANCH_PREFIX)
&& !oid.is_empty()
&& e.head.eq_ignore_ascii_case(oid))
})
})
.collect()
}
pub(crate) fn kept_run_from(entry: &WorktreeEntry, pr: u64, head_only: bool) -> String {
let branch = entry.branch.as_deref().unwrap_or_default();
let recovery = if head_only {
format!(
"run `git switch --detach` in it, then `git branch -D {branch}` — this merge's \
cleanup reaches only {branch}"
)
} else {
format!(
"run `git switch --detach` in it, then `git branch -D {branch}`; or run \
`tm pr cleanup {pr}` from the main checkout"
)
};
format!(
"{}: holds {branch} and was kept — cleanup runs its git commands in this checkout and \
never removes it; {recovery}",
entry.path.display()
)
}
pub(crate) fn holder_of<'a>(
standing: &'a [WorktreeEntry],
branch: &str,
) -> Option<&'a WorktreeEntry> {
let branch = branch.trim();
standing
.iter()
.find(|e| e.branch.as_deref().map(str::trim) == Some(branch))
}
pub(crate) fn kept_branch(branch: &str, holder: &WorktreeEntry) -> String {
format!(
"kept {branch}: the worktree at {} has it checked out, and git refuses to delete a \
branch a worktree holds",
holder.path.display()
)
}
pub(crate) const HOLDERS_UNKNOWN: &str = "no branch deleted — the worktree listing was not read, \
so which branches a worktree holds is unknown (#8489)";
pub(crate) fn inconclusive_listing(branch: &str) -> String {
format!(
"`git worktree list --porcelain` named no worktree, not even this checkout — the \
lookup is inconclusive, so whether a worktree holds {branch} is unknown"
)
}
pub fn split_head_only<'a>(
targets: Vec<&'a WorktreeEntry>,
head: &str,
) -> (Vec<&'a WorktreeEntry>, Vec<&'a WorktreeEntry>) {
let head = head.trim();
targets
.into_iter()
.partition(|e| !head.is_empty() && e.branch.as_deref().map(str::trim) == Some(head))
}
pub fn left_in_place(left: &[&WorktreeEntry], pr: u64) -> String {
let paths: Vec<String> = left.iter().map(|e| e.path.display().to_string()).collect();
format!(
"left in place, not this PR's head worktree: {} — `tm pr cleanup {pr}` reclaims them",
paths.join(", ")
)
}
pub fn pr_branches(listing: &str, head: &str) -> Vec<String> {
let head = head.trim();
let mut out: Vec<String> = listing
.lines()
.filter_map(|l| l.split_whitespace().next())
.filter(|name| is_pr_branch(head, name))
.map(str::to_string)
.collect();
out.sort_by_key(|n| (n != head, n.clone()));
out
}
pub fn agent_branches_at(listing: &str, head_oid: &str) -> Vec<String> {
let oid = head_oid.trim();
if oid.is_empty() {
return Vec::new();
}
listing
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let name = parts.next()?;
let object = parts.next()?;
(name.starts_with(AGENT_BRANCH_PREFIX) && object.eq_ignore_ascii_case(oid))
.then(|| name.to_string())
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum StepStatus {
Ok,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct StepLine {
pub step: &'static str,
pub status: StepStatus,
pub detail: String,
}
impl StepLine {
pub fn ok(step: &'static str, detail: impl Into<String>) -> Self {
Self {
step,
status: StepStatus::Ok,
detail: detail.into(),
}
}
pub fn failed(step: &'static str, detail: impl Into<String>) -> Self {
Self {
step,
status: StepStatus::Failed,
detail: detail.into(),
}
}
pub fn is_failure(&self) -> bool {
self.status == StepStatus::Failed
}
pub fn render(&self) -> String {
let tag = match self.status {
StepStatus::Ok => "ok",
StepStatus::Failed => "FAILED",
};
format!("{}: {tag} — {}", self.step, self.detail)
}
}