use std::path::Path;
use serde_json::Value;
use crate::detect::Forge;
pub const PROTECTED_PREFIX: &str = "release/";
pub const FOR_EACH_REF_FORMAT: &str =
"%(refname:short)%09%(objectname)%09%(upstream:short)%09%(upstream:track)%09%(worktreepath)";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Branch {
pub name: String,
pub tip: String,
pub upstream: Option<String>,
pub gone: bool,
pub worktree: Option<String>,
}
#[must_use]
pub fn parse_branches(text: &str) -> Vec<Branch> {
text.lines()
.filter_map(|line| {
let mut fields = line.splitn(5, '\t');
let name = fields.next()?.to_owned();
let tip = fields.next()?.to_owned();
let upstream = fields.next()?;
let track = fields.next()?;
let worktree = fields.next()?;
Some(Branch {
name,
tip,
upstream: (!upstream.is_empty()).then(|| upstream.to_owned()),
gone: track == "[gone]",
worktree: (!worktree.is_empty()).then(|| worktree.to_owned()),
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Class {
Kept {
reason: String,
},
Candidate,
WorktreeBound {
path: String,
},
Confirmed {
request: String,
},
Unconfirmed {
detail: String,
},
Unknown {
detail: String,
},
}
#[must_use]
pub fn classify(branch: &Branch, current: Option<&str>, trunk: &str) -> Option<Class> {
if !branch.gone {
return None;
}
if current.is_some_and(|name| name == branch.name) {
return Some(Class::Kept {
reason: "the current branch".to_owned(),
});
}
if let Some(worktree) = &branch.worktree {
return Some(Class::WorktreeBound {
path: worktree.clone(),
});
}
if branch.name == trunk || branch.name.starts_with(PROTECTED_PREFIX) {
return Some(Class::Kept {
reason: "a protected branch".to_owned(),
});
}
Some(Class::Candidate)
}
#[must_use]
pub fn confirmation(forge: Forge, body: &Value, tip: &str) -> Class {
let Some(requests) = body.as_array() else {
return Class::Unknown {
detail: "the forge answer is not a list of requests".to_owned(),
};
};
let confirmed = requests.iter().find_map(|request| match forge {
Forge::Github => (!request["merged_at"].is_null()
&& request["head"]["sha"].as_str() == Some(tip))
.then(|| request["number"].as_u64())
.flatten()
.map(|number| format!("#{number}")),
Forge::Gitlab => (request["state"].as_str() == Some("merged")
&& request["sha"].as_str() == Some(tip))
.then(|| request["iid"].as_u64())
.flatten()
.map(|iid| format!("!{iid}")),
});
confirmed.map_or_else(
|| Class::Unconfirmed {
detail: "no merged request records this tip".to_owned(),
},
|request| Class::Confirmed { request },
)
}
#[must_use]
pub fn merged_request_for(cli: &Path, target: &Path, forge: Forge, repo: &str, tip: &str) -> Class {
let path = match forge {
Forge::Github => format!("repos/{repo}/commits/{tip}/pulls"),
Forge::Gitlab => format!(
"projects/{}/repository/commits/{tip}/merge_requests",
repo.replace('/', "%2F")
),
};
let answered = std::process::Command::new(cli)
.args(["api", &path])
.current_dir(target)
.env("GH_PAGER", "")
.env("GLAB_PAGER", "")
.output();
let output = match answered {
Ok(output) => output,
Err(source) => {
return Class::Unknown {
detail: format!("the forge CLI did not run: {source}"),
};
}
};
if output.status.success() {
return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
|_| Class::Unknown {
detail: "the forge answer did not parse as JSON".to_owned(),
},
|body| confirmation(forge, &body, tip),
);
}
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
return Class::Unconfirmed {
detail: "the forge does not know this commit".to_owned(),
};
}
Class::Unknown {
detail: last_line(&output.stderr),
}
}
fn last_line(bytes: &[u8]) -> String {
String::from_utf8_lossy(bytes)
.lines()
.rev()
.find(|line| !line.trim().is_empty())
.unwrap_or("no output")
.to_owned()
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use serde_json::json;
use super::{Branch, Class, classify, confirmation, parse_branches};
use crate::detect::Forge;
#[test]
fn a_for_each_ref_line_parses_into_a_branch() {
let text = "feat/x\taaaa\torigin/feat/x\t[gone]\t\n\
master\tbbbb\torigin/master\t\t/srv/checkouts/repo\n\
local-only\tcccc\t\t\t\n\
behind\tdddd\torigin/behind\t[behind 2]\t\n\
short\tline\n";
let branches = parse_branches(text);
assert_eq!(branches.len(), 4, "the short line is skipped");
assert_eq!(
branches[0],
Branch {
name: "feat/x".into(),
tip: "aaaa".into(),
upstream: Some("origin/feat/x".into()),
gone: true,
worktree: None,
}
);
assert_eq!(branches[1].worktree.as_deref(), Some("/srv/checkouts/repo"));
assert!(!branches[1].gone);
assert_eq!(branches[2].upstream, None);
assert!(!branches[3].gone, "[behind 2] is tracking, not gone");
}
#[test]
fn classification_guards_current_worktree_and_protected_branches() {
let gone = |name: &str, worktree: Option<&str>| Branch {
name: name.into(),
tip: "aaaa".into(),
upstream: Some(format!("origin/{name}")),
gone: true,
worktree: worktree.map(str::to_owned),
};
assert_eq!(
classify(&gone("feat/x", None), Some("feat/x"), "master"),
Some(Class::Kept {
reason: "the current branch".into()
})
);
assert_eq!(
classify(&gone("feat/x", Some("/wt")), Some("master"), "master"),
Some(Class::WorktreeBound { path: "/wt".into() })
);
assert_eq!(
classify(&gone("feat/x", Some("/wt")), Some("feat/x"), "master"),
Some(Class::Kept {
reason: "the current branch".into()
}),
"the current branch wins over its own worktree"
);
assert_eq!(
classify(&gone("master", None), None, "master"),
Some(Class::Kept {
reason: "a protected branch".into()
})
);
assert_eq!(
classify(&gone("release/1.2", None), None, "master"),
Some(Class::Kept {
reason: "a protected branch".into()
})
);
assert_eq!(
classify(&gone("feat/x", None), Some("master"), "master"),
Some(Class::Candidate)
);
let live = Branch {
gone: false,
..gone("feat/live", None)
};
assert_eq!(classify(&live, None, "master"), None);
}
#[test]
fn a_merged_request_confirms_only_on_head_sha_equality() {
let github = json!([
{"number": 7, "merged_at": null, "head": {"sha": "aaaa"}},
{"number": 8, "merged_at": "2026-01-01T00:00:00Z", "head": {"sha": "aaaa"}},
]);
assert_eq!(
confirmation(Forge::Github, &github, "aaaa"),
Class::Confirmed {
request: "#8".into()
}
);
assert_eq!(
confirmation(Forge::Github, &github, "bbbb"),
Class::Unconfirmed {
detail: "no merged request records this tip".into()
},
"a merged request for another tip proves nothing about this one"
);
let gitlab = json!([
{"iid": 3, "state": "opened", "sha": "aaaa"},
{"iid": 4, "state": "merged", "sha": "aaaa"},
]);
assert_eq!(
confirmation(Forge::Gitlab, &gitlab, "aaaa"),
Class::Confirmed {
request: "!4".into()
}
);
assert!(matches!(
confirmation(Forge::Github, &json!({"message": "rate limited"}), "aaaa"),
Class::Unknown { .. }
));
}
}