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    /// One of the two proofs covers this tip.
88    Confirmed {
89        /// Which proof, and what it names.
90        proof: Proof,
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/// What authorized a branch's retirement.
105///
106/// Exactly two proofs are admissible, one per integration authority, and
107/// neither stands in for the other. Both rest on the same predicate: the
108/// tip the proof recorded equals the tip observed now.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Proof {
111    /// A merged request whose recorded head equals this tip.
112    Request(String),
113    /// A local integration whose recorded branch tip equals this tip.
114    LocalIntegration(String),
115}
116
117impl Proof {
118    /// What the report prints beside the branch.
119    #[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    /// Which proof this is, for the machine report.
130    #[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/// Classify one branch: `None` when its upstream is live or unset — the
140/// branch never reaches the report — and the guard's verdict otherwise.
141///
142/// The guards run in order and the first one holds: the current branch
143/// stays kept — it is the operator's own seat — a branch checked out in
144/// any other worktree is worktree-bound and belongs to the worktree
145/// verb, then the trunk and the release lines stay kept. What survives
146/// is a candidate.
147#[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    // A locally integrated branch never reached the forge, so it has no
155    // upstream to go gone. Its own evidence is what puts it in the
156    // report, and a branch that advanced after its integration matches
157    // no evidence and stays invisible, exactly as an advanced
158    // forge-merged branch does.
159    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/// Judge one forge answer against one tip: only a merged request whose
186/// recorded head equals the local tip confirms, because a squash merge
187/// destroys the ancestry every other proof would rest on.
188///
189/// A branch
190/// advanced after its merge, or one whose upstream was deleted by hand,
191/// matches nothing and stays.
192#[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/// Ask the forge for the requests carrying one commit and judge the
222/// answer.
223///
224/// Every failure keeps the branch: a spawn error or an
225/// unclassifiable exit is `Unknown`, and a 404 — a tip the forge never
226/// saw — is `Unconfirmed`.
227#[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    // A definite not-found is proof of absence; anything less specific
259    // stays unknown. `gh` renders "HTTP 404", `glab` "404 Not Found" -
260    // a bare substring would read an outage message mentioning 404 as
261    // an answer.
262    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
273/// The last non-empty stderr line, for a one-line detail.
274fn 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    /// The five tab-separated fields parse, empty ones to `None`, and only
291    /// the literal `[gone]` marks a branch gone.
292    #[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    /// The guards hold in order — current, worktree, trunk, release line —
318    /// and a live or upstreamless branch never reaches the report.
319    #[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    /// Only a merged request whose recorded head equals the tip confirms;
369    /// an open request, a mismatched head, and a non-list answer never do.
370    #[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}