Skip to main content

release_kit/
branches.rs

1//! Local-branch hygiene after squash merges.
2//!
3//! A squash merge rewrites the branch's work into one trunk commit, so the
4//! branch tip never becomes an ancestor of the trunk and git's own
5//! `--merged` test cannot see the merge. Once the forge deletes the remote
6//! branch and a pruning fetch drops the tracking ref, `[gone]` is the one
7//! local signal left — and it proves only that the upstream vanished,
8//! never that it merged. This module holds the pure half of `rk branches
9//! prune`: parsing what `git for-each-ref` reports, the guard order that
10//! keeps a branch out of the candidate set, and the confirmation predicate
11//! that turns a forge answer into proof. Spawning stays in the handler.
12
13use std::path::Path;
14
15use serde_json::Value;
16
17use crate::detect::Forge;
18
19/// The prefix naming a release line; a branch under it is never a
20/// candidate, whatever its upstream says.
21pub const PROTECTED_PREFIX: &str = "release/";
22
23/// The `--format` string the handler passes to `git for-each-ref`, one
24/// tab-separated line per local branch.
25///
26/// `%(upstream:track)` renders
27/// `[gone]` verbatim in format strings — plumbing, not the localized
28/// porcelain of `git branch -vv` — and `%(worktreepath)` is non-empty for
29/// a branch checked out in any worktree, the main one included.
30pub const FOR_EACH_REF_FORMAT: &str =
31    "%(refname:short)%09%(objectname)%09%(upstream:short)%09%(upstream:track)%09%(worktreepath)";
32
33/// One local branch, as `git for-each-ref` reports it.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Branch {
36    /// The short ref name.
37    pub name: String,
38    /// The full object name at the tip.
39    pub tip: String,
40    /// The configured upstream's short name, where one is configured.
41    pub upstream: Option<String>,
42    /// Whether the configured upstream no longer exists.
43    pub gone: bool,
44    /// The worktree the branch is checked out in, where it is.
45    pub worktree: Option<String>,
46}
47
48/// Parse the tab-separated `for-each-ref` output into branches, skipping
49/// any line that does not carry all five fields.
50#[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/// What the report says about one gone branch.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Class {
74    /// Guarded out of the candidate set, with the guard's reason.
75    Kept {
76        /// Why the branch stays.
77        reason: String,
78    },
79    /// Gone upstream and unguarded: a candidate, not proof.
80    Candidate,
81    /// Checked out in a worktree: the worktree owns the cleanup, and
82    /// the worktree verb is the one that performs it.
83    WorktreeBound {
84        /// The worktree's path, as `for-each-ref` reports it.
85        path: String,
86    },
87    /// A merged request's recorded head equals this tip.
88    Confirmed {
89        /// The request, as the forge names it: `#N` or `!N`.
90        request: String,
91    },
92    /// The forge answered and no merged request records this tip.
93    Unconfirmed {
94        /// What the answer lacked.
95        detail: String,
96    },
97    /// The forge could not answer; an apply keeps the branch.
98    Unknown {
99        /// Why the answer is missing.
100        detail: String,
101    },
102}
103
104/// Classify one branch: `None` when its upstream is live or unset — the
105/// branch never reaches the report — and the guard's verdict otherwise.
106///
107/// The guards run in order and the first one holds: the current branch
108/// stays kept — it is the operator's own seat — a branch checked out in
109/// any other worktree is worktree-bound and belongs to the worktree
110/// verb, then the trunk and the release lines stay kept. What survives
111/// is a candidate.
112#[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/// Judge one forge answer against one tip: only a merged request whose
136/// recorded head equals the local tip confirms, because a squash merge
137/// destroys the ancestry every other proof would rest on.
138///
139/// A branch
140/// advanced after its merge, or one whose upstream was deleted by hand,
141/// matches nothing and stays.
142#[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/// Ask the forge for the requests carrying one commit and judge the
170/// answer.
171///
172/// Every failure keeps the branch: a spawn error or an
173/// unclassifiable exit is `Unknown`, and a 404 — a tip the forge never
174/// saw — is `Unconfirmed`.
175#[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    // A definite not-found is proof of absence; anything less specific
207    // stays unknown. `gh` renders "HTTP 404", `glab` "404 Not Found" -
208    // a bare substring would read an outage message mentioning 404 as
209    // an answer.
210    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
221/// The last non-empty stderr line, for a one-line detail.
222fn 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    /// The five tab-separated fields parse, empty ones to `None`, and only
241    /// the literal `[gone]` marks a branch gone.
242    #[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    /// The guards hold in order — current, worktree, trunk, release line —
268    /// and a live or upstreamless branch never reaches the report.
269    #[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    /// Only a merged request whose recorded head equals the tip confirms;
319    /// an open request, a mismatched head, and a non-list answer never do.
320    #[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}