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::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 trunk = crate::config::trunk_of(target.as_std_path())?;
412    let worktrees = inventory(target)?;
413    let layout = layout_of(&worktrees)?;
414
415    // The convention's grammar first — necessary, not sufficient — then
416    // git's own ref rules, so a name that would fail at `git worktree
417    // add` fails at the preview instead, with git's reason.
418    if !matches_grammar(branch) {
419        return Err(RkError::Usage(format!(
420            "branch '{branch}' is none of the three forms — <type>/<slug>, <issue-id>-<slug>, or release/<line> — the landed grammar admits"
421        )));
422    }
423    let checked = git(target, &["check-ref-format", "--branch", branch])?;
424    if !checked.status.success() {
425        return Err(RkError::Usage(format!(
426            "git refuses the branch name '{branch}': {}",
427            last_line(&checked.stderr)
428        )));
429    }
430    if branch == trunk {
431        return Err(RkError::refusal(
432            Diagnostic::new(
433                Reason::PrerequisiteUnmet,
434                format!("{trunk} takes no worktree; the main checkout is its seat"),
435            )
436            .expected("a short-lived branch to seat")
437            .target_state("unchanged"),
438        ));
439    }
440    if let Some(base) = base {
441        if base.starts_with('-') {
442            return Err(RkError::Usage(format!(
443                "--base '{base}' is option-shaped; pass a commit-ish"
444            )));
445        }
446    }
447    let path = derived_path(&layout, branch);
448
449    // Refusals before any mutation: the collision, and the non-canonical
450    // seat. The already-standing canonical worktree is satisfied instead.
451    let registered = worktrees
452        .iter()
453        .find(|worktree| worktree.branch.as_deref() == Some(branch));
454    if let Some(seat) = registered {
455        return judge_registered(seat, branch, &path, &layout.main);
456    }
457    if path.exists() {
458        let occupant = worktrees
459            .iter()
460            .find(|worktree| worktree.path == path)
461            .and_then(|worktree| worktree.branch.clone())
462            .map_or_else(
463                || "a directory this repository does not register".to_owned(),
464                |other| format!("the worktree of branch {other}"),
465            );
466        return Err(RkError::refusal(
467            Diagnostic::new(
468                Reason::StateDrift,
469                format!(
470                    "{path} already exists as {occupant}; flattening is not injective and nothing is suffixed silently"
471                ),
472            )
473            .expected("the derived path free, or registered to this branch")
474            .target_state("unchanged"),
475        ));
476    }
477
478    // Under apply, refresh through the remote's configured refmap first —
479    // best-effort, because a missing remote must not block a local
480    // branch — then resolve; a preview decides from the local refs as
481    // they stand and says so.
482    let mut detail = None;
483    if apply {
484        let fetched = git(target, &["fetch", "origin"])?;
485        if !fetched.status.success() {
486            detail = Some(format!(
487                "the fetch failed ({}); the run proceeded on local refs",
488                last_line(&fetched.stderr)
489            ));
490        }
491    }
492    let source = resolve_source(target, branch, base, &path)?;
493    Ok(Seat::Fresh {
494        path,
495        source,
496        detail,
497    })
498}
499
500/// Judge a branch this repository already registers a seat for: the
501/// canonical seat standing is satisfied, and everything else refuses with
502/// the recovery its own case takes.
503fn judge_registered(
504    seat: &Worktree,
505    branch: &str,
506    path: &Utf8Path,
507    main: &Utf8Path,
508) -> Result<Seat, RkError> {
509    let trunk = crate::config::trunk_of(main.as_std_path())?;
510    if seat.path == path {
511        // Satisfied only while the seat actually stands: a record whose
512        // directory was deleted by hand is a stale record, not a standing
513        // worktree, and reporting it satisfied would print a path that
514        // does not exist.
515        // The recovery differs by lock: prune keeps a locked record
516        // unconditionally, so naming it for one would loop the operator
517        // back here forever.
518        if seat.prunable.is_some() || !path.is_dir() {
519            let recovery = if seat.locked.is_some() {
520                format!(
521                    "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"
522                )
523            } else {
524                "rk worktree prune --apply clears the stale record, then re-run; git worktree repair recovers a moved directory instead".to_owned()
525            };
526            return Err(RkError::refusal(
527                Diagnostic::new(
528                    Reason::StateDrift,
529                    format!("{path} is registered to {branch} and its directory is missing"),
530                )
531                .expected("the canonical worktree standing, or its stale record cleared")
532                .action(recovery)
533                .target_state("unchanged"),
534            ));
535        }
536        return Ok(Seat::Satisfied {
537            path: path.to_owned(),
538        });
539    }
540    // The recovery differs by seat: the main checkout is never moved, so
541    // the branch leaves it; a linked worktree moves to the derived path,
542    // keeping its standing state.
543    let recovery = if seat.path == main {
544        format!("git switch {trunk} there, then re-run")
545    } else {
546        format!("git worktree move {} {path}", seat.path)
547    };
548    Err(RkError::refusal(
549        Diagnostic::new(
550            Reason::StateDrift,
551            format!(
552                "branch {branch} is checked out at {}, and one branch has one seat",
553                seat.path
554            ),
555        )
556        .expected("the branch free, or already at its derived path")
557        .action(recovery)
558        .target_state("unchanged"),
559    ))
560}
561
562/// Run a planned source, creating the worktree.
563///
564/// # Errors
565///
566/// The git invocation refusing, with its own last line.
567pub(crate) fn create_seat(target: &Utf8Path, source: &Source) -> Result<(), RkError> {
568    let argv: Vec<&str> = source.command.iter().map(String::as_str).collect();
569    let created = git(target, &argv)?;
570    if created.status.success() {
571        return Ok(());
572    }
573    Err(RkError::subprocess(
574        Diagnostic::new(
575            Reason::SubprocessFailed,
576            format!("git worktree add refused: {}", last_line(&created.stderr)),
577        )
578        .expected("the worktree created at the derived path")
579        .target_state("unchanged"),
580    ))
581}
582
583/// Create or adopt one branch's worktree at its derived sibling path.
584fn add(
585    target: &Utf8Path,
586    branch: &str,
587    base: Option<&str>,
588    apply: bool,
589    out: Output,
590) -> Result<(), RkError> {
591    let (path, source, detail) = match plan_seat(target, branch, base, apply)? {
592        Seat::Satisfied { path } => return report_satisfied(out, branch, &path, apply),
593        Seat::Fresh {
594            path,
595            source,
596            detail,
597        } => (path, source, detail),
598    };
599
600    if !apply {
601        out.result_line(format!(
602            "branch: {branch}  ({})",
603            match source.kind {
604                "adopted" => "existing, adopted".to_owned(),
605                "remote" => format!(
606                    "remote, from {}",
607                    source.upstream.as_deref().unwrap_or("origin")
608                ),
609                _ => format!("new, from {}", source.base.as_deref().unwrap_or("?")),
610            }
611        ));
612        out.result_line(format!(
613            "path:   ../{}",
614            path.file_name().unwrap_or_default()
615        ));
616        if let Some(base) = &source.base {
617            out.result_line(format!("base:   {base}"));
618        }
619        out.result_line(format!("would run: git {}", source.command.join(" ")));
620        let base_flag = base.map_or_else(String::new, |base| format!(" --base {base}"));
621        let next = vec![format!(
622            "rk worktree add {branch}{base_flag} --target {target} --apply creates it; the apply refreshes the remote refs and re-resolves"
623        )];
624        out.next(&next);
625        return out.emit(&AddReport {
626            schema: "rk.worktree-add/1",
627            mode: "preview",
628            branch: branch.to_owned(),
629            path: path.to_string(),
630            created: source.created,
631            source: source.kind,
632            base: source.base,
633            upstream: source.upstream,
634            detail: Some(
635                "a preview decides from the local refs as they stand; apply refreshes and re-resolves"
636                    .to_owned(),
637            ),
638            next,
639        });
640    }
641
642    create_seat(target, &source)?;
643    out.result_line(&path);
644    let next = vec![
645        format!("cd {path}"),
646        "rk worktree list reports every seat".to_owned(),
647    ];
648    out.next(&next);
649    out.emit(&AddReport {
650        schema: "rk.worktree-add/1",
651        mode: "apply",
652        branch: branch.to_owned(),
653        path: path.to_string(),
654        created: source.created,
655        source: source.kind,
656        base: source.base,
657        upstream: source.upstream,
658        detail,
659        next,
660    })
661}
662
663/// The idempotent outcome: the canonical worktree already stands.
664fn report_satisfied(
665    out: Output,
666    branch: &str,
667    path: &Utf8Path,
668    apply: bool,
669) -> Result<(), RkError> {
670    out.result_line(format!("{path} already seats {branch}; nothing to create"));
671    let next = vec![format!("cd {path}")];
672    out.next(&next);
673    out.emit(&AddReport {
674        schema: "rk.worktree-add/1",
675        mode: if apply { "apply" } else { "preview" },
676        branch: branch.to_owned(),
677        path: path.to_string(),
678        created: "nothing",
679        source: "adopted",
680        base: None,
681        upstream: None,
682        detail: None,
683        next,
684    })
685}
686
687/// The source precedence, in order: adopt a local branch, create a
688/// tracking branch from a lone matching remote tip, else create from
689/// `--base` or the refreshed trunk — so a forge-minted branch or the
690/// bot's release branch is seated from its real tip, never silently
691/// recreated from the trunk. Everything is resolved to an exact object
692/// name behind `--end-of-options` before any mutation, and the one name
693/// passed onward — the remote ref a tracking branch needs — sits in a
694/// documented value position, fully qualified.
695fn resolve_source(
696    target: &Utf8Path,
697    branch: &str,
698    base: Option<&str>,
699    path: &Utf8Path,
700) -> Result<Source, RkError> {
701    let trunk = crate::config::trunk_of(target.as_std_path())?;
702    let resolve = |name: &str| -> Result<Option<String>, RkError> {
703        let resolved = git(
704            target,
705            &[
706                "rev-parse",
707                "--verify",
708                "--quiet",
709                "--end-of-options",
710                &format!("{name}^{{commit}}"),
711            ],
712        )?;
713        Ok(resolved
714            .status
715            .success()
716            .then(|| String::from_utf8_lossy(&resolved.stdout).trim().to_owned()))
717    };
718
719    // Arm 1: the branch exists locally — adopt it. The caller already
720    // handled a branch seated elsewhere; here it is free.
721    if resolve(&format!("refs/heads/{branch}"))?.is_some() {
722        return Ok(Source {
723            kind: "adopted",
724            created: "worktree",
725            base: None,
726            upstream: None,
727            command: vec![
728                "worktree".into(),
729                "add".into(),
730                path.to_string(),
731                branch.to_owned(),
732            ],
733        });
734    }
735
736    // Arm 2: exactly origin/<branch> exists — create the local tracking
737    // branch from that remote tip.
738    let remote_ref = format!("refs/remotes/origin/{branch}");
739    if resolve(&remote_ref)?.is_some() {
740        return Ok(Source {
741            kind: "remote",
742            created: "branch",
743            base: Some(format!("origin/{branch}")),
744            upstream: Some(format!("origin/{branch}")),
745            command: vec![
746                "worktree".into(),
747                "add".into(),
748                "--track".into(),
749                "-b".into(),
750                branch.to_owned(),
751                path.to_string(),
752                remote_ref,
753            ],
754        });
755    }
756
757    // Arm 3: --base where given, else the refreshed trunk; a release
758    // line requires the explicit base — a line is cut from a tag, never
759    // the tip.
760    if branch.starts_with(crate::branches::PROTECTED_PREFIX) && base.is_none() {
761        return Err(RkError::refusal(
762            Diagnostic::new(
763                Reason::PrerequisiteUnmet,
764                format!(
765                    "release line {branch} takes an explicit --base; a line is cut from a tag, never the tip"
766                ),
767            )
768            .expected("--base \"v<version>\" naming the tag the line patches")
769            .target_state("unchanged"),
770        ));
771    }
772    let (kind, shown) = base.map_or_else(
773        || ("trunk", format!("origin/{trunk}")),
774        |base| ("base", base.to_owned()),
775    );
776    let resolved = match resolve(&shown)? {
777        Some(oid) => Some(oid),
778        // A clone with no remote still creates from its own trunk.
779        None if kind == "trunk" => resolve(&trunk)?,
780        None => None,
781    };
782    let oid = resolved.ok_or_else(|| {
783        RkError::refusal(
784            Diagnostic::new(
785                Reason::PrerequisiteUnmet,
786                format!("{shown} does not resolve to a commit"),
787            )
788            .expected("a commit-ish the new branch can start from")
789            .target_state("unchanged"),
790        )
791    })?;
792    Ok(Source {
793        kind,
794        created: "branch",
795        base: Some(shown),
796        upstream: None,
797        command: vec![
798            "worktree".into(),
799            "add".into(),
800            path.to_string(),
801            "-b".into(),
802            branch.to_owned(),
803            oid,
804        ],
805    })
806}
807
808// ---------------------------------------------------------------------------
809// prune
810
811/// One worktree in the prune report.
812#[derive(Debug, Serialize)]
813struct PruneRow {
814    /// The worktree's path.
815    path: String,
816    /// The branch it seats, where one is known.
817    #[serde(skip_serializing_if = "Option::is_none")]
818    branch: Option<String>,
819    /// The branch tip the judgment rests on, where one is known.
820    #[serde(skip_serializing_if = "Option::is_none")]
821    tip: Option<String>,
822    /// The judgment: kept, candidate, stale, confirmed, unconfirmed,
823    /// unknown, pruned, remove-failed, or branch-delete-failed.
824    status: &'static str,
825    /// The merged request that proved the tip, where one did.
826    #[serde(skip_serializing_if = "Option::is_none")]
827    request: Option<String>,
828    /// Why the worktree stays, or what is still owed.
829    #[serde(skip_serializing_if = "Option::is_none")]
830    detail: Option<String>,
831}
832
833impl PruneRow {
834    /// The human tail of a row line.
835    fn describe(&self) -> String {
836        match self.status {
837            "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
838            "stale" => {
839                "stale: the registered directory is missing; apply clears the record".to_owned()
840            }
841            "confirmed" => format!(
842                "confirmed: merged request {} matches this tip",
843                self.request.as_deref().unwrap_or("")
844            ),
845            "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
846            "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
847            "pruned" => {
848                let mut line = self.request.as_deref().map_or_else(
849                    || "pruned".to_owned(),
850                    |request| format!("pruned (merged request {request})"),
851                );
852                if let Some(detail) = &self.detail {
853                    line.push_str("; ");
854                    line.push_str(detail);
855                }
856                line
857            }
858            "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
859            "branch-delete-failed" => format!(
860                "branch delete failed: {}",
861                self.detail.as_deref().unwrap_or("")
862            ),
863            _ => "candidate".to_owned(),
864        }
865    }
866}
867
868/// The machine form of a prune report.
869#[derive(Debug, Serialize)]
870struct PruneReport {
871    /// The shape version of this document.
872    schema: &'static str,
873    /// Which mode produced it: preview, verify, or apply.
874    mode: &'static str,
875    /// Every reportable worktree, judged; empty when the clone is clean.
876    worktrees: Vec<PruneRow>,
877    /// What plausibly follows.
878    next: Vec<String>,
879}
880
881/// One reportable worktree, carried from classification to the report.
882struct Judged {
883    worktree: Worktree,
884    /// The branch observation, where the join found one.
885    tip: Option<String>,
886    class: WtClass,
887}
888
889/// The ordered cleanup: report stale records and gone-upstream worktrees
890/// — never the main checkout or a healthy linked one — confirm against
891/// the forge under `--verify`, and remove worktree before branch under
892/// `--apply`, re-observing at the moment of action.
893#[allow(clippy::too_many_lines)]
894fn prune(
895    target: &Utf8Path,
896    repo_flag: Option<&str>,
897    forge_flag: Option<&str>,
898    verify: bool,
899    apply: bool,
900    quiet: bool,
901    out: Output,
902) -> Result<(), RkError> {
903    let trunk = crate::config::trunk_of(target.as_std_path())?;
904    let worktrees = inventory(target)?;
905    let layout = layout_of(&worktrees)?;
906    let branches = branch_inventory(target)?;
907    // The join fails closed as a whole: no branch lines where the
908    // inventory names checked-out branches means the observation itself
909    // cannot be trusted, and no judgment is made over it.
910    if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
911        return Err(RkError::refusal(
912            Diagnostic::new(
913                Reason::PrerequisiteUnmet,
914                "the branch inventory did not parse, and no worktree is judged without its branch observation",
915            )
916            .expected("a branch listing covering the checked-out branches")
917            .target_state("unchanged"),
918        ));
919    }
920    let seat_paths = seats(target);
921    let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
922
923    // The reportable set: stale-eligible records, and linked worktrees
924    // whose branch observation is gone — or missing, which is kept by
925    // name, never guessed. A healthy seat is never a row.
926    let mut judged: Vec<Judged> = Vec::new();
927    for worktree in worktrees.iter().skip(1) {
928        let observation = worktree
929            .branch
930            .as_deref()
931            .and_then(|name| branches.iter().find(|branch| branch.name == name));
932        let reportable = worktree.prunable.is_some()
933            || worktree
934                .branch
935                .as_deref()
936                .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
937        if !reportable {
938            continue;
939        }
940        let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
941        let class = classify(worktree, observation, &layout, &seat_refs, &trunk, dirty);
942        judged.push(Judged {
943            worktree: worktree.clone(),
944            tip: observation.map(|branch| branch.tip.clone()),
945            class,
946        });
947    }
948
949    // The forge is asked only where a candidate exists to confirm.
950    if (verify || apply)
951        && judged
952            .iter()
953            .any(|row| matches!(row.class, WtClass::Candidate))
954    {
955        let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
956        let forge = Forge::parse(&resolved.forge)
957            .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
958        let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
959        let cli = resolve_cli(forge)?;
960        for row in &mut judged {
961            if matches!(row.class, WtClass::Candidate) {
962                let Some(tip) = row.tip.as_deref() else {
963                    continue;
964                };
965                row.class = WtClass::Judged(merged_request_for(
966                    &cli,
967                    target.as_std_path(),
968                    forge,
969                    &repo,
970                    tip,
971                ));
972            }
973        }
974    }
975
976    let mut rows: Vec<PruneRow> = judged
977        .iter()
978        .map(|row| {
979            let (status, request, detail) = match &row.class {
980                WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
981                WtClass::Candidate => ("candidate", None, None),
982                WtClass::Stale => ("stale", None, None),
983                WtClass::Judged(Class::Confirmed { request }) => {
984                    ("confirmed", Some(request.clone()), None)
985                }
986                WtClass::Judged(Class::Unconfirmed { detail }) => {
987                    ("unconfirmed", None, Some(detail.clone()))
988                }
989                WtClass::Judged(Class::Unknown { detail }) => {
990                    ("unknown", None, Some(detail.clone()))
991                }
992                WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
993            };
994            PruneRow {
995                path: row.worktree.path.to_string(),
996                branch: row.worktree.branch.clone(),
997                tip: row.tip.clone(),
998                status,
999                request,
1000                detail,
1001            }
1002        })
1003        .collect();
1004
1005    let mut failures = 0usize;
1006    if apply {
1007        for row in &mut rows {
1008            if row.status != "confirmed" {
1009                continue;
1010            }
1011            if let Err(count) = retire(target, row) {
1012                failures += count;
1013            }
1014        }
1015        failures += sweep_stale(target, &mut rows)?;
1016    }
1017
1018    let mode = if apply {
1019        "apply"
1020    } else if verify {
1021        "verify"
1022    } else {
1023        "preview"
1024    };
1025    let next = next_lines(mode);
1026    render(out, &rows, &next, quiet);
1027    out.emit(&PruneReport {
1028        schema: "rk.worktree-prune/1",
1029        mode,
1030        worktrees: rows,
1031        next,
1032    })?;
1033    if failures > 0 {
1034        return Err(RkError::subprocess(
1035            Diagnostic::new(
1036                Reason::SubprocessFailed,
1037                format!("git refused {failures} cleanup actions"),
1038            )
1039            .expected("every confirmed worktree removed; the report names each outcome"),
1040        ));
1041    }
1042    Ok(())
1043}
1044
1045/// Retire one confirmed worktree, ordered, each outcome independent:
1046/// re-observe at the last moment — verification authorizes only the
1047/// state it saw — then remove the worktree, then delete its branch
1048/// through the shared compare-and-swap helper. A failed remove leaves
1049/// the branch and its configuration untouched.
1050fn retire(target: &Utf8Path, row: &mut PruneRow) -> Result<(), usize> {
1051    let Some(branch) = row.branch.clone() else {
1052        return Ok(());
1053    };
1054    let Some(tip) = row.tip.clone() else {
1055        return Ok(());
1056    };
1057    let keep = |row: &mut PruneRow, moved: &str| {
1058        row.status = "kept";
1059        row.detail = Some(format!(
1060            "{moved} after verification; rk worktree prune --verify re-confirms"
1061        ));
1062    };
1063    let reread = git(
1064        target,
1065        &[
1066            "for-each-ref",
1067            &format!("refs/heads/{branch}"),
1068            "--format",
1069            "%(objectname)",
1070        ],
1071    )
1072    .map_err(|_| 1usize)?;
1073    let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
1074    if !reread.status.success() || fresh_tip != tip {
1075        keep(row, "the tip moved");
1076        return Ok(());
1077    }
1078    // The fresh inventory fails closed: an unobservable state clears no
1079    // removal, and the record must still be the same resource — the very
1080    // branch the merge proof named, unlocked, its directory standing.
1081    let path = Utf8PathBuf::from(&row.path);
1082    let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
1083    if !fresh.status.success() {
1084        keep(row, "the worktree inventory could not be re-read");
1085        return Ok(());
1086    }
1087    let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
1088        keep(row, "the worktree inventory could not be re-read");
1089        return Ok(());
1090    };
1091    let seat = inventory.iter().find(|worktree| worktree.path == path);
1092    if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
1093        keep(row, &reason);
1094        return Ok(());
1095    }
1096    if is_dirty(&path) {
1097        keep(row, "uncommitted changes arrived");
1098        return Ok(());
1099    }
1100    let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
1101    if !removed.status.success() {
1102        row.status = "remove-failed";
1103        row.detail = Some(format!(
1104            "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
1105            last_line(&removed.stderr)
1106        ));
1107        return Err(1);
1108    }
1109    match maintenance::delete_branch(target, &branch, &tip) {
1110        maintenance::Deletion::Deleted => {
1111            row.status = "pruned";
1112            Ok(())
1113        }
1114        maintenance::Deletion::ConfigSurvived { detail } => {
1115            row.status = "pruned";
1116            row.detail = Some(detail);
1117            Ok(())
1118        }
1119        maintenance::Deletion::Refused { detail } => {
1120            // Reported truthfully: the worktree is already gone, the
1121            // branch and its work survive, and the recovery is named.
1122            row.status = "branch-delete-failed";
1123            row.detail = Some(format!(
1124                "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1125            ));
1126            Err(1)
1127        }
1128    }
1129}
1130
1131/// Clear the stale records, once, after the loop: plain `git worktree
1132/// prune` is expiration-gated, so `--expire now` is the form that
1133/// guarantees the missing-directory records go — and only those; a
1134/// locked record is never touched and was never a stale row. Because it
1135/// is one command over many rows, its outcome is read per row from a
1136/// fresh inventory rather than assumed.
1137fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1138    if !rows.iter().any(|row| row.status == "stale") {
1139        return Ok(0);
1140    }
1141    let mut failures = 0usize;
1142    let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1143    // Fail closed: only an inventory that was actually re-read proves a
1144    // record gone, so an unreadable one marks every stale row failed
1145    // rather than claiming a sweep nothing observed.
1146    let survivors: Option<Vec<Utf8PathBuf>> =
1147        git(target, &["worktree", "list", "--porcelain", "-z"])
1148            .ok()
1149            .filter(|fresh| fresh.status.success())
1150            .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1151            .map(|inventory| {
1152                inventory
1153                    .into_iter()
1154                    .map(|worktree| worktree.path)
1155                    .collect()
1156            });
1157    for row in rows.iter_mut().filter(|row| row.status == "stale") {
1158        let survived = survivors
1159            .as_ref()
1160            .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1161        if survived {
1162            row.status = "remove-failed";
1163            row.detail = Some(if survivors.is_none() {
1164                "the record's fate could not be observed; re-run rk worktree prune --apply"
1165                    .to_owned()
1166            } else if swept.status.success() {
1167                "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1168            } else {
1169                format!(
1170                    "{}; re-run rk worktree prune --apply",
1171                    last_line(&swept.stderr)
1172                )
1173            });
1174            failures += 1;
1175        } else {
1176            row.status = "pruned";
1177        }
1178    }
1179    if !swept.status.success() && failures == 0 {
1180        failures = 1;
1181    }
1182    Ok(failures)
1183}
1184
1185/// What plausibly follows each mode; an apply is its own conclusion.
1186fn next_lines(mode: &str) -> Vec<String> {
1187    let verify = "rk worktree prune --verify confirms each candidate against the forge";
1188    let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1189    match mode {
1190        "preview" => vec![verify.to_owned(), apply.to_owned()],
1191        "verify" => vec![apply.to_owned()],
1192        _ => Vec::new(),
1193    }
1194}
1195
1196/// The human report: silent under `--quiet` when nothing is reportable —
1197/// the clean-clone guarantee the reminder hook rests on — one judged line
1198/// per reportable worktree otherwise, closed by who owns the removal only
1199/// while some row still names a move.
1200fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1201    if quiet && rows.is_empty() {
1202        return;
1203    }
1204    if rows.is_empty() {
1205        out.result_line("no worktree needs cleanup");
1206    } else {
1207        out.result_line(header(rows.len()));
1208        let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1209        for row in rows {
1210            let tip = row
1211                .tip
1212                .as_deref()
1213                .map_or("        ", |tip| tip.get(..8).unwrap_or(tip));
1214            out.result_line(format!("  {:width$}  {tip}  {}", row.path, row.describe()));
1215        }
1216    }
1217    out.next(next);
1218    if rows
1219        .iter()
1220        .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1221    {
1222        out.result_line(OPERATOR_LINE);
1223    }
1224}
1225
1226/// The count-bearing first line.
1227fn header(count: usize) -> String {
1228    if count == 1 {
1229        "1 worktree reports cleanup (a candidate, not proof):".to_owned()
1230    } else {
1231        format!("{count} worktrees report cleanup (a candidate, not proof):")
1232    }
1233}
1234
1235/// Run one git command against the target, spawn failure typed. The
1236/// hook variables are scrubbed: a run from inside a git hook must act
1237/// on the named target, never on the hook's own repository.
1238///
1239/// `rk issue start` spawns git too, and shares this so the scrubbing is
1240/// one owner rather than a rule each caller has to remember.
1241///
1242/// # Errors
1243///
1244/// git failing to spawn.
1245pub(crate) fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1246    let mut command = std::process::Command::new(crate::probes::git_bin());
1247    for var in maintenance::GIT_HOOK_VARS {
1248        command.env_remove(var);
1249    }
1250    command
1251        .arg("-C")
1252        .arg(target.as_std_path())
1253        .args(args)
1254        .output()
1255        .map_err(|source| {
1256            RkError::subprocess(
1257                Diagnostic::new(
1258                    Reason::SubprocessSpawn,
1259                    format!("git did not run: {source}"),
1260                )
1261                .expected("git installed and on PATH"),
1262            )
1263        })
1264}
1265
1266/// The last non-empty stderr line, for a one-line detail.
1267fn last_line(bytes: &[u8]) -> String {
1268    maintenance::last_line(bytes)
1269}
1270
1271#[cfg(test)]
1272mod tests {
1273    #![allow(clippy::expect_used)]
1274
1275    use super::{ListReport, ListRow, PruneReport, PruneRow};
1276
1277    /// The complete `rk.worktree-list/1` shape, held by snapshot in the
1278    /// populated and empty-next forms.
1279    #[test]
1280    fn the_worktree_list_schema_snapshot_holds() {
1281        let populated = ListReport {
1282            schema: "rk.worktree-list/1",
1283            worktrees: vec![
1284                ListRow {
1285                    path: "/srv/widget".into(),
1286                    branch: Some("master".into()),
1287                    head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1288                    kind: "main",
1289                    state: "clean",
1290                    canonical: true,
1291                },
1292                ListRow {
1293                    path: "/srv/elsewhere".into(),
1294                    branch: None,
1295                    head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1296                    kind: "linked",
1297                    state: "detached",
1298                    canonical: true,
1299                },
1300            ],
1301            next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1302        };
1303        assert_eq!(
1304            serde_json::to_string(&populated).expect("a report serializes"),
1305            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"]}"#,
1306            "a detached row must omit branch rather than serializing null"
1307        );
1308    }
1309
1310    /// The complete `rk.worktree-add/1` shape, held by snapshot in the
1311    /// apply form with every optional field present and the preview form
1312    /// with each absent — so a field rename, removal, or a null leaking
1313    /// from an optional fails here, not at some agent's parser.
1314    #[test]
1315    fn the_worktree_add_schema_snapshot_holds() {
1316        let apply = super::AddReport {
1317            schema: "rk.worktree-add/1",
1318            mode: "apply",
1319            branch: "feat/x".into(),
1320            path: "/srv/widget@feat-x".into(),
1321            created: "branch",
1322            source: "remote",
1323            base: Some("origin/feat/x".into()),
1324            upstream: Some("origin/feat/x".into()),
1325            detail: Some("the fetch failed; the run proceeded on local refs".into()),
1326            next: vec!["cd /srv/widget@feat-x".into()],
1327        };
1328        assert_eq!(
1329            serde_json::to_string(&apply).expect("a report serializes"),
1330            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"]}"#
1331        );
1332        let preview = super::AddReport {
1333            mode: "preview",
1334            created: "nothing",
1335            source: "adopted",
1336            base: None,
1337            upstream: None,
1338            detail: None,
1339            ..apply
1340        };
1341        assert_eq!(
1342            serde_json::to_string(&preview).expect("a report serializes"),
1343            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"]}"#,
1344            "an absent option must be omitted rather than serializing null"
1345        );
1346    }
1347
1348    /// The complete `rk.worktree-prune/1` shape, held by snapshot in the
1349    /// populated and clean forms.
1350    #[test]
1351    fn the_worktree_prune_schema_snapshot_holds() {
1352        let populated = PruneReport {
1353            schema: "rk.worktree-prune/1",
1354            mode: "verify",
1355            worktrees: vec![
1356                PruneRow {
1357                    path: "/srv/widget@feat-x".into(),
1358                    branch: Some("feat/x".into()),
1359                    tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1360                    status: "confirmed",
1361                    request: Some("#8".into()),
1362                    detail: None,
1363                },
1364                PruneRow {
1365                    path: "/srv/widget@fix-y".into(),
1366                    branch: None,
1367                    tip: None,
1368                    status: "stale",
1369                    request: None,
1370                    detail: None,
1371                },
1372            ],
1373            next: vec![
1374                "rk worktree prune --apply verifies, then removes each worktree before its branch"
1375                    .into(),
1376            ],
1377        };
1378        assert_eq!(
1379            serde_json::to_string(&populated).expect("a report serializes"),
1380            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"]}"##
1381        );
1382        let clean = PruneReport {
1383            schema: "rk.worktree-prune/1",
1384            mode: "preview",
1385            worktrees: vec![],
1386            next: vec![
1387                "rk worktree prune --verify confirms each candidate against the forge".into(),
1388            ],
1389        };
1390        assert_eq!(
1391            serde_json::to_string(&clean).expect("a report serializes"),
1392            r#"{"schema":"rk.worktree-prune/1","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1393            "a clean clone reports one empty list a caller can branch on"
1394        );
1395    }
1396}