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