Skip to main content

release_kit/commands/
worktree.rs

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