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