1use std::path::Path;
14
15use serde_json::Value;
16
17use crate::detect::Forge;
18
19pub const PROTECTED_PREFIX: &str = "release/";
22
23pub const FOR_EACH_REF_FORMAT: &str =
31 "%(refname:short)%09%(objectname)%09%(upstream:short)%09%(upstream:track)%09%(worktreepath)";
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Branch {
36 pub name: String,
38 pub tip: String,
40 pub upstream: Option<String>,
42 pub gone: bool,
44 pub worktree: Option<String>,
46}
47
48#[must_use]
51pub fn parse_branches(text: &str) -> Vec<Branch> {
52 text.lines()
53 .filter_map(|line| {
54 let mut fields = line.splitn(5, '\t');
55 let name = fields.next()?.to_owned();
56 let tip = fields.next()?.to_owned();
57 let upstream = fields.next()?;
58 let track = fields.next()?;
59 let worktree = fields.next()?;
60 Some(Branch {
61 name,
62 tip,
63 upstream: (!upstream.is_empty()).then(|| upstream.to_owned()),
64 gone: track == "[gone]",
65 worktree: (!worktree.is_empty()).then(|| worktree.to_owned()),
66 })
67 })
68 .collect()
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Class {
74 Kept {
76 reason: String,
78 },
79 Candidate,
81 WorktreeBound {
84 path: String,
86 },
87 Confirmed {
89 proof: Proof,
91 },
92 Unconfirmed {
94 detail: String,
96 },
97 Unknown {
99 detail: String,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Proof {
111 Request(String),
113 LocalIntegration(String),
115}
116
117impl Proof {
118 #[must_use]
120 pub fn detail(&self) -> String {
121 match self {
122 Self::Request(request) => request.clone(),
123 Self::LocalIntegration(commit) => {
124 format!("locally integrated as {}", crate::integrate::short(commit))
125 }
126 }
127 }
128
129 #[must_use]
131 pub const fn kind(&self) -> &'static str {
132 match self {
133 Self::Request(_) => "request",
134 Self::LocalIntegration(_) => "local-integration",
135 }
136 }
137}
138
139#[must_use]
148pub fn classify(
149 branch: &Branch,
150 current: Option<&str>,
151 trunk: &str,
152 local: Option<&crate::integrate::Entry>,
153) -> Option<Class> {
154 if !branch.gone && local.is_none() {
160 return None;
161 }
162 if current.is_some_and(|name| name == branch.name) {
163 return Some(Class::Kept {
164 reason: "the current branch".to_owned(),
165 });
166 }
167 if let Some(worktree) = &branch.worktree {
168 return Some(Class::WorktreeBound {
169 path: worktree.clone(),
170 });
171 }
172 if branch.name == trunk || branch.name.starts_with(PROTECTED_PREFIX) {
173 return Some(Class::Kept {
174 reason: "a protected branch".to_owned(),
175 });
176 }
177 if let Some(entry) = local {
178 return Some(Class::Confirmed {
179 proof: Proof::LocalIntegration(entry.trunk_commit.clone()),
180 });
181 }
182 Some(Class::Candidate)
183}
184
185#[must_use]
193pub fn confirmation(forge: Forge, body: &Value, tip: &str) -> Class {
194 let Some(requests) = body.as_array() else {
195 return Class::Unknown {
196 detail: "the forge answer is not a list of requests".to_owned(),
197 };
198 };
199 let confirmed = requests.iter().find_map(|request| match forge {
200 Forge::Github => (!request["merged_at"].is_null()
201 && request["head"]["sha"].as_str() == Some(tip))
202 .then(|| request["number"].as_u64())
203 .flatten()
204 .map(|number| format!("#{number}")),
205 Forge::Gitlab => (request["state"].as_str() == Some("merged")
206 && request["sha"].as_str() == Some(tip))
207 .then(|| request["iid"].as_u64())
208 .flatten()
209 .map(|iid| format!("!{iid}")),
210 });
211 confirmed.map_or_else(
212 || Class::Unconfirmed {
213 detail: "no merged request records this tip".to_owned(),
214 },
215 |request| Class::Confirmed {
216 proof: Proof::Request(request),
217 },
218 )
219}
220
221#[must_use]
228pub fn merged_request_for(cli: &Path, target: &Path, forge: Forge, repo: &str, tip: &str) -> Class {
229 let path = match forge {
230 Forge::Github => format!("repos/{repo}/commits/{tip}/pulls"),
231 Forge::Gitlab => format!(
232 "projects/{}/repository/commits/{tip}/merge_requests",
233 repo.replace('/', "%2F")
234 ),
235 };
236 let answered = std::process::Command::new(cli)
237 .args(["api", &path])
238 .current_dir(target)
239 .env("GH_PAGER", "")
240 .env("GLAB_PAGER", "")
241 .output();
242 let output = match answered {
243 Ok(output) => output,
244 Err(source) => {
245 return Class::Unknown {
246 detail: format!("the forge CLI did not run: {source}"),
247 };
248 }
249 };
250 if output.status.success() {
251 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
252 |_| Class::Unknown {
253 detail: "the forge answer did not parse as JSON".to_owned(),
254 },
255 |body| confirmation(forge, &body, tip),
256 );
257 }
258 let stderr = String::from_utf8_lossy(&output.stderr);
263 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
264 return Class::Unconfirmed {
265 detail: "the forge does not know this commit".to_owned(),
266 };
267 }
268 Class::Unknown {
269 detail: last_line(&output.stderr),
270 }
271}
272
273fn last_line(bytes: &[u8]) -> String {
275 String::from_utf8_lossy(bytes)
276 .lines()
277 .rev()
278 .find(|line| !line.trim().is_empty())
279 .unwrap_or("no output")
280 .to_owned()
281}
282
283#[cfg(test)]
284mod tests {
285 use serde_json::json;
286
287 use super::{Branch, Class, Proof, classify, confirmation, parse_branches};
288 use crate::detect::Forge;
289
290 #[test]
293 fn a_for_each_ref_line_parses_into_a_branch() {
294 let text = "feat/x\taaaa\torigin/feat/x\t[gone]\t\n\
295 master\tbbbb\torigin/master\t\t/srv/checkouts/repo\n\
296 local-only\tcccc\t\t\t\n\
297 behind\tdddd\torigin/behind\t[behind 2]\t\n\
298 short\tline\n";
299 let branches = parse_branches(text);
300 assert_eq!(branches.len(), 4, "the short line is skipped");
301 assert_eq!(
302 branches[0],
303 Branch {
304 name: "feat/x".into(),
305 tip: "aaaa".into(),
306 upstream: Some("origin/feat/x".into()),
307 gone: true,
308 worktree: None,
309 }
310 );
311 assert_eq!(branches[1].worktree.as_deref(), Some("/srv/checkouts/repo"));
312 assert!(!branches[1].gone);
313 assert_eq!(branches[2].upstream, None);
314 assert!(!branches[3].gone, "[behind 2] is tracking, not gone");
315 }
316
317 #[test]
320 fn classification_guards_current_worktree_and_protected_branches() {
321 let gone = |name: &str, worktree: Option<&str>| Branch {
322 name: name.into(),
323 tip: "aaaa".into(),
324 upstream: Some(format!("origin/{name}")),
325 gone: true,
326 worktree: worktree.map(str::to_owned),
327 };
328 assert_eq!(
329 classify(&gone("feat/x", None), Some("feat/x"), "master", None),
330 Some(Class::Kept {
331 reason: "the current branch".into()
332 })
333 );
334 assert_eq!(
335 classify(&gone("feat/x", Some("/wt")), Some("master"), "master", None),
336 Some(Class::WorktreeBound { path: "/wt".into() })
337 );
338 assert_eq!(
339 classify(&gone("feat/x", Some("/wt")), Some("feat/x"), "master", None),
340 Some(Class::Kept {
341 reason: "the current branch".into()
342 }),
343 "the current branch wins over its own worktree"
344 );
345 assert_eq!(
346 classify(&gone("master", None), None, "master", None),
347 Some(Class::Kept {
348 reason: "a protected branch".into()
349 })
350 );
351 assert_eq!(
352 classify(&gone("release/1.2", None), None, "master", None),
353 Some(Class::Kept {
354 reason: "a protected branch".into()
355 })
356 );
357 assert_eq!(
358 classify(&gone("feat/x", None), Some("master"), "master", None),
359 Some(Class::Candidate)
360 );
361 let live = Branch {
362 gone: false,
363 ..gone("feat/live", None)
364 };
365 assert_eq!(classify(&live, None, "master", None), None);
366 }
367
368 #[test]
371 fn a_merged_request_confirms_only_on_head_sha_equality() {
372 let github = json!([
373 {"number": 7, "merged_at": null, "head": {"sha": "aaaa"}},
374 {"number": 8, "merged_at": "2026-01-01T00:00:00Z", "head": {"sha": "aaaa"}},
375 ]);
376 assert_eq!(
377 confirmation(Forge::Github, &github, "aaaa"),
378 Class::Confirmed {
379 proof: Proof::Request("#8".into())
380 }
381 );
382 assert_eq!(
383 confirmation(Forge::Github, &github, "bbbb"),
384 Class::Unconfirmed {
385 detail: "no merged request records this tip".into()
386 },
387 "a merged request for another tip proves nothing about this one"
388 );
389 let gitlab = json!([
390 {"iid": 3, "state": "opened", "sha": "aaaa"},
391 {"iid": 4, "state": "merged", "sha": "aaaa"},
392 ]);
393 assert_eq!(
394 confirmation(Forge::Gitlab, &gitlab, "aaaa"),
395 Class::Confirmed {
396 proof: Proof::Request("!4".into())
397 }
398 );
399 assert!(matches!(
400 confirmation(Forge::Github, &json!({"message": "rate limited"}), "aaaa"),
401 Class::Unknown { .. }
402 ));
403 }
404}