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| 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()
}
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)
}
}