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