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 request: String,
91 },
92 Unconfirmed {
94 detail: String,
96 },
97 Unknown {
99 detail: String,
101 },
102}
103
104#[must_use]
113pub fn classify(branch: &Branch, current: Option<&str>, trunk: &str) -> Option<Class> {
114 if !branch.gone {
115 return None;
116 }
117 if current.is_some_and(|name| name == branch.name) {
118 return Some(Class::Kept {
119 reason: "the current branch".to_owned(),
120 });
121 }
122 if let Some(worktree) = &branch.worktree {
123 return Some(Class::WorktreeBound {
124 path: worktree.clone(),
125 });
126 }
127 if branch.name == trunk || branch.name.starts_with(PROTECTED_PREFIX) {
128 return Some(Class::Kept {
129 reason: "a protected branch".to_owned(),
130 });
131 }
132 Some(Class::Candidate)
133}
134
135#[must_use]
143pub fn confirmation(forge: Forge, body: &Value, tip: &str) -> Class {
144 let Some(requests) = body.as_array() else {
145 return Class::Unknown {
146 detail: "the forge answer is not a list of requests".to_owned(),
147 };
148 };
149 let confirmed = requests.iter().find_map(|request| match forge {
150 Forge::Github => (!request["merged_at"].is_null()
151 && request["head"]["sha"].as_str() == Some(tip))
152 .then(|| request["number"].as_u64())
153 .flatten()
154 .map(|number| format!("#{number}")),
155 Forge::Gitlab => (request["state"].as_str() == Some("merged")
156 && request["sha"].as_str() == Some(tip))
157 .then(|| request["iid"].as_u64())
158 .flatten()
159 .map(|iid| format!("!{iid}")),
160 });
161 confirmed.map_or_else(
162 || Class::Unconfirmed {
163 detail: "no merged request records this tip".to_owned(),
164 },
165 |request| Class::Confirmed { request },
166 )
167}
168
169#[must_use]
176pub fn merged_request_for(cli: &Path, target: &Path, forge: Forge, repo: &str, tip: &str) -> Class {
177 let path = match forge {
178 Forge::Github => format!("repos/{repo}/commits/{tip}/pulls"),
179 Forge::Gitlab => format!(
180 "projects/{}/repository/commits/{tip}/merge_requests",
181 repo.replace('/', "%2F")
182 ),
183 };
184 let answered = std::process::Command::new(cli)
185 .args(["api", &path])
186 .current_dir(target)
187 .env("GH_PAGER", "")
188 .env("GLAB_PAGER", "")
189 .output();
190 let output = match answered {
191 Ok(output) => output,
192 Err(source) => {
193 return Class::Unknown {
194 detail: format!("the forge CLI did not run: {source}"),
195 };
196 }
197 };
198 if output.status.success() {
199 return serde_json::from_slice::<Value>(&output.stdout).map_or_else(
200 |_| Class::Unknown {
201 detail: "the forge answer did not parse as JSON".to_owned(),
202 },
203 |body| confirmation(forge, &body, tip),
204 );
205 }
206 let stderr = String::from_utf8_lossy(&output.stderr);
211 if stderr.contains("HTTP 404") || stderr.contains("404 Not Found") {
212 return Class::Unconfirmed {
213 detail: "the forge does not know this commit".to_owned(),
214 };
215 }
216 Class::Unknown {
217 detail: last_line(&output.stderr),
218 }
219}
220
221fn last_line(bytes: &[u8]) -> String {
223 String::from_utf8_lossy(bytes)
224 .lines()
225 .rev()
226 .find(|line| !line.trim().is_empty())
227 .unwrap_or("no output")
228 .to_owned()
229}
230
231#[cfg(test)]
232mod tests {
233 #![allow(clippy::expect_used)]
234
235 use serde_json::json;
236
237 use super::{Branch, Class, classify, confirmation, parse_branches};
238 use crate::detect::Forge;
239
240 #[test]
243 fn a_for_each_ref_line_parses_into_a_branch() {
244 let text = "feat/x\taaaa\torigin/feat/x\t[gone]\t\n\
245 master\tbbbb\torigin/master\t\t/srv/checkouts/repo\n\
246 local-only\tcccc\t\t\t\n\
247 behind\tdddd\torigin/behind\t[behind 2]\t\n\
248 short\tline\n";
249 let branches = parse_branches(text);
250 assert_eq!(branches.len(), 4, "the short line is skipped");
251 assert_eq!(
252 branches[0],
253 Branch {
254 name: "feat/x".into(),
255 tip: "aaaa".into(),
256 upstream: Some("origin/feat/x".into()),
257 gone: true,
258 worktree: None,
259 }
260 );
261 assert_eq!(branches[1].worktree.as_deref(), Some("/srv/checkouts/repo"));
262 assert!(!branches[1].gone);
263 assert_eq!(branches[2].upstream, None);
264 assert!(!branches[3].gone, "[behind 2] is tracking, not gone");
265 }
266
267 #[test]
270 fn classification_guards_current_worktree_and_protected_branches() {
271 let gone = |name: &str, worktree: Option<&str>| Branch {
272 name: name.into(),
273 tip: "aaaa".into(),
274 upstream: Some(format!("origin/{name}")),
275 gone: true,
276 worktree: worktree.map(str::to_owned),
277 };
278 assert_eq!(
279 classify(&gone("feat/x", None), Some("feat/x"), "master"),
280 Some(Class::Kept {
281 reason: "the current branch".into()
282 })
283 );
284 assert_eq!(
285 classify(&gone("feat/x", Some("/wt")), Some("master"), "master"),
286 Some(Class::WorktreeBound { path: "/wt".into() })
287 );
288 assert_eq!(
289 classify(&gone("feat/x", Some("/wt")), Some("feat/x"), "master"),
290 Some(Class::Kept {
291 reason: "the current branch".into()
292 }),
293 "the current branch wins over its own worktree"
294 );
295 assert_eq!(
296 classify(&gone("master", None), None, "master"),
297 Some(Class::Kept {
298 reason: "a protected branch".into()
299 })
300 );
301 assert_eq!(
302 classify(&gone("release/1.2", None), None, "master"),
303 Some(Class::Kept {
304 reason: "a protected branch".into()
305 })
306 );
307 assert_eq!(
308 classify(&gone("feat/x", None), Some("master"), "master"),
309 Some(Class::Candidate)
310 );
311 let live = Branch {
312 gone: false,
313 ..gone("feat/live", None)
314 };
315 assert_eq!(classify(&live, None, "master"), None);
316 }
317
318 #[test]
321 fn a_merged_request_confirms_only_on_head_sha_equality() {
322 let github = json!([
323 {"number": 7, "merged_at": null, "head": {"sha": "aaaa"}},
324 {"number": 8, "merged_at": "2026-01-01T00:00:00Z", "head": {"sha": "aaaa"}},
325 ]);
326 assert_eq!(
327 confirmation(Forge::Github, &github, "aaaa"),
328 Class::Confirmed {
329 request: "#8".into()
330 }
331 );
332 assert_eq!(
333 confirmation(Forge::Github, &github, "bbbb"),
334 Class::Unconfirmed {
335 detail: "no merged request records this tip".into()
336 },
337 "a merged request for another tip proves nothing about this one"
338 );
339 let gitlab = json!([
340 {"iid": 3, "state": "opened", "sha": "aaaa"},
341 {"iid": 4, "state": "merged", "sha": "aaaa"},
342 ]);
343 assert_eq!(
344 confirmation(Forge::Gitlab, &gitlab, "aaaa"),
345 Class::Confirmed {
346 request: "!4".into()
347 }
348 );
349 assert!(matches!(
350 confirmation(Forge::Github, &json!({"message": "rate limited"}), "aaaa"),
351 Class::Unknown { .. }
352 ));
353 }
354}