1use std::path::Path;
19
20use serde_json::Value;
21
22use crate::detect::Forge;
23
24pub const PROTECTED_PREFIX: &str = "release/";
27
28pub const FOR_EACH_REF_FORMAT: &str =
36 "%(refname:short)%09%(objectname)%09%(upstream:short)%09%(upstream:track)%09%(worktreepath)";
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Branch {
41 pub name: String,
43 pub tip: String,
45 pub upstream: Option<String>,
47 pub gone: bool,
49 pub worktree: Option<String>,
51}
52
53#[must_use]
56pub fn parse_branches(text: &str) -> Vec<Branch> {
57 text.lines()
58 .filter_map(|line| {
59 let mut fields = line.splitn(5, '\t');
60 let name = fields.next()?.to_owned();
61 let tip = fields.next()?.to_owned();
62 let upstream = fields.next()?;
63 let track = fields.next()?;
64 let worktree = fields.next()?;
65 Some(Branch {
66 name,
67 tip,
68 upstream: (!upstream.is_empty()).then(|| upstream.to_owned()),
69 gone: track == "[gone]",
70 worktree: (!worktree.is_empty()).then(|| worktree.to_owned()),
71 })
72 })
73 .collect()
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Class {
79 Kept {
81 reason: String,
83 },
84 Candidate,
86 WorktreeBound {
89 path: String,
91 },
92 Confirmed {
94 proof: Proof,
96 },
97 Unconfirmed {
99 detail: String,
101 },
102 Unknown {
104 detail: String,
106 },
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Proof {
123 Request(String),
125 LocalIntegration(String),
127 ReachableTip(String),
130}
131
132impl Proof {
133 #[must_use]
135 pub fn detail(&self) -> String {
136 match self {
137 Self::Request(request) => request.clone(),
138 Self::LocalIntegration(commit) => {
139 format!("locally integrated as {}", crate::integrate::short(commit))
140 }
141 Self::ReachableTip(created) => format!(
142 "reachable tip: the trunk carries it, and the branch moved from {} since its creation",
143 crate::integrate::short(created)
144 ),
145 }
146 }
147
148 #[must_use]
150 pub const fn kind(&self) -> &'static str {
151 match self {
152 Self::Request(_) => "request",
153 Self::LocalIntegration(_) => "local-integration",
154 Self::ReachableTip(_) => "reachable-tip",
155 }
156 }
157
158 #[must_use]
161 pub const fn is_local(&self) -> bool {
162 matches!(self, Self::LocalIntegration(_) | Self::ReachableTip(_))
163 }
164}
165
166#[must_use]
175pub fn creation_entry(reflog: &str) -> Option<String> {
176 reflog
177 .lines()
178 .rev()
179 .find_map(|line| {
180 let mut fields = line.split(' ');
181 let old = fields.next()?;
182 let new = fields.next()?;
183 let is_id = |id: &str| {
184 matches!(id.len(), 40 | 64) && id.bytes().all(|byte| byte.is_ascii_hexdigit())
185 };
186 (is_id(old) && is_id(new) && old.bytes().all(|byte| byte == b'0'))
187 .then(|| new.to_owned())
188 })
189 .filter(|new| !new.bytes().all(|byte| byte == b'0'))
190}
191
192#[must_use]
199pub fn reachable_tip(created: Option<&str>, tip: &str, trunk_reaches: bool) -> Option<Proof> {
200 let created = created?;
201 (trunk_reaches && created != tip).then(|| Proof::ReachableTip(created.to_owned()))
202}
203
204#[must_use]
217pub fn classify(
218 branch: &Branch,
219 current: Option<&str>,
220 trunk: &str,
221 local: Option<Proof>,
222) -> Option<Class> {
223 if !branch.gone && local.is_none() {
230 return None;
231 }
232 if current.is_some_and(|name| name == branch.name) {
233 return Some(Class::Kept {
234 reason: "the current branch".to_owned(),
235 });
236 }
237 if let Some(worktree) = &branch.worktree {
238 return Some(Class::WorktreeBound {
239 path: worktree.clone(),
240 });
241 }
242 if branch.name == trunk || branch.name.starts_with(PROTECTED_PREFIX) {
243 return Some(Class::Kept {
244 reason: "a protected branch".to_owned(),
245 });
246 }
247 if let Some(proof) = local {
248 return Some(Class::Confirmed { proof });
249 }
250 Some(Class::Candidate)
251}
252
253#[must_use]
261pub fn confirmation(forge: Forge, body: &Value, tip: &str) -> Class {
262 let Some(requests) = body.as_array() else {
263 return Class::Unknown {
264 detail: "the forge answer is not a list of requests".to_owned(),
265 };
266 };
267 let confirmed = requests.iter().find_map(|request| match forge {
268 Forge::Github => (!request["merged_at"].is_null()
269 && request["head"]["sha"].as_str() == Some(tip))
270 .then(|| request["number"].as_u64())
271 .flatten()
272 .map(|number| format!("#{number}")),
273 Forge::Gitlab => (request["state"].as_str() == Some("merged")
274 && request["sha"].as_str() == Some(tip))
275 .then(|| request["iid"].as_u64())
276 .flatten()
277 .map(|iid| format!("!{iid}")),
278 });
279 confirmed.map_or_else(
280 || Class::Unconfirmed {
281 detail: "no merged request records this tip".to_owned(),
282 },
283 |request| Class::Confirmed {
284 proof: Proof::Request(request),
285 },
286 )
287}
288
289#[must_use]
296pub fn merged_request_for(cli: &Path, target: &Path, forge: Forge, repo: &str, tip: &str) -> Class {
297 let path = match forge {
298 Forge::Github => format!("repos/{repo}/commits/{tip}/pulls"),
299 Forge::Gitlab => format!(
300 "projects/{}/repository/commits/{tip}/merge_requests",
301 repo.replace('/', "%2F")
302 ),
303 };
304 let answered = std::process::Command::new(cli)
305 .args(["api", &path])
306 .current_dir(target)
307 .env("GH_PAGER", "")
308 .env("GLAB_PAGER", "")
309 .output();
310 let output = match answered {
311 Ok(output) => output,
312 Err(source) => {
313 return Class::Unknown {
314 detail: format!("the forge CLI did not run: {source}"),
315 };
316 }
317 };
318 if output.status.success() {
319 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
320 |_| Class::Unknown {
321 detail: "the forge answer did not parse as JSON".to_owned(),
322 },
323 |body| confirmation(forge, &body, tip),
324 );
325 }
326 let stderr = String::from_utf8_lossy(&output.stderr);
331 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
332 return Class::Unconfirmed {
333 detail: "the forge does not know this commit".to_owned(),
334 };
335 }
336 Class::Unknown {
337 detail: last_line(&output.stderr),
338 }
339}
340
341fn last_line(bytes: &[u8]) -> String {
343 String::from_utf8_lossy(bytes)
344 .lines()
345 .rev()
346 .find(|line| !line.trim().is_empty())
347 .unwrap_or("no output")
348 .to_owned()
349}
350
351#[cfg(test)]
352mod tests {
353 use serde_json::json;
354
355 use super::{
356 Branch, Class, Proof, classify, confirmation, creation_entry, parse_branches, reachable_tip,
357 };
358 use crate::detect::Forge;
359
360 #[test]
363 fn a_for_each_ref_line_parses_into_a_branch() {
364 let text = "feat/x\taaaa\torigin/feat/x\t[gone]\t\n\
365 master\tbbbb\torigin/master\t\t/srv/checkouts/repo\n\
366 local-only\tcccc\t\t\t\n\
367 behind\tdddd\torigin/behind\t[behind 2]\t\n\
368 short\tline\n";
369 let branches = parse_branches(text);
370 assert_eq!(branches.len(), 4, "the short line is skipped");
371 assert_eq!(
372 branches[0],
373 Branch {
374 name: "feat/x".into(),
375 tip: "aaaa".into(),
376 upstream: Some("origin/feat/x".into()),
377 gone: true,
378 worktree: None,
379 }
380 );
381 assert_eq!(branches[1].worktree.as_deref(), Some("/srv/checkouts/repo"));
382 assert!(!branches[1].gone);
383 assert_eq!(branches[2].upstream, None);
384 assert!(!branches[3].gone, "[behind 2] is tracking, not gone");
385 }
386
387 #[test]
390 fn classification_guards_current_worktree_and_protected_branches() {
391 let gone = |name: &str, worktree: Option<&str>| Branch {
392 name: name.into(),
393 tip: "aaaa".into(),
394 upstream: Some(format!("origin/{name}")),
395 gone: true,
396 worktree: worktree.map(str::to_owned),
397 };
398 assert_eq!(
399 classify(&gone("feat/x", None), Some("feat/x"), "master", None),
400 Some(Class::Kept {
401 reason: "the current branch".into()
402 })
403 );
404 assert_eq!(
405 classify(&gone("feat/x", Some("/wt")), Some("master"), "master", None),
406 Some(Class::WorktreeBound { path: "/wt".into() })
407 );
408 assert_eq!(
409 classify(&gone("feat/x", Some("/wt")), Some("feat/x"), "master", None),
410 Some(Class::Kept {
411 reason: "the current branch".into()
412 }),
413 "the current branch wins over its own worktree"
414 );
415 assert_eq!(
416 classify(&gone("master", None), None, "master", None),
417 Some(Class::Kept {
418 reason: "a protected branch".into()
419 })
420 );
421 assert_eq!(
422 classify(&gone("release/1.2", None), None, "master", None),
423 Some(Class::Kept {
424 reason: "a protected branch".into()
425 })
426 );
427 assert_eq!(
428 classify(&gone("feat/x", None), Some("master"), "master", None),
429 Some(Class::Candidate)
430 );
431 let live = Branch {
432 gone: false,
433 ..gone("feat/live", None)
434 };
435 assert_eq!(classify(&live, None, "master", None), None);
436 }
437
438 #[test]
441 fn a_merged_request_confirms_only_on_head_sha_equality() {
442 let github = json!([
443 {"number": 7, "merged_at": null, "head": {"sha": "aaaa"}},
444 {"number": 8, "merged_at": "2026-01-01T00:00:00Z", "head": {"sha": "aaaa"}},
445 ]);
446 assert_eq!(
447 confirmation(Forge::Github, &github, "aaaa"),
448 Class::Confirmed {
449 proof: Proof::Request("#8".into())
450 }
451 );
452 assert_eq!(
453 confirmation(Forge::Github, &github, "bbbb"),
454 Class::Unconfirmed {
455 detail: "no merged request records this tip".into()
456 },
457 "a merged request for another tip proves nothing about this one"
458 );
459 let gitlab = json!([
460 {"iid": 3, "state": "opened", "sha": "aaaa"},
461 {"iid": 4, "state": "merged", "sha": "aaaa"},
462 ]);
463 assert_eq!(
464 confirmation(Forge::Gitlab, &gitlab, "aaaa"),
465 Class::Confirmed {
466 proof: Proof::Request("!4".into())
467 }
468 );
469 assert!(matches!(
470 confirmation(Forge::Github, &json!({"message": "rate limited"}), "aaaa"),
471 Class::Unknown { .. }
472 ));
473 }
474
475 #[test]
480 fn reachable_tip_reads_the_creation_entry_of_a_raw_reflog() {
481 let zero = "0".repeat(40);
482 let a = "a".repeat(40);
483 let b = "b".repeat(40);
484 let c = "c".repeat(40);
485 let whole = format!(
486 "{zero} {a} Dev <dev@example.com> 1700000000 +0000\tbranch: Created from master\n\
487 {a} {b} Dev <dev@example.com> 1700000100 +0000\tcommit: one\n\
488 {b} {c} Dev <dev@example.com> 1700000200 +0000\tcommit: two\n"
489 );
490 assert_eq!(creation_entry(&whole), Some(a));
491 let expired = whole.lines().skip(1).collect::<Vec<_>>().join("\n");
492 assert_eq!(creation_entry(&expired), None, "the creation entry expired");
493 assert_eq!(creation_entry(""), None, "no reflog at all");
494 assert_eq!(creation_entry("not a reflog\n"), None);
495 let sha256 = format!(
496 "{} {} Dev <dev@example.com> 1700000000 +0000\tbranch: Created\n",
497 "0".repeat(64),
498 "d".repeat(64)
499 );
500 assert_eq!(creation_entry(&sha256), Some("d".repeat(64)));
501 }
502
503 #[test]
507 fn reachable_tip_needs_a_moved_branch_the_trunk_reaches() {
508 assert_eq!(
509 reachable_tip(Some("aaaa"), "bbbb", true),
510 Some(Proof::ReachableTip("aaaa".into()))
511 );
512 assert_eq!(reachable_tip(Some("bbbb"), "bbbb", true), None, "fresh");
513 assert_eq!(reachable_tip(Some("aaaa"), "bbbb", false), None, "unmerged");
514 assert_eq!(reachable_tip(None, "bbbb", true), None, "no creation entry");
515 let proof = Proof::ReachableTip("aaaa".into());
516 assert_eq!(proof.kind(), "reachable-tip");
517 assert!(proof.detail().starts_with("reachable tip"));
518 assert!(proof.is_local() && Proof::LocalIntegration("x".into()).is_local());
519 assert!(!Proof::Request("#8".into()).is_local());
520 let branch = Branch {
523 name: "feat/x".into(),
524 tip: "bbbb".into(),
525 upstream: None,
526 gone: false,
527 worktree: None,
528 };
529 assert_eq!(
530 classify(&branch, Some("master"), "master", Some(proof.clone())),
531 Some(Class::Confirmed { proof })
532 );
533 }
534}