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(
894    clippy::too_many_lines,
895    reason = "the prune order is the safety property, so report, confirm, and remove worktree before branch stay in one body where the order cannot drift"
896)]
897fn prune(
898    target: &Utf8Path,
899    repo_flag: Option<&str>,
900    forge_flag: Option<&str>,
901    verify: bool,
902    apply: bool,
903    quiet: bool,
904    out: Output,
905) -> Result<(), RkError> {
906    let trunk = crate::config::trunk_of(target.as_std_path())?;
907    let worktrees = inventory(target)?;
908    let layout = layout_of(&worktrees)?;
909    let branches = branch_inventory(target)?;
910    // The join fails closed as a whole: no branch lines where the
911    // inventory names checked-out branches means the observation itself
912    // cannot be trusted, and no judgment is made over it.
913    if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
914        return Err(RkError::refusal(
915            Diagnostic::new(
916                Reason::PrerequisiteUnmet,
917                "the branch inventory did not parse, and no worktree is judged without its branch observation",
918            )
919            .expected("a branch listing covering the checked-out branches")
920            .target_state("unchanged"),
921        ));
922    }
923    let seat_paths = seats(target);
924    let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
925
926    // The reportable set: stale-eligible records, and linked worktrees
927    // whose branch observation is gone — or missing, which is kept by
928    // name, never guessed. A healthy seat is never a row.
929    let mut judged: Vec<Judged> = Vec::new();
930    for worktree in worktrees.iter().skip(1) {
931        let observation = worktree
932            .branch
933            .as_deref()
934            .and_then(|name| branches.iter().find(|branch| branch.name == name));
935        let reportable = worktree.prunable.is_some()
936            || worktree
937                .branch
938                .as_deref()
939                .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
940        if !reportable {
941            continue;
942        }
943        let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
944        let class = classify(worktree, observation, &layout, &seat_refs, &trunk, dirty);
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    use super::{ListReport, ListRow, PruneReport, PruneRow};
1277
1278    /// The complete `rk.worktree-list/1` shape, held by snapshot in the
1279    /// populated and empty-next forms.
1280    #[test]
1281    fn the_worktree_list_schema_snapshot_holds() {
1282        let populated = ListReport {
1283            schema: "rk.worktree-list/1",
1284            worktrees: vec![
1285                ListRow {
1286                    path: "/srv/widget".into(),
1287                    branch: Some("master".into()),
1288                    head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1289                    kind: "main",
1290                    state: "clean",
1291                    canonical: true,
1292                },
1293                ListRow {
1294                    path: "/srv/elsewhere".into(),
1295                    branch: None,
1296                    head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1297                    kind: "linked",
1298                    state: "detached",
1299                    canonical: true,
1300                },
1301            ],
1302            next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1303        };
1304        assert_eq!(
1305            serde_json::to_string(&populated).expect("a report serializes"),
1306            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"]}"#,
1307            "a detached row must omit branch rather than serializing null"
1308        );
1309    }
1310
1311    /// The complete `rk.worktree-add/1` shape, held by snapshot in the
1312    /// apply form with every optional field present and the preview form
1313    /// with each absent — so a field rename, removal, or a null leaking
1314    /// from an optional fails here, not at some agent's parser.
1315    #[test]
1316    fn the_worktree_add_schema_snapshot_holds() {
1317        let apply = super::AddReport {
1318            schema: "rk.worktree-add/1",
1319            mode: "apply",
1320            branch: "feat/x".into(),
1321            path: "/srv/widget@feat-x".into(),
1322            created: "branch",
1323            source: "remote",
1324            base: Some("origin/feat/x".into()),
1325            upstream: Some("origin/feat/x".into()),
1326            detail: Some("the fetch failed; the run proceeded on local refs".into()),
1327            next: vec!["cd /srv/widget@feat-x".into()],
1328        };
1329        assert_eq!(
1330            serde_json::to_string(&apply).expect("a report serializes"),
1331            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"]}"#
1332        );
1333        let preview = super::AddReport {
1334            mode: "preview",
1335            created: "nothing",
1336            source: "adopted",
1337            base: None,
1338            upstream: None,
1339            detail: None,
1340            ..apply
1341        };
1342        assert_eq!(
1343            serde_json::to_string(&preview).expect("a report serializes"),
1344            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"]}"#,
1345            "an absent option must be omitted rather than serializing null"
1346        );
1347    }
1348
1349    /// The complete `rk.worktree-prune/1` shape, held by snapshot in the
1350    /// populated and clean forms.
1351    #[test]
1352    fn the_worktree_prune_schema_snapshot_holds() {
1353        let populated = PruneReport {
1354            schema: "rk.worktree-prune/1",
1355            mode: "verify",
1356            worktrees: vec![
1357                PruneRow {
1358                    path: "/srv/widget@feat-x".into(),
1359                    branch: Some("feat/x".into()),
1360                    tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1361                    status: "confirmed",
1362                    request: Some("#8".into()),
1363                    detail: None,
1364                },
1365                PruneRow {
1366                    path: "/srv/widget@fix-y".into(),
1367                    branch: None,
1368                    tip: None,
1369                    status: "stale",
1370                    request: None,
1371                    detail: None,
1372                },
1373            ],
1374            next: vec![
1375                "rk worktree prune --apply verifies, then removes each worktree before its branch"
1376                    .into(),
1377            ],
1378        };
1379        assert_eq!(
1380            serde_json::to_string(&populated).expect("a report serializes"),
1381            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"]}"##
1382        );
1383        let clean = PruneReport {
1384            schema: "rk.worktree-prune/1",
1385            mode: "preview",
1386            worktrees: vec![],
1387            next: vec![
1388                "rk worktree prune --verify confirms each candidate against the forge".into(),
1389            ],
1390        };
1391        assert_eq!(
1392            serde_json::to_string(&clean).expect("a report serializes"),
1393            r#"{"schema":"rk.worktree-prune/1","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1394            "a clean clone reports one empty list a caller can branch on"
1395        );
1396    }
1397}