Skip to main content

release_kit/commands/
branches.rs

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