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    /// What proved the tip, where anything did: a merged request as the
825    /// forge names it, the local integration that wrote the trunk, or the
826    /// trunk reaching the tip.
827    #[serde(skip_serializing_if = "Option::is_none")]
828    proof: Option<String>,
829    /// Which of the three proofs it was: `request`, `local-integration`,
830    /// or `reachable-tip`.
831    #[serde(skip_serializing_if = "Option::is_none")]
832    proof_kind: Option<&'static str>,
833    /// Why the worktree stays, or what is still owed.
834    #[serde(skip_serializing_if = "Option::is_none")]
835    detail: Option<String>,
836}
837
838impl PruneRow {
839    /// The human tail of a row line.
840    fn describe(&self) -> String {
841        match self.status {
842            "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
843            "stale" => {
844                "stale: the registered directory is missing; apply clears the record".to_owned()
845            }
846            "confirmed" if self.proof_kind == Some("reachable-tip") => {
847                format!("confirmed: {}", self.proof.as_deref().unwrap_or(""))
848            }
849            "confirmed" => format!(
850                "confirmed: {} matches this tip",
851                self.proof.as_deref().unwrap_or("")
852            ),
853            "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
854            "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
855            "pruned" => {
856                let mut line = self
857                    .proof
858                    .as_deref()
859                    .map_or_else(|| "pruned".to_owned(), |proof| format!("pruned ({proof})"));
860                if let Some(detail) = &self.detail {
861                    line.push_str("; ");
862                    line.push_str(detail);
863                }
864                line
865            }
866            "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
867            "branch-delete-failed" => format!(
868                "branch delete failed: {}",
869                self.detail.as_deref().unwrap_or("")
870            ),
871            _ => "candidate".to_owned(),
872        }
873    }
874}
875
876/// The machine form of a prune report.
877#[derive(Debug, Serialize)]
878struct PruneReport {
879    /// The shape version of this document.
880    schema: &'static str,
881    /// Which mode produced it: preview, verify, or apply.
882    mode: &'static str,
883    /// Every reportable worktree, judged; empty when the clone is clean.
884    worktrees: Vec<PruneRow>,
885    /// What plausibly follows.
886    next: Vec<String>,
887}
888
889/// One reportable worktree, carried from classification to the report.
890struct Judged {
891    worktree: Worktree,
892    /// The branch observation, where the join found one.
893    tip: Option<String>,
894    class: WtClass,
895}
896
897/// The ordered cleanup: report stale records and gone-upstream worktrees
898/// — never the main checkout or a healthy linked one — confirm against
899/// the forge under `--verify`, and remove worktree before branch under
900/// `--apply`, re-observing at the moment of action.
901#[allow(
902    clippy::too_many_lines,
903    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"
904)]
905fn prune(
906    target: &Utf8Path,
907    repo_flag: Option<&str>,
908    forge_flag: Option<&str>,
909    verify: bool,
910    apply: bool,
911    quiet: bool,
912    out: Output,
913) -> Result<(), RkError> {
914    let trunk = crate::config::trunk_of(target.as_std_path())?;
915    let worktrees = inventory(target)?;
916    let layout = layout_of(&worktrees)?;
917    let branches = branch_inventory(target)?;
918    // The join fails closed as a whole: no branch lines where the
919    // inventory names checked-out branches means the observation itself
920    // cannot be trusted, and no judgment is made over it.
921    if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
922        return Err(RkError::refusal(
923            Diagnostic::new(
924                Reason::PrerequisiteUnmet,
925                "the branch inventory did not parse, and no worktree is judged without its branch observation",
926            )
927            .expected("a branch listing covering the checked-out branches")
928            .target_state("unchanged"),
929        ));
930    }
931    let ledger = crate::maintenance::integration_ledger(target, &trunk);
932    let seat_paths = seats(target);
933    let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
934
935    // The reportable set: stale-eligible records, and linked worktrees
936    // whose branch observation is gone — or missing, which is kept by
937    // name, never guessed. A healthy seat is never a row.
938    let mut judged: Vec<Judged> = Vec::new();
939    for worktree in worktrees.iter().skip(1) {
940        let observation = worktree
941            .branch
942            .as_deref()
943            .and_then(|name| branches.iter().find(|branch| branch.name == name));
944        // A locally integrated branch has no upstream to go gone, so
945        // its own evidence is what makes its seat reportable at all, and
946        // so does a tip the trunk reaches. Where both hold, the reachable
947        // tip is named, as on the branch side.
948        let local = observation.and_then(|branch| {
949            maintenance::reachable_tip(target, &trunk, &branch.name, &branch.tip).or_else(|| {
950                ledger.proof(&branch.name, &branch.tip).map(|entry| {
951                    crate::branches::Proof::LocalIntegration(entry.trunk_commit.clone())
952                })
953            })
954        });
955        let reportable = worktree.prunable.is_some()
956            || local.is_some()
957            || worktree
958                .branch
959                .as_deref()
960                .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
961        if !reportable {
962            continue;
963        }
964        let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
965        let class = classify(
966            worktree,
967            observation,
968            &layout,
969            &seat_refs,
970            &trunk,
971            dirty,
972            local,
973        );
974        judged.push(Judged {
975            worktree: worktree.clone(),
976            tip: observation.map(|branch| branch.tip.clone()),
977            class,
978        });
979    }
980
981    // The forge is asked only where a candidate exists to confirm.
982    if (verify || apply)
983        && judged
984            .iter()
985            .any(|row| matches!(row.class, WtClass::Candidate))
986    {
987        let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
988        let forge = Forge::parse(&resolved.forge)
989            .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
990        let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
991        let cli = resolve_cli(forge)?;
992        for row in &mut judged {
993            if matches!(row.class, WtClass::Candidate) {
994                let Some(tip) = row.tip.as_deref() else {
995                    continue;
996                };
997                row.class = WtClass::Judged(merged_request_for(
998                    &cli,
999                    target.as_std_path(),
1000                    forge,
1001                    &repo,
1002                    tip,
1003                ));
1004            }
1005        }
1006    }
1007
1008    let mut rows: Vec<PruneRow> = judged
1009        .iter()
1010        .map(|row| {
1011            let (status, proof, detail) = match &row.class {
1012                WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
1013                WtClass::Candidate => ("candidate", None, None),
1014                WtClass::Stale => ("stale", None, None),
1015                WtClass::Judged(Class::Confirmed { proof }) => {
1016                    ("confirmed", Some(proof.clone()), None)
1017                }
1018                WtClass::Judged(Class::Unconfirmed { detail }) => {
1019                    ("unconfirmed", None, Some(detail.clone()))
1020                }
1021                WtClass::Judged(Class::Unknown { detail }) => {
1022                    ("unknown", None, Some(detail.clone()))
1023                }
1024                WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
1025            };
1026            PruneRow {
1027                path: row.worktree.path.to_string(),
1028                branch: row.worktree.branch.clone(),
1029                tip: row.tip.clone(),
1030                status,
1031                proof_kind: proof.as_ref().map(crate::branches::Proof::kind),
1032                proof: proof.as_ref().map(crate::branches::Proof::detail),
1033                detail,
1034            }
1035        })
1036        .collect();
1037
1038    let mut failures = 0usize;
1039    if apply {
1040        for row in &mut rows {
1041            if row.status != "confirmed" {
1042                continue;
1043            }
1044            if let Err(count) = retire(target, &trunk, row) {
1045                failures += count;
1046            }
1047        }
1048        failures += sweep_stale(target, &mut rows)?;
1049    }
1050
1051    let mode = if apply {
1052        "apply"
1053    } else if verify {
1054        "verify"
1055    } else {
1056        "preview"
1057    };
1058    let next = next_lines(mode);
1059    render(out, &rows, &next, quiet);
1060    out.emit(&PruneReport {
1061        schema: "rk.worktree-prune/2",
1062        mode,
1063        worktrees: rows,
1064        next,
1065    })?;
1066    if failures > 0 {
1067        return Err(RkError::subprocess(
1068            Diagnostic::new(
1069                Reason::SubprocessFailed,
1070                format!("git refused {failures} cleanup actions"),
1071            )
1072            .expected("every confirmed worktree removed; the report names each outcome"),
1073        ));
1074    }
1075    Ok(())
1076}
1077
1078/// Retire one confirmed worktree, ordered, each outcome independent:
1079/// re-observe at the last moment — verification authorizes only the
1080/// state it saw, and a reachable-tip proof, which no record holds, is
1081/// asked again — then remove the worktree, then delete its branch
1082/// through the shared compare-and-swap helper. A failed remove leaves
1083/// the branch and its configuration untouched.
1084fn retire(target: &Utf8Path, trunk: &str, row: &mut PruneRow) -> Result<(), usize> {
1085    let Some(branch) = row.branch.clone() else {
1086        return Ok(());
1087    };
1088    let Some(tip) = row.tip.clone() else {
1089        return Ok(());
1090    };
1091    let keep = |row: &mut PruneRow, moved: &str| {
1092        row.status = "kept";
1093        row.detail = Some(format!(
1094            "{moved} after verification; rk worktree prune --verify re-confirms"
1095        ));
1096    };
1097    let reread = git(
1098        target,
1099        &[
1100            "for-each-ref",
1101            &format!("refs/heads/{branch}"),
1102            "--format",
1103            "%(objectname)",
1104        ],
1105    )
1106    .map_err(|_| 1usize)?;
1107    let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
1108    if !reread.status.success() || fresh_tip != tip {
1109        keep(row, "the tip moved");
1110        return Ok(());
1111    }
1112    if row.proof_kind == Some("reachable-tip") && !maintenance::trunk_reaches(target, trunk, &tip) {
1113        keep(row, "the trunk no longer reaches the tip");
1114        return Ok(());
1115    }
1116    // The fresh inventory fails closed: an unobservable state clears no
1117    // removal, and the record must still be the same resource — the very
1118    // branch the merge proof named, unlocked, its directory standing.
1119    let path = Utf8PathBuf::from(&row.path);
1120    let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
1121    if !fresh.status.success() {
1122        keep(row, "the worktree inventory could not be re-read");
1123        return Ok(());
1124    }
1125    let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
1126        keep(row, "the worktree inventory could not be re-read");
1127        return Ok(());
1128    };
1129    let seat = inventory.iter().find(|worktree| worktree.path == path);
1130    if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
1131        keep(row, &reason);
1132        return Ok(());
1133    }
1134    if is_dirty(&path) {
1135        keep(row, "uncommitted changes arrived");
1136        return Ok(());
1137    }
1138    let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
1139    if !removed.status.success() {
1140        row.status = "remove-failed";
1141        row.detail = Some(format!(
1142            "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
1143            last_line(&removed.stderr)
1144        ));
1145        return Err(1);
1146    }
1147    match maintenance::delete_branch(target, &branch, &tip) {
1148        maintenance::Deletion::Deleted => {
1149            maintenance::forget_integration(target, &branch);
1150            row.status = "pruned";
1151            Ok(())
1152        }
1153        maintenance::Deletion::ConfigSurvived { detail } => {
1154            maintenance::forget_integration(target, &branch);
1155            row.status = "pruned";
1156            row.detail = Some(detail);
1157            Ok(())
1158        }
1159        maintenance::Deletion::Refused { detail } => {
1160            // Reported truthfully: the worktree is already gone, the
1161            // branch and its work survive, and the recovery is named.
1162            row.status = "branch-delete-failed";
1163            row.detail = Some(format!(
1164                "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1165            ));
1166            Err(1)
1167        }
1168    }
1169}
1170
1171/// Clear the stale records, once, after the loop: plain `git worktree
1172/// prune` is expiration-gated, so `--expire now` is the form that
1173/// guarantees the missing-directory records go — and only those; a
1174/// locked record is never touched and was never a stale row. Because it
1175/// is one command over many rows, its outcome is read per row from a
1176/// fresh inventory rather than assumed.
1177fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1178    if !rows.iter().any(|row| row.status == "stale") {
1179        return Ok(0);
1180    }
1181    let mut failures = 0usize;
1182    let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1183    // Fail closed: only an inventory that was actually re-read proves a
1184    // record gone, so an unreadable one marks every stale row failed
1185    // rather than claiming a sweep nothing observed.
1186    let survivors: Option<Vec<Utf8PathBuf>> =
1187        git(target, &["worktree", "list", "--porcelain", "-z"])
1188            .ok()
1189            .filter(|fresh| fresh.status.success())
1190            .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1191            .map(|inventory| {
1192                inventory
1193                    .into_iter()
1194                    .map(|worktree| worktree.path)
1195                    .collect()
1196            });
1197    for row in rows.iter_mut().filter(|row| row.status == "stale") {
1198        let survived = survivors
1199            .as_ref()
1200            .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1201        if survived {
1202            row.status = "remove-failed";
1203            row.detail = Some(if survivors.is_none() {
1204                "the record's fate could not be observed; re-run rk worktree prune --apply"
1205                    .to_owned()
1206            } else if swept.status.success() {
1207                "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1208            } else {
1209                format!(
1210                    "{}; re-run rk worktree prune --apply",
1211                    last_line(&swept.stderr)
1212                )
1213            });
1214            failures += 1;
1215        } else {
1216            row.status = "pruned";
1217        }
1218    }
1219    if !swept.status.success() && failures == 0 {
1220        failures = 1;
1221    }
1222    Ok(failures)
1223}
1224
1225/// What plausibly follows each mode; an apply is its own conclusion.
1226fn next_lines(mode: &str) -> Vec<String> {
1227    let verify = "rk worktree prune --verify confirms each candidate against the forge";
1228    let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1229    match mode {
1230        "preview" => vec![verify.to_owned(), apply.to_owned()],
1231        "verify" => vec![apply.to_owned()],
1232        _ => Vec::new(),
1233    }
1234}
1235
1236/// The human report: silent under `--quiet` when nothing is reportable —
1237/// the clean-clone guarantee the reminder hook rests on — one judged line
1238/// per reportable worktree otherwise, closed by who owns the removal only
1239/// while some row still names a move.
1240fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1241    if quiet && rows.is_empty() {
1242        return;
1243    }
1244    if rows.is_empty() {
1245        out.result_line("no worktree needs cleanup");
1246    } else {
1247        out.result_line(header(
1248            rows.len(),
1249            rows.iter()
1250                .filter(|row| row.proof_kind == Some("local-integration"))
1251                .count(),
1252            rows.iter()
1253                .filter(|row| row.proof_kind == Some("reachable-tip"))
1254                .count(),
1255        ));
1256        let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1257        for row in rows {
1258            let tip = row
1259                .tip
1260                .as_deref()
1261                .map_or("        ", |tip| tip.get(..8).unwrap_or(tip));
1262            out.result_line(format!("  {:width$}  {tip}  {}", row.path, row.describe()));
1263        }
1264    }
1265    out.next(next);
1266    if rows
1267        .iter()
1268        .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1269    {
1270        out.result_line(OPERATOR_LINE);
1271    }
1272}
1273
1274/// The count-bearing first line.
1275///
1276/// As on the branch side, the line names which signal reported each
1277/// seat: a gone upstream is a candidate, and a recorded local
1278/// integration and a tip the trunk reaches are proof.
1279fn header(count: usize, local: usize, reachable: usize) -> String {
1280    let (noun, reports, carry) = if count == 1 {
1281        ("1 worktree".to_owned(), "reports", "carries")
1282    } else {
1283        (format!("{count} worktrees"), "report", "carry")
1284    };
1285    match (local, reachable, count - local - reachable) {
1286        (0, 0, _) => format!("{noun} {reports} cleanup (a candidate, not proof):"),
1287        (_, 0, 0) => format!("{noun} {carry} a local integration this clone recorded:"),
1288        (0, _, 0) => format!("{noun} {carry} a branch tip the trunk reaches:"),
1289        (_, _, 0) => format!(
1290            "{noun}: {local} locally integrated, {reachable} with a branch tip the trunk reaches:"
1291        ),
1292        (_, 0, other) => {
1293            format!("{noun}: {other} reporting cleanup, {local} locally integrated:")
1294        }
1295        (_, _, other) => format!(
1296            "{noun}: {other} reporting cleanup, {local} locally integrated, {reachable} with a branch tip the trunk reaches:"
1297        ),
1298    }
1299}
1300
1301/// Run one git command against the target, spawn failure typed. The
1302/// hook variables are scrubbed: a run from inside a git hook must act
1303/// on the named target, never on the hook's own repository.
1304///
1305/// `rk issue start` spawns git too, and shares this so the scrubbing is
1306/// one owner rather than a rule each caller has to remember.
1307///
1308/// # Errors
1309///
1310/// git failing to spawn.
1311pub(crate) fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1312    let mut command = std::process::Command::new(crate::probes::git_bin());
1313    for var in maintenance::GIT_HOOK_VARS {
1314        command.env_remove(var);
1315    }
1316    command
1317        .arg("-C")
1318        .arg(target.as_std_path())
1319        .args(args)
1320        .output()
1321        .map_err(|source| {
1322            RkError::subprocess(
1323                Diagnostic::new(
1324                    Reason::SubprocessSpawn,
1325                    format!("git did not run: {source}"),
1326                )
1327                .expected("git installed and on PATH"),
1328            )
1329        })
1330}
1331
1332/// The last non-empty stderr line, for a one-line detail.
1333fn last_line(bytes: &[u8]) -> String {
1334    maintenance::last_line(bytes)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339    use super::{ListReport, ListRow, PruneReport, PruneRow};
1340
1341    /// The complete `rk.worktree-list/1` shape, held by snapshot in the
1342    /// populated and empty-next forms.
1343    #[test]
1344    fn the_worktree_list_schema_snapshot_holds() {
1345        let populated = ListReport {
1346            schema: "rk.worktree-list/1",
1347            worktrees: vec![
1348                ListRow {
1349                    path: "/srv/widget".into(),
1350                    branch: Some("master".into()),
1351                    head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1352                    kind: "main",
1353                    state: "clean",
1354                    canonical: true,
1355                },
1356                ListRow {
1357                    path: "/srv/elsewhere".into(),
1358                    branch: None,
1359                    head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1360                    kind: "linked",
1361                    state: "detached",
1362                    canonical: true,
1363                },
1364            ],
1365            next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1366        };
1367        assert_eq!(
1368            serde_json::to_string(&populated).expect("a report serializes"),
1369            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"]}"#,
1370            "a detached row must omit branch rather than serializing null"
1371        );
1372    }
1373
1374    /// The complete `rk.worktree-add/1` shape, held by snapshot in the
1375    /// apply form with every optional field present and the preview form
1376    /// with each absent — so a field rename, removal, or a null leaking
1377    /// from an optional fails here, not at some agent's parser.
1378    #[test]
1379    fn the_worktree_add_schema_snapshot_holds() {
1380        let apply = super::AddReport {
1381            schema: "rk.worktree-add/1",
1382            mode: "apply",
1383            branch: "feat/x".into(),
1384            path: "/srv/widget@feat-x".into(),
1385            created: "branch",
1386            source: "remote",
1387            base: Some("origin/feat/x".into()),
1388            upstream: Some("origin/feat/x".into()),
1389            detail: Some("the fetch failed; the run proceeded on local refs".into()),
1390            next: vec!["cd /srv/widget@feat-x".into()],
1391        };
1392        assert_eq!(
1393            serde_json::to_string(&apply).expect("a report serializes"),
1394            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"]}"#
1395        );
1396        let preview = super::AddReport {
1397            mode: "preview",
1398            created: "nothing",
1399            source: "adopted",
1400            base: None,
1401            upstream: None,
1402            detail: None,
1403            ..apply
1404        };
1405        assert_eq!(
1406            serde_json::to_string(&preview).expect("a report serializes"),
1407            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"]}"#,
1408            "an absent option must be omitted rather than serializing null"
1409        );
1410    }
1411
1412    /// The complete `rk.worktree-prune/2` shape, held by snapshot in the
1413    /// populated and clean forms.
1414    #[test]
1415    fn the_worktree_prune_schema_snapshot_holds() {
1416        let populated = PruneReport {
1417            schema: "rk.worktree-prune/2",
1418            mode: "verify",
1419            worktrees: vec![
1420                PruneRow {
1421                    path: "/srv/widget@feat-x".into(),
1422                    branch: Some("feat/x".into()),
1423                    tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1424                    status: "confirmed",
1425                    proof: Some("#8".into()),
1426                    proof_kind: Some("request"),
1427                    detail: None,
1428                },
1429                PruneRow {
1430                    path: "/srv/widget@fix-y".into(),
1431                    branch: None,
1432                    tip: None,
1433                    status: "stale",
1434                    proof: None,
1435                    proof_kind: None,
1436                    detail: None,
1437                },
1438            ],
1439            next: vec![
1440                "rk worktree prune --apply verifies, then removes each worktree before its branch"
1441                    .into(),
1442            ],
1443        };
1444        assert_eq!(
1445            serde_json::to_string(&populated).expect("a report serializes"),
1446            r##"{"schema":"rk.worktree-prune/2","mode":"verify","worktrees":[{"path":"/srv/widget@feat-x","branch":"feat/x","tip":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","status":"confirmed","proof":"#8","proof_kind":"request"},{"path":"/srv/widget@fix-y","status":"stale"}],"next":["rk worktree prune --apply verifies, then removes each worktree before its branch"]}"##
1447        );
1448        let clean = PruneReport {
1449            schema: "rk.worktree-prune/2",
1450            mode: "preview",
1451            worktrees: vec![],
1452            next: vec![
1453                "rk worktree prune --verify confirms each candidate against the forge".into(),
1454            ],
1455        };
1456        assert_eq!(
1457            serde_json::to_string(&clean).expect("a report serializes"),
1458            r#"{"schema":"rk.worktree-prune/2","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1459            "a clean clone reports one empty list a caller can branch on"
1460        );
1461    }
1462}