Skip to main content

release_kit/commands/
worktree.rs

1//! `rk worktree list | add | prune`: the worktree half of the local
2//! cleanup pair.
3//!
4//! Mode-free by design: the verbs behave identically under the worktree
5//! and branches workflows, and the recorded mode gates only what the
6//! landed blocks render. The landed branches surface's idioms hold
7//! throughout — preview by default, `--apply` to act, `--json` with a
8//! versioned schema, `--quiet` for the hook path, exit 0 for any report,
9//! and refusals through the existing matrix. Every git spawn lives here;
10//! `crate::worktree` stays pure.
11
12use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::branches::{Branch, Class, FOR_EACH_REF_FORMAT, merged_request_for};
16use crate::cli::worktree::{WorktreeAction, WorktreeArgs};
17use crate::detect::Forge;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::maintenance;
21use crate::output::Output;
22use crate::setup::context::{TRUNK_BRANCH, resolve_cli};
23use crate::worktree::{Layout, Worktree, WtClass, classify, derived_path, matches_grammar};
24
25/// The closing line a prune report ends with while some reported row
26/// still names a move the operator may make; [`maintenance::row_owes`]
27/// is the shared predicate.
28const OPERATOR_LINE: &str = "Removing a worktree and deleting its branch are the operator's action: an agent reading this states the command and waits to be asked.";
29
30/// Dispatch the worktree surface.
31///
32/// # Errors
33///
34/// Refuses a target that is not a directory or not a git repository, an
35/// inventory the porcelain parser cannot trust, and the `add` refusals
36/// each documented below; propagates subprocess failures through the
37/// matrix after the report has named every outcome.
38pub fn run(args: &WorktreeArgs) -> Result<(), RkError> {
39    match &args.action {
40        WorktreeAction::List { target, json } => list(target, Output::new(*json)),
41        WorktreeAction::Add {
42            branch,
43            target,
44            base,
45            apply,
46            json,
47        } => add(target, branch, base.as_deref(), *apply, Output::new(*json)),
48        WorktreeAction::Prune {
49            target,
50            repo,
51            forge,
52            verify,
53            apply,
54            quiet,
55            json,
56        } => prune(
57            target,
58            repo.as_deref(),
59            forge.as_deref(),
60            *verify,
61            *apply,
62            *quiet,
63            Output::new(*json),
64        ),
65    }
66}
67
68// ---------------------------------------------------------------------------
69// The common gate
70
71/// The parsed worktree inventory, behind the common gate: the target is a
72/// directory, git lists it, and the porcelain parser trusts every record.
73fn inventory(target: &Utf8Path) -> Result<Vec<Worktree>, RkError> {
74    if !target.is_dir() {
75        return Err(RkError::missing(
76            Diagnostic::new(
77                Reason::TargetNotFound,
78                format!("target {target} is not a directory"),
79            )
80            .expected("an existing repository to read"),
81        ));
82    }
83    let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
84    if !listed.status.success() {
85        return Err(RkError::refusal(
86            Diagnostic::new(
87                Reason::PrerequisiteUnmet,
88                format!("target {target} is not a git repository"),
89            )
90            .expected("a repository whose worktrees git can list"),
91        ));
92    }
93    crate::worktree::parse_worktrees(&listed.stdout).map_err(|detail| {
94        RkError::refusal(
95            Diagnostic::new(
96                Reason::PrerequisiteUnmet,
97                format!("the worktree inventory cannot be trusted: {detail}"),
98            )
99            .expected("a worktree inventory this binary can parse whole")
100            .target_state("unchanged"),
101        )
102    })
103}
104
105/// The repository layout, from the inventory the gate already parsed.
106fn layout_of(worktrees: &[Worktree]) -> Result<Layout, RkError> {
107    Layout::of(worktrees).map_err(|detail| {
108        RkError::refusal(
109            Diagnostic::new(Reason::PrerequisiteUnmet, detail)
110                .expected("a main worktree the sibling convention composes with"),
111        )
112    })
113}
114
115/// The repository's main checkout, whichever of its worktrees named it.
116///
117/// `rk issue start` needs this under the branches mode: the mode works
118/// branches in the main checkout, and a `--target` naming a linked
119/// worktree would otherwise switch that worktree instead.
120///
121/// # Errors
122///
123/// The same gate [`plan_seat`] runs: a target that is not a directory,
124/// not a repository, or whose inventory the parser cannot trust.
125pub(crate) fn main_checkout(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
126    let worktrees = inventory(target)?;
127    Ok(layout_of(&worktrees)?.main)
128}
129
130/// Where a branch is checked out, if anywhere in this repository.
131///
132/// `rk issue start` needs this under the branches mode: `git switch`
133/// refuses a branch another worktree holds, and that refusal is knowable
134/// before the forge is written to.
135///
136/// # Errors
137///
138/// The same gate [`plan_seat`] runs over the inventory.
139pub(crate) fn seat_of(target: &Utf8Path, branch: &str) -> Result<Option<Utf8PathBuf>, RkError> {
140    Ok(inventory(target)?
141        .into_iter()
142        .find(|worktree| worktree.branch.as_deref() == Some(branch))
143        .map(|worktree| worktree.path))
144}
145
146/// The seats in use: the caller's own worktree, resolved from the process
147/// working directory, and the target's current worktree — both,
148/// independently, so invoking from worktree A with `--target` naming the
149/// main checkout still keeps A.
150fn seats(target: &Utf8Path) -> Vec<Utf8PathBuf> {
151    let toplevel = |output: std::io::Result<std::process::Output>| {
152        output
153            .ok()
154            .filter(|answer| answer.status.success())
155            .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
156            .filter(|path| !path.is_empty())
157            .map(Utf8PathBuf::from)
158    };
159    let scrubbed = || {
160        let mut command = std::process::Command::new(crate::probes::git_bin());
161        for var in maintenance::GIT_HOOK_VARS {
162            command.env_remove(var);
163        }
164        command
165    };
166    let mut seats = Vec::new();
167    if let Some(seat) = toplevel(scrubbed().args(["rev-parse", "--show-toplevel"]).output()) {
168        seats.push(seat);
169    }
170    if let Some(seat) = toplevel(
171        scrubbed()
172            .arg("-C")
173            .arg(target.as_std_path())
174            .args(["rev-parse", "--show-toplevel"])
175            .output(),
176    ) {
177        if !seats.contains(&seat) {
178            seats.push(seat);
179        }
180    }
181    seats
182}
183
184/// Whether one existing worktree holds uncommitted work; untracked files
185/// count, and a probe that cannot answer counts too — fail closed.
186fn is_dirty(path: &Utf8Path) -> bool {
187    git(path, &["status", "--porcelain"]).map_or(true, |probed| {
188        !probed.status.success() || !probed.stdout.is_empty()
189    })
190}
191
192/// The local branches, parsed; [`crate::branches::parse_branches`] skips
193/// a malformed line, so the caller judges absence against the worktrees.
194fn branch_inventory(target: &Utf8Path) -> Result<Vec<Branch>, RkError> {
195    let listed = git(
196        target,
197        &[
198            "for-each-ref",
199            "refs/heads",
200            "--format",
201            FOR_EACH_REF_FORMAT,
202        ],
203    )?;
204    if !listed.status.success() {
205        return Err(RkError::refusal(
206            Diagnostic::new(
207                Reason::PrerequisiteUnmet,
208                format!("target {target} is not a git repository"),
209            )
210            .expected("a repository whose branches git can list"),
211        ));
212    }
213    Ok(crate::branches::parse_branches(&String::from_utf8_lossy(
214        &listed.stdout,
215    )))
216}
217
218// ---------------------------------------------------------------------------
219// list
220
221/// One worktree in the list report.
222#[derive(Debug, Serialize)]
223struct ListRow {
224    /// The worktree's path.
225    path: String,
226    /// The checked-out branch; absent when detached.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    branch: Option<String>,
229    /// The full object name at HEAD.
230    head: String,
231    /// `main` or `linked`.
232    kind: &'static str,
233    /// One state by fixed precedence: locked over missing over detached
234    /// over dirty over clean.
235    state: &'static str,
236    /// Whether a linked worktree sits at its derived sibling path.
237    canonical: bool,
238}
239
240/// The machine form of a list report.
241#[derive(Debug, Serialize)]
242struct ListReport {
243    /// The shape version of this document.
244    schema: &'static str,
245    /// Every worktree, the main one first.
246    worktrees: Vec<ListRow>,
247    /// What plausibly follows.
248    next: Vec<String>,
249}
250
251/// The offline inventory: every worktree, one deterministic state each.
252fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
253    let worktrees = inventory(target)?;
254    let layout = layout_of(&worktrees)?;
255    let rows: Vec<ListRow> = worktrees
256        .iter()
257        .enumerate()
258        .map(|(index, worktree)| {
259            // The precedence is deterministic and pinned: a locked record
260            // whose directory is missing reports locked, an unlocked one
261            // reports missing, and no status probe runs on an absent path.
262            let state = if worktree.locked.is_some() {
263                "locked"
264            } else if worktree.prunable.is_some() {
265                "missing"
266            } else if worktree.branch.is_none() {
267                "detached"
268            } else if is_dirty(&worktree.path) {
269                "dirty"
270            } else {
271                "clean"
272            };
273            let canonical = index == 0
274                || worktree
275                    .branch
276                    .as_deref()
277                    .is_none_or(|branch| derived_path(&layout, branch) == worktree.path);
278            ListRow {
279                path: worktree.path.to_string(),
280                branch: worktree.branch.clone(),
281                head: worktree.head.clone(),
282                kind: if index == 0 { "main" } else { "linked" },
283                state,
284                canonical,
285            }
286        })
287        .collect();
288    let next = vec![
289        "rk worktree add <branch> creates or adopts a branch's worktree".to_owned(),
290        "rk worktree prune reports the worktrees a squash merge retired".to_owned(),
291    ];
292    out.result_line(format!(
293        "{} worktree{} of {}:",
294        rows.len(),
295        if rows.len() == 1 { "" } else { "s" },
296        layout.main
297    ));
298    let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
299    for row in &rows {
300        let head = row.head.get(..8).unwrap_or(&row.head);
301        let mut line = format!(
302            "  {:width$}  {head}  {}  {}",
303            row.path,
304            row.branch.as_deref().unwrap_or("(detached)"),
305            row.state
306        );
307        if !row.canonical {
308            if let Some(branch) = &row.branch {
309                use std::fmt::Write as _;
310                let expected = derived_path(&layout, branch);
311                let _ = write!(
312                    line,
313                    "  off-path: expected ../{}",
314                    expected.file_name().unwrap_or_default()
315                );
316            }
317        }
318        out.result_line(line);
319    }
320    out.next(&next);
321    out.emit(&ListReport {
322        schema: "rk.worktree-list/1",
323        worktrees: rows,
324        next,
325    })
326}
327
328// ---------------------------------------------------------------------------
329// add
330
331/// The machine form of an add report.
332#[derive(Debug, Serialize)]
333struct AddReport {
334    /// The shape version of this document.
335    schema: &'static str,
336    /// `preview` or `apply`.
337    mode: &'static str,
338    /// The branch the worktree seats.
339    branch: String,
340    /// The worktree's path, absolute.
341    path: String,
342    /// What the run creates: `branch` (a new branch and its worktree),
343    /// `worktree` (an existing branch adopted), or `nothing` (the
344    /// canonical worktree already stands).
345    created: &'static str,
346    /// Where the branch comes from: `adopted`, `remote`, `base`, or
347    /// `trunk`.
348    source: &'static str,
349    /// The commit-ish a created branch starts from, where one is created.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    base: Option<String>,
352    /// The upstream a tracking branch was created against, where one was.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    upstream: Option<String>,
355    /// What the run wants said beside the result.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    detail: Option<String>,
358    /// What plausibly follows.
359    next: Vec<String>,
360}
361
362/// One resolved source for the branch.
363pub(crate) struct Source {
364    /// `adopted`, `remote`, `base`, or `trunk`.
365    pub(crate) kind: &'static str,
366    /// What creates: `branch`, `worktree`, or `nothing`.
367    pub(crate) created: &'static str,
368    /// The commit-ish shown as the base, where a branch is created.
369    pub(crate) base: Option<String>,
370    /// The upstream of a created tracking branch.
371    pub(crate) upstream: Option<String>,
372    /// The exact git invocation, argv after `git`.
373    pub(crate) command: Vec<String>,
374}
375
376/// One branch's planned seat at its derived sibling path.
377pub(crate) enum Seat {
378    /// The canonical worktree already stands; nothing is created.
379    Satisfied {
380        /// Where it stands.
381        path: Utf8PathBuf,
382    },
383    /// The seat is not there yet, and the source says how it is made.
384    Fresh {
385        /// The derived path the worktree lands at.
386        path: Utf8PathBuf,
387        /// How the branch is resolved.
388        source: Source,
389        /// What the apply's refresh reported, where it failed.
390        detail: Option<String>,
391    },
392}
393
394/// Plan one branch's seat: every refusal first, then the source it comes
395/// from. Nothing is created here.
396///
397/// `rk worktree add` and `rk issue start` both seat a worktree, so both
398/// come through this one function and one derivation.
399///
400/// # Errors
401///
402/// A name the grammar or git refuses, the trunk, an option-shaped base, a
403/// branch already seated elsewhere, a stale record at the derived path,
404/// and a derived path already occupied.
405pub(crate) fn plan_seat(
406    target: &Utf8Path,
407    branch: &str,
408    base: Option<&str>,
409    apply: bool,
410) -> Result<Seat, RkError> {
411    let worktrees = inventory(target)?;
412    let layout = layout_of(&worktrees)?;
413
414    // The convention's grammar first — necessary, not sufficient — then
415    // git's own ref rules, so a name that would fail at `git worktree
416    // add` fails at the preview instead, with git's reason.
417    if !matches_grammar(branch) {
418        return Err(RkError::Usage(format!(
419            "branch '{branch}' is none of the three forms — <type>/<slug>, <issue-id>-<slug>, or release/<line> — the landed grammar admits"
420        )));
421    }
422    let checked = git(target, &["check-ref-format", "--branch", branch])?;
423    if !checked.status.success() {
424        return Err(RkError::Usage(format!(
425            "git refuses the branch name '{branch}': {}",
426            last_line(&checked.stderr)
427        )));
428    }
429    if branch == TRUNK_BRANCH {
430        return Err(RkError::refusal(
431            Diagnostic::new(
432                Reason::PrerequisiteUnmet,
433                format!("{TRUNK_BRANCH} takes no worktree; the main checkout is its seat"),
434            )
435            .expected("a short-lived branch to seat")
436            .target_state("unchanged"),
437        ));
438    }
439    if let Some(base) = base {
440        if base.starts_with('-') {
441            return Err(RkError::Usage(format!(
442                "--base '{base}' is option-shaped; pass a commit-ish"
443            )));
444        }
445    }
446    let path = derived_path(&layout, branch);
447
448    // Refusals before any mutation: the collision, and the non-canonical
449    // seat. The already-standing canonical worktree is satisfied instead.
450    let registered = worktrees
451        .iter()
452        .find(|worktree| worktree.branch.as_deref() == Some(branch));
453    if let Some(seat) = registered {
454        return judge_registered(seat, branch, &path, &layout.main);
455    }
456    if path.exists() {
457        let occupant = worktrees
458            .iter()
459            .find(|worktree| worktree.path == path)
460            .and_then(|worktree| worktree.branch.clone())
461            .map_or_else(
462                || "a directory this repository does not register".to_owned(),
463                |other| format!("the worktree of branch {other}"),
464            );
465        return Err(RkError::refusal(
466            Diagnostic::new(
467                Reason::StateDrift,
468                format!(
469                    "{path} already exists as {occupant}; flattening is not injective and nothing is suffixed silently"
470                ),
471            )
472            .expected("the derived path free, or registered to this branch")
473            .target_state("unchanged"),
474        ));
475    }
476
477    // Under apply, refresh through the remote's configured refmap first —
478    // best-effort, because a missing remote must not block a local
479    // branch — then resolve; a preview decides from the local refs as
480    // they stand and says so.
481    let mut detail = None;
482    if apply {
483        let fetched = git(target, &["fetch", "origin"])?;
484        if !fetched.status.success() {
485            detail = Some(format!(
486                "the fetch failed ({}); the run proceeded on local refs",
487                last_line(&fetched.stderr)
488            ));
489        }
490    }
491    let source = resolve_source(target, branch, base, &path)?;
492    Ok(Seat::Fresh {
493        path,
494        source,
495        detail,
496    })
497}
498
499/// Judge a branch this repository already registers a seat for: the
500/// canonical seat standing is satisfied, and everything else refuses with
501/// the recovery its own case takes.
502fn judge_registered(
503    seat: &Worktree,
504    branch: &str,
505    path: &Utf8Path,
506    main: &Utf8Path,
507) -> Result<Seat, RkError> {
508    if seat.path == path {
509        // Satisfied only while the seat actually stands: a record whose
510        // directory was deleted by hand is a stale record, not a standing
511        // worktree, and reporting it satisfied would print a path that
512        // does not exist.
513        // The recovery differs by lock: prune keeps a locked record
514        // unconditionally, so naming it for one would loop the operator
515        // back here forever.
516        if seat.prunable.is_some() || !path.is_dir() {
517            let recovery = if seat.locked.is_some() {
518                format!(
519                    "the record is locked, which prune keeps unconditionally: git worktree repair recovers a moved directory, or git worktree unlock {path} — for a lock you own — then rk worktree prune --apply clears it"
520                )
521            } else {
522                "rk worktree prune --apply clears the stale record, then re-run; git worktree repair recovers a moved directory instead".to_owned()
523            };
524            return Err(RkError::refusal(
525                Diagnostic::new(
526                    Reason::StateDrift,
527                    format!("{path} is registered to {branch} and its directory is missing"),
528                )
529                .expected("the canonical worktree standing, or its stale record cleared")
530                .action(recovery)
531                .target_state("unchanged"),
532            ));
533        }
534        return Ok(Seat::Satisfied {
535            path: path.to_owned(),
536        });
537    }
538    // The recovery differs by seat: the main checkout is never moved, so
539    // the branch leaves it; a linked worktree moves to the derived path,
540    // keeping its standing state.
541    let recovery = if seat.path == main {
542        format!("git switch {TRUNK_BRANCH} there, then re-run")
543    } else {
544        format!("git worktree move {} {path}", seat.path)
545    };
546    Err(RkError::refusal(
547        Diagnostic::new(
548            Reason::StateDrift,
549            format!(
550                "branch {branch} is checked out at {}, and one branch has one seat",
551                seat.path
552            ),
553        )
554        .expected("the branch free, or already at its derived path")
555        .action(recovery)
556        .target_state("unchanged"),
557    ))
558}
559
560/// Run a planned source, creating the worktree.
561///
562/// # Errors
563///
564/// The git invocation refusing, with its own last line.
565pub(crate) fn create_seat(target: &Utf8Path, source: &Source) -> Result<(), RkError> {
566    let argv: Vec<&str> = source.command.iter().map(String::as_str).collect();
567    let created = git(target, &argv)?;
568    if created.status.success() {
569        return Ok(());
570    }
571    Err(RkError::subprocess(
572        Diagnostic::new(
573            Reason::SubprocessFailed,
574            format!("git worktree add refused: {}", last_line(&created.stderr)),
575        )
576        .expected("the worktree created at the derived path")
577        .target_state("unchanged"),
578    ))
579}
580
581/// Create or adopt one branch's worktree at its derived sibling path.
582fn add(
583    target: &Utf8Path,
584    branch: &str,
585    base: Option<&str>,
586    apply: bool,
587    out: Output,
588) -> Result<(), RkError> {
589    let (path, source, detail) = match plan_seat(target, branch, base, apply)? {
590        Seat::Satisfied { path } => return report_satisfied(out, branch, &path, apply),
591        Seat::Fresh {
592            path,
593            source,
594            detail,
595        } => (path, source, detail),
596    };
597
598    if !apply {
599        out.result_line(format!(
600            "branch: {branch}  ({})",
601            match source.kind {
602                "adopted" => "existing, adopted".to_owned(),
603                "remote" => format!(
604                    "remote, from {}",
605                    source.upstream.as_deref().unwrap_or("origin")
606                ),
607                _ => format!("new, from {}", source.base.as_deref().unwrap_or("?")),
608            }
609        ));
610        out.result_line(format!(
611            "path:   ../{}",
612            path.file_name().unwrap_or_default()
613        ));
614        if let Some(base) = &source.base {
615            out.result_line(format!("base:   {base}"));
616        }
617        out.result_line(format!("would run: git {}", source.command.join(" ")));
618        let base_flag = base.map_or_else(String::new, |base| format!(" --base {base}"));
619        let next = vec![format!(
620            "rk worktree add {branch}{base_flag} --target {target} --apply creates it; the apply refreshes the remote refs and re-resolves"
621        )];
622        out.next(&next);
623        return out.emit(&AddReport {
624            schema: "rk.worktree-add/1",
625            mode: "preview",
626            branch: branch.to_owned(),
627            path: path.to_string(),
628            created: source.created,
629            source: source.kind,
630            base: source.base,
631            upstream: source.upstream,
632            detail: Some(
633                "a preview decides from the local refs as they stand; apply refreshes and re-resolves"
634                    .to_owned(),
635            ),
636            next,
637        });
638    }
639
640    create_seat(target, &source)?;
641    out.result_line(&path);
642    let next = vec![
643        format!("cd {path}"),
644        "rk worktree list reports every seat".to_owned(),
645    ];
646    out.next(&next);
647    out.emit(&AddReport {
648        schema: "rk.worktree-add/1",
649        mode: "apply",
650        branch: branch.to_owned(),
651        path: path.to_string(),
652        created: source.created,
653        source: source.kind,
654        base: source.base,
655        upstream: source.upstream,
656        detail,
657        next,
658    })
659}
660
661/// The idempotent outcome: the canonical worktree already stands.
662fn report_satisfied(
663    out: Output,
664    branch: &str,
665    path: &Utf8Path,
666    apply: bool,
667) -> Result<(), RkError> {
668    out.result_line(format!("{path} already seats {branch}; nothing to create"));
669    let next = vec![format!("cd {path}")];
670    out.next(&next);
671    out.emit(&AddReport {
672        schema: "rk.worktree-add/1",
673        mode: if apply { "apply" } else { "preview" },
674        branch: branch.to_owned(),
675        path: path.to_string(),
676        created: "nothing",
677        source: "adopted",
678        base: None,
679        upstream: None,
680        detail: None,
681        next,
682    })
683}
684
685/// The source precedence, in order: adopt a local branch, create a
686/// tracking branch from a lone matching remote tip, else create from
687/// `--base` or the refreshed trunk — so a forge-minted branch or the
688/// bot's release branch is seated from its real tip, never silently
689/// recreated from the trunk. Everything is resolved to an exact object
690/// name behind `--end-of-options` before any mutation, and the one name
691/// passed onward — the remote ref a tracking branch needs — sits in a
692/// documented value position, fully qualified.
693fn resolve_source(
694    target: &Utf8Path,
695    branch: &str,
696    base: Option<&str>,
697    path: &Utf8Path,
698) -> Result<Source, RkError> {
699    let resolve = |name: &str| -> Result<Option<String>, RkError> {
700        let resolved = git(
701            target,
702            &[
703                "rev-parse",
704                "--verify",
705                "--quiet",
706                "--end-of-options",
707                &format!("{name}^{{commit}}"),
708            ],
709        )?;
710        Ok(resolved
711            .status
712            .success()
713            .then(|| String::from_utf8_lossy(&resolved.stdout).trim().to_owned()))
714    };
715
716    // Arm 1: the branch exists locally — adopt it. The caller already
717    // handled a branch seated elsewhere; here it is free.
718    if resolve(&format!("refs/heads/{branch}"))?.is_some() {
719        return Ok(Source {
720            kind: "adopted",
721            created: "worktree",
722            base: None,
723            upstream: None,
724            command: vec![
725                "worktree".into(),
726                "add".into(),
727                path.to_string(),
728                branch.to_owned(),
729            ],
730        });
731    }
732
733    // Arm 2: exactly origin/<branch> exists — create the local tracking
734    // branch from that remote tip.
735    let remote_ref = format!("refs/remotes/origin/{branch}");
736    if resolve(&remote_ref)?.is_some() {
737        return Ok(Source {
738            kind: "remote",
739            created: "branch",
740            base: Some(format!("origin/{branch}")),
741            upstream: Some(format!("origin/{branch}")),
742            command: vec![
743                "worktree".into(),
744                "add".into(),
745                "--track".into(),
746                "-b".into(),
747                branch.to_owned(),
748                path.to_string(),
749                remote_ref,
750            ],
751        });
752    }
753
754    // Arm 3: --base where given, else the refreshed trunk; a release
755    // line requires the explicit base — a line is cut from a tag, never
756    // the tip.
757    if branch.starts_with(crate::branches::PROTECTED_PREFIX) && base.is_none() {
758        return Err(RkError::refusal(
759            Diagnostic::new(
760                Reason::PrerequisiteUnmet,
761                format!(
762                    "release line {branch} takes an explicit --base; a line is cut from a tag, never the tip"
763                ),
764            )
765            .expected("--base \"v<version>\" naming the tag the line patches")
766            .target_state("unchanged"),
767        ));
768    }
769    let (kind, shown) = base.map_or_else(
770        || ("trunk", format!("origin/{TRUNK_BRANCH}")),
771        |base| ("base", base.to_owned()),
772    );
773    let resolved = match resolve(&shown)? {
774        Some(oid) => Some(oid),
775        // A clone with no remote still creates from its own trunk.
776        None if kind == "trunk" => resolve(TRUNK_BRANCH)?,
777        None => None,
778    };
779    let oid = resolved.ok_or_else(|| {
780        RkError::refusal(
781            Diagnostic::new(
782                Reason::PrerequisiteUnmet,
783                format!("{shown} does not resolve to a commit"),
784            )
785            .expected("a commit-ish the new branch can start from")
786            .target_state("unchanged"),
787        )
788    })?;
789    Ok(Source {
790        kind,
791        created: "branch",
792        base: Some(shown),
793        upstream: None,
794        command: vec![
795            "worktree".into(),
796            "add".into(),
797            path.to_string(),
798            "-b".into(),
799            branch.to_owned(),
800            oid,
801        ],
802    })
803}
804
805// ---------------------------------------------------------------------------
806// prune
807
808/// One worktree in the prune report.
809#[derive(Debug, Serialize)]
810struct PruneRow {
811    /// The worktree's path.
812    path: String,
813    /// The branch it seats, where one is known.
814    #[serde(skip_serializing_if = "Option::is_none")]
815    branch: Option<String>,
816    /// The branch tip the judgment rests on, where one is known.
817    #[serde(skip_serializing_if = "Option::is_none")]
818    tip: Option<String>,
819    /// The judgment: kept, candidate, stale, confirmed, unconfirmed,
820    /// unknown, pruned, remove-failed, or branch-delete-failed.
821    status: &'static str,
822    /// The merged request that proved the tip, where one did.
823    #[serde(skip_serializing_if = "Option::is_none")]
824    request: Option<String>,
825    /// Why the worktree stays, or what is still owed.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    detail: Option<String>,
828}
829
830impl PruneRow {
831    /// The human tail of a row line.
832    fn describe(&self) -> String {
833        match self.status {
834            "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
835            "stale" => {
836                "stale: the registered directory is missing; apply clears the record".to_owned()
837            }
838            "confirmed" => format!(
839                "confirmed: merged request {} matches this tip",
840                self.request.as_deref().unwrap_or("")
841            ),
842            "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
843            "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
844            "pruned" => {
845                let mut line = self.request.as_deref().map_or_else(
846                    || "pruned".to_owned(),
847                    |request| format!("pruned (merged request {request})"),
848                );
849                if let Some(detail) = &self.detail {
850                    line.push_str("; ");
851                    line.push_str(detail);
852                }
853                line
854            }
855            "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
856            "branch-delete-failed" => format!(
857                "branch delete failed: {}",
858                self.detail.as_deref().unwrap_or("")
859            ),
860            _ => "candidate".to_owned(),
861        }
862    }
863}
864
865/// The machine form of a prune report.
866#[derive(Debug, Serialize)]
867struct PruneReport {
868    /// The shape version of this document.
869    schema: &'static str,
870    /// Which mode produced it: preview, verify, or apply.
871    mode: &'static str,
872    /// Every reportable worktree, judged; empty when the clone is clean.
873    worktrees: Vec<PruneRow>,
874    /// What plausibly follows.
875    next: Vec<String>,
876}
877
878/// One reportable worktree, carried from classification to the report.
879struct Judged {
880    worktree: Worktree,
881    /// The branch observation, where the join found one.
882    tip: Option<String>,
883    class: WtClass,
884}
885
886/// The ordered cleanup: report stale records and gone-upstream worktrees
887/// — never the main checkout or a healthy linked one — confirm against
888/// the forge under `--verify`, and remove worktree before branch under
889/// `--apply`, re-observing at the moment of action.
890#[allow(clippy::too_many_lines)]
891fn prune(
892    target: &Utf8Path,
893    repo_flag: Option<&str>,
894    forge_flag: Option<&str>,
895    verify: bool,
896    apply: bool,
897    quiet: bool,
898    out: Output,
899) -> Result<(), RkError> {
900    let worktrees = inventory(target)?;
901    let layout = layout_of(&worktrees)?;
902    let branches = branch_inventory(target)?;
903    // The join fails closed as a whole: no branch lines where the
904    // inventory names checked-out branches means the observation itself
905    // cannot be trusted, and no judgment is made over it.
906    if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
907        return Err(RkError::refusal(
908            Diagnostic::new(
909                Reason::PrerequisiteUnmet,
910                "the branch inventory did not parse, and no worktree is judged without its branch observation",
911            )
912            .expected("a branch listing covering the checked-out branches")
913            .target_state("unchanged"),
914        ));
915    }
916    let seat_paths = seats(target);
917    let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
918
919    // The reportable set: stale-eligible records, and linked worktrees
920    // whose branch observation is gone — or missing, which is kept by
921    // name, never guessed. A healthy seat is never a row.
922    let mut judged: Vec<Judged> = Vec::new();
923    for worktree in worktrees.iter().skip(1) {
924        let observation = worktree
925            .branch
926            .as_deref()
927            .and_then(|name| branches.iter().find(|branch| branch.name == name));
928        let reportable = worktree.prunable.is_some()
929            || worktree
930                .branch
931                .as_deref()
932                .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
933        if !reportable {
934            continue;
935        }
936        let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
937        let class = classify(
938            worktree,
939            observation,
940            &layout,
941            &seat_refs,
942            TRUNK_BRANCH,
943            dirty,
944        );
945        judged.push(Judged {
946            worktree: worktree.clone(),
947            tip: observation.map(|branch| branch.tip.clone()),
948            class,
949        });
950    }
951
952    // The forge is asked only where a candidate exists to confirm.
953    if (verify || apply)
954        && judged
955            .iter()
956            .any(|row| matches!(row.class, WtClass::Candidate))
957    {
958        let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
959        let forge = Forge::parse(&resolved.forge)
960            .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
961        let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
962        let cli = resolve_cli(forge)?;
963        for row in &mut judged {
964            if matches!(row.class, WtClass::Candidate) {
965                let Some(tip) = row.tip.as_deref() else {
966                    continue;
967                };
968                row.class = WtClass::Judged(merged_request_for(
969                    &cli,
970                    target.as_std_path(),
971                    forge,
972                    &repo,
973                    tip,
974                ));
975            }
976        }
977    }
978
979    let mut rows: Vec<PruneRow> = judged
980        .iter()
981        .map(|row| {
982            let (status, request, detail) = match &row.class {
983                WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
984                WtClass::Candidate => ("candidate", None, None),
985                WtClass::Stale => ("stale", None, None),
986                WtClass::Judged(Class::Confirmed { request }) => {
987                    ("confirmed", Some(request.clone()), None)
988                }
989                WtClass::Judged(Class::Unconfirmed { detail }) => {
990                    ("unconfirmed", None, Some(detail.clone()))
991                }
992                WtClass::Judged(Class::Unknown { detail }) => {
993                    ("unknown", None, Some(detail.clone()))
994                }
995                WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
996            };
997            PruneRow {
998                path: row.worktree.path.to_string(),
999                branch: row.worktree.branch.clone(),
1000                tip: row.tip.clone(),
1001                status,
1002                request,
1003                detail,
1004            }
1005        })
1006        .collect();
1007
1008    let mut failures = 0usize;
1009    if apply {
1010        for row in &mut rows {
1011            if row.status != "confirmed" {
1012                continue;
1013            }
1014            if let Err(count) = retire(target, row) {
1015                failures += count;
1016            }
1017        }
1018        failures += sweep_stale(target, &mut rows)?;
1019    }
1020
1021    let mode = if apply {
1022        "apply"
1023    } else if verify {
1024        "verify"
1025    } else {
1026        "preview"
1027    };
1028    let next = next_lines(mode);
1029    render(out, &rows, &next, quiet);
1030    out.emit(&PruneReport {
1031        schema: "rk.worktree-prune/1",
1032        mode,
1033        worktrees: rows,
1034        next,
1035    })?;
1036    if failures > 0 {
1037        return Err(RkError::subprocess(
1038            Diagnostic::new(
1039                Reason::SubprocessFailed,
1040                format!("git refused {failures} cleanup actions"),
1041            )
1042            .expected("every confirmed worktree removed; the report names each outcome"),
1043        ));
1044    }
1045    Ok(())
1046}
1047
1048/// Retire one confirmed worktree, ordered, each outcome independent:
1049/// re-observe at the last moment — verification authorizes only the
1050/// state it saw — then remove the worktree, then delete its branch
1051/// through the shared compare-and-swap helper. A failed remove leaves
1052/// the branch and its configuration untouched.
1053fn retire(target: &Utf8Path, row: &mut PruneRow) -> Result<(), usize> {
1054    let Some(branch) = row.branch.clone() else {
1055        return Ok(());
1056    };
1057    let Some(tip) = row.tip.clone() else {
1058        return Ok(());
1059    };
1060    let keep = |row: &mut PruneRow, moved: &str| {
1061        row.status = "kept";
1062        row.detail = Some(format!(
1063            "{moved} after verification; rk worktree prune --verify re-confirms"
1064        ));
1065    };
1066    let reread = git(
1067        target,
1068        &[
1069            "for-each-ref",
1070            &format!("refs/heads/{branch}"),
1071            "--format",
1072            "%(objectname)",
1073        ],
1074    )
1075    .map_err(|_| 1usize)?;
1076    let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
1077    if !reread.status.success() || fresh_tip != tip {
1078        keep(row, "the tip moved");
1079        return Ok(());
1080    }
1081    // The fresh inventory fails closed: an unobservable state clears no
1082    // removal, and the record must still be the same resource — the very
1083    // branch the merge proof named, unlocked, its directory standing.
1084    let path = Utf8PathBuf::from(&row.path);
1085    let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
1086    if !fresh.status.success() {
1087        keep(row, "the worktree inventory could not be re-read");
1088        return Ok(());
1089    }
1090    let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
1091        keep(row, "the worktree inventory could not be re-read");
1092        return Ok(());
1093    };
1094    let seat = inventory.iter().find(|worktree| worktree.path == path);
1095    if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
1096        keep(row, &reason);
1097        return Ok(());
1098    }
1099    if is_dirty(&path) {
1100        keep(row, "uncommitted changes arrived");
1101        return Ok(());
1102    }
1103    let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
1104    if !removed.status.success() {
1105        row.status = "remove-failed";
1106        row.detail = Some(format!(
1107            "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
1108            last_line(&removed.stderr)
1109        ));
1110        return Err(1);
1111    }
1112    match maintenance::delete_branch(target, &branch, &tip) {
1113        maintenance::Deletion::Deleted => {
1114            row.status = "pruned";
1115            Ok(())
1116        }
1117        maintenance::Deletion::ConfigSurvived { detail } => {
1118            row.status = "pruned";
1119            row.detail = Some(detail);
1120            Ok(())
1121        }
1122        maintenance::Deletion::Refused { detail } => {
1123            // Reported truthfully: the worktree is already gone, the
1124            // branch and its work survive, and the recovery is named.
1125            row.status = "branch-delete-failed";
1126            row.detail = Some(format!(
1127                "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1128            ));
1129            Err(1)
1130        }
1131    }
1132}
1133
1134/// Clear the stale records, once, after the loop: plain `git worktree
1135/// prune` is expiration-gated, so `--expire now` is the form that
1136/// guarantees the missing-directory records go — and only those; a
1137/// locked record is never touched and was never a stale row. Because it
1138/// is one command over many rows, its outcome is read per row from a
1139/// fresh inventory rather than assumed.
1140fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1141    if !rows.iter().any(|row| row.status == "stale") {
1142        return Ok(0);
1143    }
1144    let mut failures = 0usize;
1145    let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1146    // Fail closed: only an inventory that was actually re-read proves a
1147    // record gone, so an unreadable one marks every stale row failed
1148    // rather than claiming a sweep nothing observed.
1149    let survivors: Option<Vec<Utf8PathBuf>> =
1150        git(target, &["worktree", "list", "--porcelain", "-z"])
1151            .ok()
1152            .filter(|fresh| fresh.status.success())
1153            .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1154            .map(|inventory| {
1155                inventory
1156                    .into_iter()
1157                    .map(|worktree| worktree.path)
1158                    .collect()
1159            });
1160    for row in rows.iter_mut().filter(|row| row.status == "stale") {
1161        let survived = survivors
1162            .as_ref()
1163            .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1164        if survived {
1165            row.status = "remove-failed";
1166            row.detail = Some(if survivors.is_none() {
1167                "the record's fate could not be observed; re-run rk worktree prune --apply"
1168                    .to_owned()
1169            } else if swept.status.success() {
1170                "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1171            } else {
1172                format!(
1173                    "{}; re-run rk worktree prune --apply",
1174                    last_line(&swept.stderr)
1175                )
1176            });
1177            failures += 1;
1178        } else {
1179            row.status = "pruned";
1180        }
1181    }
1182    if !swept.status.success() && failures == 0 {
1183        failures = 1;
1184    }
1185    Ok(failures)
1186}
1187
1188/// What plausibly follows each mode; an apply is its own conclusion.
1189fn next_lines(mode: &str) -> Vec<String> {
1190    let verify = "rk worktree prune --verify confirms each candidate against the forge";
1191    let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1192    match mode {
1193        "preview" => vec![verify.to_owned(), apply.to_owned()],
1194        "verify" => vec![apply.to_owned()],
1195        _ => Vec::new(),
1196    }
1197}
1198
1199/// The human report: silent under `--quiet` when nothing is reportable —
1200/// the clean-clone guarantee the reminder hook rests on — one judged line
1201/// per reportable worktree otherwise, closed by who owns the removal only
1202/// while some row still names a move.
1203fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1204    if quiet && rows.is_empty() {
1205        return;
1206    }
1207    if rows.is_empty() {
1208        out.result_line("no worktree needs cleanup");
1209    } else {
1210        out.result_line(header(rows.len()));
1211        let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1212        for row in rows {
1213            let tip = row
1214                .tip
1215                .as_deref()
1216                .map_or("        ", |tip| tip.get(..8).unwrap_or(tip));
1217            out.result_line(format!("  {:width$}  {tip}  {}", row.path, row.describe()));
1218        }
1219    }
1220    out.next(next);
1221    if rows
1222        .iter()
1223        .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1224    {
1225        out.result_line(OPERATOR_LINE);
1226    }
1227}
1228
1229/// The count-bearing first line.
1230fn header(count: usize) -> String {
1231    if count == 1 {
1232        "1 worktree reports cleanup (a candidate, not proof):".to_owned()
1233    } else {
1234        format!("{count} worktrees report cleanup (a candidate, not proof):")
1235    }
1236}
1237
1238/// Run one git command against the target, spawn failure typed. The
1239/// hook variables are scrubbed: a run from inside a git hook must act
1240/// on the named target, never on the hook's own repository.
1241///
1242/// `rk issue start` spawns git too, and shares this so the scrubbing is
1243/// one owner rather than a rule each caller has to remember.
1244///
1245/// # Errors
1246///
1247/// git failing to spawn.
1248pub(crate) fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1249    let mut command = std::process::Command::new(crate::probes::git_bin());
1250    for var in maintenance::GIT_HOOK_VARS {
1251        command.env_remove(var);
1252    }
1253    command
1254        .arg("-C")
1255        .arg(target.as_std_path())
1256        .args(args)
1257        .output()
1258        .map_err(|source| {
1259            RkError::subprocess(
1260                Diagnostic::new(
1261                    Reason::SubprocessSpawn,
1262                    format!("git did not run: {source}"),
1263                )
1264                .expected("git installed and on PATH"),
1265            )
1266        })
1267}
1268
1269/// The last non-empty stderr line, for a one-line detail.
1270fn last_line(bytes: &[u8]) -> String {
1271    maintenance::last_line(bytes)
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276    #![allow(clippy::expect_used)]
1277
1278    use super::{ListReport, ListRow, PruneReport, PruneRow};
1279
1280    /// The complete `rk.worktree-list/1` shape, held by snapshot in the
1281    /// populated and empty-next forms.
1282    #[test]
1283    fn the_worktree_list_schema_snapshot_holds() {
1284        let populated = ListReport {
1285            schema: "rk.worktree-list/1",
1286            worktrees: vec![
1287                ListRow {
1288                    path: "/srv/widget".into(),
1289                    branch: Some("master".into()),
1290                    head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1291                    kind: "main",
1292                    state: "clean",
1293                    canonical: true,
1294                },
1295                ListRow {
1296                    path: "/srv/elsewhere".into(),
1297                    branch: None,
1298                    head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1299                    kind: "linked",
1300                    state: "detached",
1301                    canonical: true,
1302                },
1303            ],
1304            next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1305        };
1306        assert_eq!(
1307            serde_json::to_string(&populated).expect("a report serializes"),
1308            r#"{"schema":"rk.worktree-list/1","worktrees":[{"path":"/srv/widget","branch":"master","head":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","kind":"main","state":"clean","canonical":true},{"path":"/srv/elsewhere","head":"bbbbccccddddaaaabbbbccccddddaaaabbbbcccc","kind":"linked","state":"detached","canonical":true}],"next":["rk worktree prune reports the worktrees a squash merge retired"]}"#,
1309            "a detached row must omit branch rather than serializing null"
1310        );
1311    }
1312
1313    /// The complete `rk.worktree-add/1` shape, held by snapshot in the
1314    /// apply form with every optional field present and the preview form
1315    /// with each absent — so a field rename, removal, or a null leaking
1316    /// from an optional fails here, not at some agent's parser.
1317    #[test]
1318    fn the_worktree_add_schema_snapshot_holds() {
1319        let apply = super::AddReport {
1320            schema: "rk.worktree-add/1",
1321            mode: "apply",
1322            branch: "feat/x".into(),
1323            path: "/srv/widget@feat-x".into(),
1324            created: "branch",
1325            source: "remote",
1326            base: Some("origin/feat/x".into()),
1327            upstream: Some("origin/feat/x".into()),
1328            detail: Some("the fetch failed; the run proceeded on local refs".into()),
1329            next: vec!["cd /srv/widget@feat-x".into()],
1330        };
1331        assert_eq!(
1332            serde_json::to_string(&apply).expect("a report serializes"),
1333            r#"{"schema":"rk.worktree-add/1","mode":"apply","branch":"feat/x","path":"/srv/widget@feat-x","created":"branch","source":"remote","base":"origin/feat/x","upstream":"origin/feat/x","detail":"the fetch failed; the run proceeded on local refs","next":["cd /srv/widget@feat-x"]}"#
1334        );
1335        let preview = super::AddReport {
1336            mode: "preview",
1337            created: "nothing",
1338            source: "adopted",
1339            base: None,
1340            upstream: None,
1341            detail: None,
1342            ..apply
1343        };
1344        assert_eq!(
1345            serde_json::to_string(&preview).expect("a report serializes"),
1346            r#"{"schema":"rk.worktree-add/1","mode":"preview","branch":"feat/x","path":"/srv/widget@feat-x","created":"nothing","source":"adopted","next":["cd /srv/widget@feat-x"]}"#,
1347            "an absent option must be omitted rather than serializing null"
1348        );
1349    }
1350
1351    /// The complete `rk.worktree-prune/1` shape, held by snapshot in the
1352    /// populated and clean forms.
1353    #[test]
1354    fn the_worktree_prune_schema_snapshot_holds() {
1355        let populated = PruneReport {
1356            schema: "rk.worktree-prune/1",
1357            mode: "verify",
1358            worktrees: vec![
1359                PruneRow {
1360                    path: "/srv/widget@feat-x".into(),
1361                    branch: Some("feat/x".into()),
1362                    tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1363                    status: "confirmed",
1364                    request: Some("#8".into()),
1365                    detail: None,
1366                },
1367                PruneRow {
1368                    path: "/srv/widget@fix-y".into(),
1369                    branch: None,
1370                    tip: None,
1371                    status: "stale",
1372                    request: None,
1373                    detail: None,
1374                },
1375            ],
1376            next: vec![
1377                "rk worktree prune --apply verifies, then removes each worktree before its branch"
1378                    .into(),
1379            ],
1380        };
1381        assert_eq!(
1382            serde_json::to_string(&populated).expect("a report serializes"),
1383            r##"{"schema":"rk.worktree-prune/1","mode":"verify","worktrees":[{"path":"/srv/widget@feat-x","branch":"feat/x","tip":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","status":"confirmed","request":"#8"},{"path":"/srv/widget@fix-y","status":"stale"}],"next":["rk worktree prune --apply verifies, then removes each worktree before its branch"]}"##
1384        );
1385        let clean = PruneReport {
1386            schema: "rk.worktree-prune/1",
1387            mode: "preview",
1388            worktrees: vec![],
1389            next: vec![
1390                "rk worktree prune --verify confirms each candidate against the forge".into(),
1391            ],
1392        };
1393        assert_eq!(
1394            serde_json::to_string(&clean).expect("a report serializes"),
1395            r#"{"schema":"rk.worktree-prune/1","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1396            "a clean clone reports one empty list a caller can branch on"
1397        );
1398    }
1399}