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