Skip to main content

release_kit/commands/
branches.rs

1//! `rk branches prune`: report the branches a squash merge retired.
2//!
3//! Preview by default and fully offline: the post-merge hook runs it on
4//! every pull, so the read path costs no network and no forge CLI. Only
5//! `--verify` and `--apply` resolve the forge, and only `--apply` deletes
6//! — each branch on the strength of a merged request whose recorded head
7//! equals the local tip, never on `[gone]` alone.
8
9use camino::Utf8Path;
10use serde::Serialize;
11
12use crate::branches::{Branch, Class, FOR_EACH_REF_FORMAT, classify, merged_request_for};
13use crate::cli::branches::{BranchesAction, BranchesArgs};
14use crate::detect::Forge;
15use crate::diagnostic::{Diagnostic, Reason};
16use crate::error::RkError;
17use crate::output::Output;
18use crate::setup::context::{TRUNK_BRANCH, resolve_cli};
19
20/// The closing line every non-quiet report ends with; it states who owns
21/// the deletion, in the same voice as the landed routing block.
22const OPERATOR_LINE: &str = "Deleting a branch is the operator's action: an agent reading this states the command and waits to be asked.";
23
24/// The machine form of a prune report.
25#[derive(Debug, Serialize)]
26struct Report {
27    /// The shape version of this document.
28    schema: &'static str,
29    /// Which mode produced it: preview, verify, or apply.
30    mode: &'static str,
31    /// Every gone branch, judged; empty when the clone is clean.
32    branches: Vec<Row>,
33    /// What plausibly follows.
34    next: Vec<String>,
35}
36
37/// One gone branch in the report.
38#[derive(Debug, Serialize)]
39struct Row {
40    /// The branch name.
41    name: String,
42    /// The full object name at the tip.
43    tip: String,
44    /// The judgment: candidate, kept, worktree-bound, confirmed,
45    /// unconfirmed, unknown, deleted, or delete-failed.
46    status: &'static str,
47    /// The merged request that proved the tip, where one did.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    request: Option<String>,
50    /// Why the branch was kept or the answer is missing.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    detail: Option<String>,
53    /// The worktree the branch is checked out in, for the worktree-bound
54    /// rows.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    worktree: Option<String>,
57}
58
59impl Row {
60    /// Map one judgment onto its wire form.
61    fn from(branch: &Branch, class: Class) -> Self {
62        let (status, request, detail, worktree) = match class {
63            Class::Kept { reason } => ("kept", None, Some(reason), None),
64            Class::Candidate => ("candidate", None, None, None),
65            Class::WorktreeBound { path } => ("worktree-bound", None, None, Some(path)),
66            Class::Confirmed { request } => ("confirmed", Some(request), None, None),
67            Class::Unconfirmed { detail } => ("unconfirmed", None, Some(detail), None),
68            Class::Unknown { detail } => ("unknown", None, Some(detail), None),
69        };
70        Self {
71            name: branch.name.clone(),
72            tip: branch.tip.clone(),
73            status,
74            request,
75            detail,
76            worktree,
77        }
78    }
79
80    /// The human tail of a row line.
81    fn describe(&self) -> String {
82        match self.status {
83            "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
84            "worktree-bound" => format!(
85                "worktree-bound: checked out at {}; its worktree owns the cleanup",
86                self.worktree.as_deref().unwrap_or("")
87            ),
88            "confirmed" => format!(
89                "confirmed: merged request {} matches this tip",
90                self.request.as_deref().unwrap_or("")
91            ),
92            "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
93            "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
94            "deleted" => {
95                let mut line = format!(
96                    "deleted (merged request {})",
97                    self.request.as_deref().unwrap_or("")
98                );
99                if let Some(detail) = &self.detail {
100                    line.push_str("; ");
101                    line.push_str(detail);
102                }
103                line
104            }
105            "delete-failed" => format!("delete failed: {}", self.detail.as_deref().unwrap_or("")),
106            _ => "candidate".to_owned(),
107        }
108    }
109}
110
111/// Dispatch the branches surface.
112///
113/// # Errors
114///
115/// Refuses when the target is not a git repository, propagates a git or
116/// forge-CLI resolution failure, and — under `--apply` — returns the
117/// subprocess failure of a deletion git itself refused, after the report
118/// has named every branch's outcome.
119pub fn run(args: &BranchesArgs) -> Result<(), RkError> {
120    match &args.action {
121        BranchesAction::Prune {
122            target,
123            repo,
124            forge,
125            verify,
126            apply,
127            quiet,
128            json,
129        } => prune(
130            target,
131            repo.as_deref(),
132            forge.as_deref(),
133            *verify,
134            *apply,
135            *quiet,
136            Output::new(*json),
137        ),
138    }
139}
140
141/// The whole verb: enumerate, guard, optionally confirm, optionally
142/// delete, and report.
143fn prune(
144    target: &Utf8Path,
145    repo_flag: Option<&str>,
146    forge_flag: Option<&str>,
147    verify: bool,
148    apply: bool,
149    quiet: bool,
150    out: Output,
151) -> Result<(), RkError> {
152    if !target.is_dir() {
153        return Err(RkError::missing(
154            Diagnostic::new(
155                Reason::TargetNotFound,
156                format!("target {target} is not a directory"),
157            )
158            .expected("an existing repository to read"),
159        ));
160    }
161    let listed = git(
162        target,
163        &[
164            "for-each-ref",
165            "refs/heads",
166            "--format",
167            FOR_EACH_REF_FORMAT,
168        ],
169    )?;
170    if !listed.status.success() {
171        return Err(RkError::refusal(
172            Diagnostic::new(
173                Reason::PrerequisiteUnmet,
174                format!("target {target} is not a git repository"),
175            )
176            .expected("a repository whose branches git can list"),
177        ));
178    }
179    let branches = crate::branches::parse_branches(&String::from_utf8_lossy(&listed.stdout));
180    let current = git(target, &["symbolic-ref", "--quiet", "--short", "HEAD"])
181        .ok()
182        .filter(|answer| answer.status.success())
183        .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
184        .filter(|name| !name.is_empty());
185    let mut judged: Vec<(&Branch, Class)> = branches
186        .iter()
187        .filter_map(|branch| {
188            classify(branch, current.as_deref(), TRUNK_BRANCH).map(|class| (branch, class))
189        })
190        .collect();
191
192    // The forge is asked only where a candidate exists to confirm: the
193    // clean path stays offline in every mode.
194    if (verify || apply)
195        && judged
196            .iter()
197            .any(|(_, class)| matches!(class, Class::Candidate))
198    {
199        confirm_candidates(target, forge_flag, repo_flag, &mut judged)?;
200    }
201
202    let mut rows: Vec<Row> = judged
203        .iter()
204        .map(|(branch, class)| Row::from(branch, class.clone()))
205        .collect();
206
207    let mut failed_deletes = 0usize;
208    if apply {
209        for row in &mut rows {
210            if row.status != "confirmed" {
211                continue;
212            }
213            if let Err(count) = delete_branch(target, row) {
214                failed_deletes += count;
215            }
216        }
217    }
218
219    let mode = if apply {
220        "apply"
221    } else if verify {
222        "verify"
223    } else {
224        "preview"
225    };
226    let next = next_lines(mode);
227    render(out, &rows, &next, quiet);
228    out.emit(&Report {
229        schema: "rk.branches-prune/1",
230        mode,
231        branches: rows,
232        next,
233    })?;
234    if failed_deletes > 0 {
235        return Err(RkError::subprocess(
236            Diagnostic::new(
237                Reason::SubprocessFailed,
238                format!("git refused to delete {failed_deletes} confirmed branches"),
239            )
240            .expected("every confirmed branch deleted; the report names each outcome"),
241        ));
242    }
243    Ok(())
244}
245
246/// Resolve the forge once and ask it about every candidate, in place.
247fn confirm_candidates(
248    target: &Utf8Path,
249    forge_flag: Option<&str>,
250    repo_flag: Option<&str>,
251    judged: &mut [(&Branch, Class)],
252) -> Result<(), RkError> {
253    let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
254    let forge = Forge::parse(&resolved.forge)
255        .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
256    let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
257    let cli = resolve_cli(forge)?;
258    for (branch, class) in judged {
259        if matches!(class, Class::Candidate) {
260            *class = merged_request_for(&cli, target.as_std_path(), forge, &repo, &branch.tip);
261        }
262    }
263    Ok(())
264}
265
266/// Delete one confirmed branch, updating its row; `Err(1)` counts a
267/// failed deletion toward the run's typed failure.
268///
269/// Two guards close the window between verification and deletion. The
270/// checkout state is re-read at the last instant — a branch someone
271/// checked out mid-run becomes worktree-bound, because `update-ref`,
272/// unlike `branch -D`, never looks at worktree HEADs. Then the deletion
273/// itself is a compare-and-delete: the verified tip travels with it, so
274/// a ref the forge CLI raced past the verification is refused, not lost.
275fn delete_branch(target: &Utf8Path, row: &mut Row) -> Result<(), usize> {
276    let ref_name = format!("refs/heads/{}", row.name);
277    let rechecked = git(
278        target,
279        &[
280            "for-each-ref",
281            &ref_name,
282            "--format",
283            "%(objectname)%09%(worktreepath)",
284        ],
285    )
286    .map_err(|_| 1usize)?;
287    match recheck_verdict(&rechecked) {
288        Err(detail) => {
289            // Fail closed: a probe that cannot answer proves nothing,
290            // and only a probe that answered "free" clears the delete.
291            row.status = "delete-failed";
292            row.detail = Some(detail);
293            return Err(1);
294        }
295        Ok(Some(worktree)) => {
296            row.status = "worktree-bound";
297            row.worktree = Some(worktree);
298            return Ok(());
299        }
300        Ok(None) => {}
301    }
302    let deleted = git(target, &["update-ref", "-d", &ref_name, &row.tip]).map_err(|_| 1usize)?;
303    if !deleted.status.success() {
304        row.status = "delete-failed";
305        row.detail = Some(last_line(&deleted.stderr));
306        return Err(1);
307    }
308    row.status = "deleted";
309    // What `git branch -d` would have removed beside the ref: the
310    // branch's own configuration section, so a later branch under the
311    // reused name inherits nothing stale. A section that was never
312    // written makes the removal fail, which is the common clean case;
313    // entries that survive the attempt are the reportable failure.
314    let section = format!("branch.{}", row.name);
315    let removed = git(target, &["config", "--remove-section", &section]).map_err(|_| 1usize)?;
316    if !removed.status.success() {
317        // Enumerate rather than pattern-match: a branch name can carry
318        // regex metacharacters, so the filter is an exact prefix test
319        // over the fixed-pattern listing.
320        let leftover =
321            git(target, &["config", "--get-regexp", "^branch\\."]).map_err(|_| 1usize)?;
322        let prefix = format!("branch.{}.", row.name);
323        let survives = leftover.status.success()
324            && String::from_utf8_lossy(&leftover.stdout)
325                .lines()
326                .any(|line| line.starts_with(&prefix));
327        if survives {
328            row.detail = Some("the branch configuration could not be removed".to_owned());
329        }
330    }
331    Ok(())
332}
333
334/// Judge the last-instant probe: `Err` when it did not answer, the
335/// worktree path when the branch is checked out, `None` when it is free.
336fn recheck_verdict(probe: &std::process::Output) -> Result<Option<String>, String> {
337    if !probe.status.success() {
338        return Err(format!(
339            "the checkout recheck failed: {}",
340            last_line(&probe.stderr)
341        ));
342    }
343    let answer = String::from_utf8_lossy(&probe.stdout);
344    let worktree = answer
345        .trim_end()
346        .split_once('\t')
347        .map(|(_, worktree)| worktree.to_owned())
348        .unwrap_or_default();
349    Ok((!worktree.is_empty()).then_some(worktree))
350}
351
352/// What plausibly follows each mode; an apply is its own conclusion.
353fn next_lines(mode: &str) -> Vec<String> {
354    let verify = "rk branches prune --verify confirms each candidate against the forge";
355    let apply = "rk branches prune --apply verifies, then deletes the confirmed branches";
356    match mode {
357        "preview" => vec![verify.to_owned(), apply.to_owned()],
358        "verify" => vec![apply.to_owned()],
359        _ => Vec::new(),
360    }
361}
362
363/// The human report: silent under `--quiet` when nothing is reportable,
364/// one judged line per gone branch otherwise, closed by who owns the
365/// deletion.
366fn render(out: Output, rows: &[Row], next: &[String], quiet: bool) {
367    if quiet && rows.is_empty() {
368        return;
369    }
370    if rows.is_empty() {
371        out.result_line("no local branch tracks a gone remote branch");
372    } else {
373        out.result_line(header(rows.len()));
374        let width = rows.iter().map(|row| row.name.len()).max().unwrap_or(0);
375        for row in rows {
376            let tip = row.tip.get(..8).unwrap_or(&row.tip);
377            out.result_line(format!("  {:width$}  {tip}  {}", row.name, row.describe()));
378        }
379    }
380    out.next(next);
381    out.result_line(OPERATOR_LINE);
382}
383
384/// The count-bearing first line.
385fn header(count: usize) -> String {
386    if count == 1 {
387        "1 local branch tracks a remote branch that is gone (a candidate, not proof):".to_owned()
388    } else {
389        format!(
390            "{count} local branches track a remote branch that is gone (a candidate, not proof):"
391        )
392    }
393}
394
395/// Run one git command against the target, spawn failure typed.
396fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
397    std::process::Command::new("git")
398        .arg("-C")
399        .arg(target.as_std_path())
400        .args(args)
401        .output()
402        .map_err(|source| {
403            RkError::subprocess(
404                Diagnostic::new(
405                    Reason::SubprocessSpawn,
406                    format!("git did not run: {source}"),
407                )
408                .expected("git installed and on PATH"),
409            )
410        })
411}
412
413/// The last non-empty stderr line, for a one-line detail.
414fn last_line(bytes: &[u8]) -> String {
415    String::from_utf8_lossy(bytes)
416        .lines()
417        .rev()
418        .find(|line| !line.trim().is_empty())
419        .unwrap_or("no output")
420        .to_owned()
421}
422
423#[cfg(test)]
424mod tests {
425    #![allow(clippy::expect_used)]
426
427    use super::{Report, Row, recheck_verdict};
428
429    /// The last-instant probe fails closed: no answer is an error, a
430    /// worktree path spares the branch, and only "free" clears the way.
431    #[cfg(unix)]
432    #[test]
433    fn the_recheck_verdict_fails_closed() {
434        use std::os::unix::process::ExitStatusExt as _;
435        let output = |code: i32, stdout: &str, stderr: &str| std::process::Output {
436            status: std::process::ExitStatus::from_raw(code << 8),
437            stdout: stdout.as_bytes().to_vec(),
438            stderr: stderr.as_bytes().to_vec(),
439        };
440        let failed = recheck_verdict(&output(128, "", "fatal: not a git repository"));
441        assert!(
442            failed.is_err_and(|detail| detail.contains("not a git repository")),
443            "a probe that cannot answer proves nothing"
444        );
445        assert_eq!(
446            recheck_verdict(&output(
447                0,
448                "aaaa	/srv/checkouts/wt
449",
450                ""
451            )),
452            Ok(Some("/srv/checkouts/wt".to_owned()))
453        );
454        assert_eq!(
455            recheck_verdict(&output(
456                0, "aaaa
457", ""
458            )),
459            Ok(None)
460        );
461    }
462
463    /// The complete `rk.branches-prune/1` shape, held by snapshot in both
464    /// the populated and the clean forms.
465    #[test]
466    fn the_branches_prune_schema_snapshot_holds() {
467        let populated = Report {
468            schema: "rk.branches-prune/1",
469            mode: "verify",
470            branches: vec![
471                Row {
472                    name: "feat/x".into(),
473                    tip: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
474                    status: "confirmed",
475                    request: Some("#8".into()),
476                    detail: None,
477                    worktree: None,
478                },
479                Row {
480                    name: "fix/y".into(),
481                    tip: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
482                    status: "kept",
483                    request: None,
484                    detail: Some("the current branch".into()),
485                    worktree: None,
486                },
487                Row {
488                    name: "fix/z".into(),
489                    tip: "ccccddddaaaabbbbccccddddaaaabbbbccccdddd".into(),
490                    status: "worktree-bound",
491                    request: None,
492                    detail: None,
493                    worktree: Some("/srv/checkouts/wt".into()),
494                },
495            ],
496            next: vec![
497                "rk branches prune --apply verifies, then deletes the confirmed branches".into(),
498            ],
499        };
500        assert_eq!(
501            serde_json::to_string(&populated).expect("a report serializes"),
502            r##"{"schema":"rk.branches-prune/1","mode":"verify","branches":[{"name":"feat/x","tip":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","status":"confirmed","request":"#8"},{"name":"fix/y","tip":"bbbbccccddddaaaabbbbccccddddaaaabbbbcccc","status":"kept","detail":"the current branch"},{"name":"fix/z","tip":"ccccddddaaaabbbbccccddddaaaabbbbccccdddd","status":"worktree-bound","worktree":"/srv/checkouts/wt"}],"next":["rk branches prune --apply verifies, then deletes the confirmed branches"]}"##
503        );
504        let clean = Report {
505            schema: "rk.branches-prune/1",
506            mode: "preview",
507            branches: vec![],
508            next: vec![
509                "rk branches prune --verify confirms each candidate against the forge".into(),
510                "rk branches prune --apply verifies, then deletes the confirmed branches".into(),
511            ],
512        };
513        assert_eq!(
514            serde_json::to_string(&clean).expect("a report serializes"),
515            r#"{"schema":"rk.branches-prune/1","mode":"preview","branches":[],"next":["rk branches prune --verify confirms each candidate against the forge","rk branches prune --apply verifies, then deletes the confirmed branches"]}"#,
516            "a clean clone reports one empty list a caller can branch on"
517        );
518    }
519}