Skip to main content

release_kit/
branches.rs

1//! Local-branch hygiene after 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. A merge that keeps the commits — a local rebase
9//! integration, a fast-forward merge, a hand `git merge --ff-only` —
10//! leaves the tip on the trunk, and the branch's own reflog says whether
11//! the tip is work or only where the branch was created. This module
12//! holds the pure half of `rk branches prune`: parsing what
13//! `git for-each-ref` and the reflog report, the guard order that keeps a
14//! branch out of the candidate set, and the confirmation predicates that
15//! turn a forge answer or a reachable tip into proof. Spawning stays in
16//! the handler and in `crate::maintenance`.
17
18use std::path::Path;
19
20use serde_json::Value;
21
22use crate::detect::Forge;
23
24/// The prefix naming a release line; a branch under it is never a
25/// candidate, whatever its upstream says.
26pub const PROTECTED_PREFIX: &str = "release/";
27
28/// The `--format` string the handler passes to `git for-each-ref`, one
29/// tab-separated line per local branch.
30///
31/// `%(upstream:track)` renders
32/// `[gone]` verbatim in format strings — plumbing, not the localized
33/// porcelain of `git branch -vv` — and `%(worktreepath)` is non-empty for
34/// a branch checked out in any worktree, the main one included.
35pub const FOR_EACH_REF_FORMAT: &str =
36    "%(refname:short)%09%(objectname)%09%(upstream:short)%09%(upstream:track)%09%(worktreepath)";
37
38/// One local branch, as `git for-each-ref` reports it.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Branch {
41    /// The short ref name.
42    pub name: String,
43    /// The full object name at the tip.
44    pub tip: String,
45    /// The configured upstream's short name, where one is configured.
46    pub upstream: Option<String>,
47    /// Whether the configured upstream no longer exists.
48    pub gone: bool,
49    /// The worktree the branch is checked out in, where it is.
50    pub worktree: Option<String>,
51}
52
53/// Parse the tab-separated `for-each-ref` output into branches, skipping
54/// any line that does not carry all five fields.
55#[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/// What the report says about one gone branch.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub enum Class {
79    /// Guarded out of the candidate set, with the guard's reason.
80    Kept {
81        /// Why the branch stays.
82        reason: String,
83    },
84    /// Gone upstream and unguarded: a candidate, not proof.
85    Candidate,
86    /// Checked out in a worktree: the worktree owns the cleanup, and
87    /// the worktree verb is the one that performs it.
88    WorktreeBound {
89        /// The worktree's path, as `for-each-ref` reports it.
90        path: String,
91    },
92    /// One of the three proofs covers this tip.
93    Confirmed {
94        /// Which proof, and what it names.
95        proof: Proof,
96    },
97    /// The forge answered and no merged request records this tip.
98    Unconfirmed {
99        /// What the answer lacked.
100        detail: String,
101    },
102    /// The forge could not answer; an apply keeps the branch.
103    Unknown {
104        /// Why the answer is missing.
105        detail: String,
106    },
107}
108
109/// What authorized a branch's retirement.
110///
111/// Exactly three proofs are admissible, and none stands in for another.
112/// The first two are records, one per integration authority, and both
113/// rest on the same predicate: the tip the proof recorded equals the tip
114/// observed now. The third is no record at all: the trunk reaches the tip
115/// observed now, and the branch moved since its reflog's creation entry,
116/// so the tip is work the trunk carries rather than the commit a fresh
117/// branch started at.
118///
119/// SATISFIES maintenance:gone-is-a-candidate-not-proof
120/// SATISFIES maintenance:one-merge-proof-authorizes-both-removals
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub enum Proof {
123    /// A merged request whose recorded head equals this tip.
124    Request(String),
125    /// A local integration whose recorded branch tip equals this tip.
126    LocalIntegration(String),
127    /// A trunk that reaches this tip, carrying the commit the branch's
128    /// creation entry names, which differs from the tip.
129    ReachableTip(String),
130}
131
132impl Proof {
133    /// What the report prints beside the branch.
134    #[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    /// Which proof this is, for the machine report.
149    #[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    /// Whether this proof rests on the clone alone, with no forge answer:
159    /// the prune reports name these as proof rather than candidates.
160    #[must_use]
161    pub const fn is_local(&self) -> bool {
162        matches!(self, Self::LocalIntegration(_) | Self::ReachableTip(_))
163    }
164}
165
166/// The commit a branch's latest creation entry names, from the raw reflog
167/// text git keeps for the ref: one `<old> <new> <identity>\t<message>`
168/// line per update, oldest first.
169///
170/// The creation entry is the one whose old object ID is all zeros. A
171/// reflog with none — expired in part, or rewritten — answers `None`, and
172/// so does one that does not parse: an absent creation proves nothing,
173/// which keeps the branch.
174#[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/// The third proof, judged: the trunk reaches `tip`, and the branch's
193/// creation entry names a commit other than `tip`.
194///
195/// A fresh branch still at the commit it was created at is new, not
196/// merged, so equality is no proof; and with no creation entry there is
197/// nothing to compare against, so the proof is unavailable.
198#[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/// Classify one branch: `None` when its upstream is live or unset and no
205/// local proof covers it — the branch never reaches the report — and the
206/// guard's verdict otherwise.
207///
208/// `local` is the proof the clone holds on its own: a recorded local
209/// integration, or a tip the trunk reaches.
210///
211/// The guards run in order and the first one holds: the current branch
212/// stays kept — it is the operator's own seat — a branch checked out in
213/// any other worktree is worktree-bound and belongs to the worktree
214/// verb, then the trunk and the release lines stay kept. What survives
215/// is a candidate.
216#[must_use]
217pub fn classify(
218    branch: &Branch,
219    current: Option<&str>,
220    trunk: &str,
221    local: Option<Proof>,
222) -> Option<Class> {
223    // A locally integrated branch never reached the forge, so it has no
224    // upstream to go gone. Its own evidence is what puts it in the
225    // report, and a branch that advanced after its integration matches
226    // no evidence and stays invisible, exactly as an advanced
227    // forge-merged branch does. A branch the trunk reaches is reported
228    // on the same footing.
229    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/// Judge one forge answer against one tip: only a merged request whose
254/// recorded head equals the local tip confirms, because a squash merge
255/// destroys the ancestry every other proof would rest on.
256///
257/// A branch
258/// advanced after its merge, or one whose upstream was deleted by hand,
259/// matches nothing and stays.
260#[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/// Ask the forge for the requests carrying one commit and judge the
290/// answer.
291///
292/// Every failure keeps the branch: a spawn error or an
293/// unclassifiable exit is `Unknown`, and a 404 — a tip the forge never
294/// saw — is `Unconfirmed`.
295#[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    // A definite not-found is proof of absence; anything less specific
327    // stays unknown. `gh` renders "HTTP 404", `glab` "404 Not Found" -
328    // a bare substring would read an outage message mentioning 404 as
329    // an answer.
330    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
341/// The last non-empty stderr line, for a one-line detail.
342fn 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    /// The five tab-separated fields parse, empty ones to `None`, and only
361    /// the literal `[gone]` marks a branch gone.
362    #[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    /// The guards hold in order — current, worktree, trunk, release line —
388    /// and a live or upstreamless branch never reaches the report.
389    #[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    /// Only a merged request whose recorded head equals the tip confirms;
439    /// an open request, a mismatched head, and a non-list answer never do.
440    #[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    /// The creation entry is the one whose old object ID is all zeros, and
476    /// the latest such entry answers. A reflog whose creation entry is gone
477    /// — expired in part, or never written — answers nothing, and so does
478    /// text that is no reflog.
479    #[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    /// The third proof needs all three facts: a creation entry, a tip that
504    /// moved from it, and a trunk that reaches the tip. A fresh branch at
505    /// its creation commit is new rather than merged.
506    #[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        // The proof confirms the branch the way local evidence does, after
521        // every guard.
522        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}