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        let move_hint = if seat.path == layout.main {
426            format!("; git switch {TRUNK_BRANCH} there, then re-run")
427        } else {
428            String::new()
429        };
430        return Err(RkError::refusal(
431            Diagnostic::new(
432                Reason::StateDrift,
433                format!(
434                    "branch {branch} is checked out at {}, and one branch has one seat{move_hint}",
435                    seat.path
436                ),
437            )
438            .expected("the branch free, or already at its derived path")
439            .target_state("unchanged"),
440        ));
441    }
442    if path.exists() {
443        let occupant = worktrees
444            .iter()
445            .find(|worktree| worktree.path == path)
446            .and_then(|worktree| worktree.branch.clone())
447            .map_or_else(
448                || "a directory this repository does not register".to_owned(),
449                |other| format!("the worktree of branch {other}"),
450            );
451        return Err(RkError::refusal(
452            Diagnostic::new(
453                Reason::StateDrift,
454                format!(
455                    "{path} already exists as {occupant}; flattening is not injective and nothing is suffixed silently"
456                ),
457            )
458            .expected("the derived path free, or registered to this branch")
459            .target_state("unchanged"),
460        ));
461    }
462
463    // Under apply, refresh through the remote's configured refmap first —
464    // best-effort, because a missing remote must not block a local
465    // branch — then resolve; a preview decides from the local refs as
466    // they stand and says so.
467    let mut detail = None;
468    if apply {
469        let fetched = git(target, &["fetch", "origin"])?;
470        if !fetched.status.success() {
471            detail = Some(format!(
472                "the fetch failed ({}); the run proceeded on local refs",
473                last_line(&fetched.stderr)
474            ));
475        }
476    }
477    let source = resolve_source(target, branch, base, &path)?;
478
479    if !apply {
480        out.result_line(format!(
481            "branch: {branch}  ({})",
482            match source.kind {
483                "adopted" => "existing, adopted".to_owned(),
484                "remote" => format!(
485                    "remote, from {}",
486                    source.upstream.as_deref().unwrap_or("origin")
487                ),
488                _ => format!("new, from {}", source.base.as_deref().unwrap_or("?")),
489            }
490        ));
491        out.result_line(format!(
492            "path:   ../{}",
493            path.file_name().unwrap_or_default()
494        ));
495        if let Some(base) = &source.base {
496            out.result_line(format!("base:   {base}"));
497        }
498        out.result_line(format!("would run: git {}", source.command.join(" ")));
499        let base_flag = base.map_or_else(String::new, |base| format!(" --base {base}"));
500        let next = vec![format!(
501            "rk worktree add {branch}{base_flag} --target {target} --apply creates it; the apply refreshes the remote refs and re-resolves"
502        )];
503        out.next(&next);
504        return out.emit(&AddReport {
505            schema: "rk.worktree-add/1",
506            mode: "preview",
507            branch: branch.to_owned(),
508            path: path.to_string(),
509            created: source.created,
510            source: source.kind,
511            base: source.base,
512            upstream: source.upstream,
513            detail: Some(
514                "a preview decides from the local refs as they stand; apply refreshes and re-resolves"
515                    .to_owned(),
516            ),
517            next,
518        });
519    }
520
521    let argv: Vec<&str> = source.command.iter().map(String::as_str).collect();
522    let created = git(target, &argv)?;
523    if !created.status.success() {
524        return Err(RkError::subprocess(
525            Diagnostic::new(
526                Reason::SubprocessFailed,
527                format!("git worktree add refused: {}", last_line(&created.stderr)),
528            )
529            .expected("the worktree created at the derived path")
530            .target_state("unchanged"),
531        ));
532    }
533    out.result_line(&path);
534    let next = vec![
535        format!("cd {path}"),
536        "rk worktree list reports every seat".to_owned(),
537    ];
538    out.next(&next);
539    out.emit(&AddReport {
540        schema: "rk.worktree-add/1",
541        mode: "apply",
542        branch: branch.to_owned(),
543        path: path.to_string(),
544        created: source.created,
545        source: source.kind,
546        base: source.base,
547        upstream: source.upstream,
548        detail,
549        next,
550    })
551}
552
553/// The idempotent outcome: the canonical worktree already stands.
554fn report_satisfied(
555    out: Output,
556    branch: &str,
557    path: &Utf8Path,
558    apply: bool,
559) -> Result<(), RkError> {
560    out.result_line(format!("{path} already seats {branch}; nothing to create"));
561    let next = vec![format!("cd {path}")];
562    out.next(&next);
563    out.emit(&AddReport {
564        schema: "rk.worktree-add/1",
565        mode: if apply { "apply" } else { "preview" },
566        branch: branch.to_owned(),
567        path: path.to_string(),
568        created: "nothing",
569        source: "adopted",
570        base: None,
571        upstream: None,
572        detail: None,
573        next,
574    })
575}
576
577/// The source precedence, in order: adopt a local branch, create a
578/// tracking branch from a lone matching remote tip, else create from
579/// `--base` or the refreshed trunk — so a forge-minted branch or the
580/// bot's release branch is seated from its real tip, never silently
581/// recreated from the trunk. Everything is resolved to an exact object
582/// name behind `--end-of-options` before any mutation, and the one name
583/// passed onward — the remote ref a tracking branch needs — sits in a
584/// documented value position, fully qualified.
585fn resolve_source(
586    target: &Utf8Path,
587    branch: &str,
588    base: Option<&str>,
589    path: &Utf8Path,
590) -> Result<Source, RkError> {
591    let resolve = |name: &str| -> Result<Option<String>, RkError> {
592        let resolved = git(
593            target,
594            &[
595                "rev-parse",
596                "--verify",
597                "--quiet",
598                "--end-of-options",
599                &format!("{name}^{{commit}}"),
600            ],
601        )?;
602        Ok(resolved
603            .status
604            .success()
605            .then(|| String::from_utf8_lossy(&resolved.stdout).trim().to_owned()))
606    };
607
608    // Arm 1: the branch exists locally — adopt it. The caller already
609    // handled a branch seated elsewhere; here it is free.
610    if resolve(&format!("refs/heads/{branch}"))?.is_some() {
611        return Ok(Source {
612            kind: "adopted",
613            created: "worktree",
614            base: None,
615            upstream: None,
616            command: vec![
617                "worktree".into(),
618                "add".into(),
619                path.to_string(),
620                branch.to_owned(),
621            ],
622        });
623    }
624
625    // Arm 2: exactly origin/<branch> exists — create the local tracking
626    // branch from that remote tip.
627    let remote_ref = format!("refs/remotes/origin/{branch}");
628    if resolve(&remote_ref)?.is_some() {
629        return Ok(Source {
630            kind: "remote",
631            created: "branch",
632            base: Some(format!("origin/{branch}")),
633            upstream: Some(format!("origin/{branch}")),
634            command: vec![
635                "worktree".into(),
636                "add".into(),
637                "--track".into(),
638                "-b".into(),
639                branch.to_owned(),
640                path.to_string(),
641                remote_ref,
642            ],
643        });
644    }
645
646    // Arm 3: --base where given, else the refreshed trunk; a release
647    // line requires the explicit base — a line is cut from a tag, never
648    // the tip.
649    if branch.starts_with(crate::branches::PROTECTED_PREFIX) && base.is_none() {
650        return Err(RkError::refusal(
651            Diagnostic::new(
652                Reason::PrerequisiteUnmet,
653                format!(
654                    "release line {branch} takes an explicit --base; a line is cut from a tag, never the tip"
655                ),
656            )
657            .expected("--base \"v<version>\" naming the tag the line patches")
658            .target_state("unchanged"),
659        ));
660    }
661    let (kind, shown) = base.map_or_else(
662        || ("trunk", format!("origin/{TRUNK_BRANCH}")),
663        |base| ("base", base.to_owned()),
664    );
665    let resolved = match resolve(&shown)? {
666        Some(oid) => Some(oid),
667        // A clone with no remote still creates from its own trunk.
668        None if kind == "trunk" => resolve(TRUNK_BRANCH)?,
669        None => None,
670    };
671    let oid = resolved.ok_or_else(|| {
672        RkError::refusal(
673            Diagnostic::new(
674                Reason::PrerequisiteUnmet,
675                format!("{shown} does not resolve to a commit"),
676            )
677            .expected("a commit-ish the new branch can start from")
678            .target_state("unchanged"),
679        )
680    })?;
681    Ok(Source {
682        kind,
683        created: "branch",
684        base: Some(shown),
685        upstream: None,
686        command: vec![
687            "worktree".into(),
688            "add".into(),
689            path.to_string(),
690            "-b".into(),
691            branch.to_owned(),
692            oid,
693        ],
694    })
695}
696
697// ---------------------------------------------------------------------------
698// prune
699
700/// One worktree in the prune report.
701#[derive(Debug, Serialize)]
702struct PruneRow {
703    /// The worktree's path.
704    path: String,
705    /// The branch it seats, where one is known.
706    #[serde(skip_serializing_if = "Option::is_none")]
707    branch: Option<String>,
708    /// The branch tip the judgment rests on, where one is known.
709    #[serde(skip_serializing_if = "Option::is_none")]
710    tip: Option<String>,
711    /// The judgment: kept, candidate, stale, confirmed, unconfirmed,
712    /// unknown, pruned, remove-failed, or branch-delete-failed.
713    status: &'static str,
714    /// The merged request that proved the tip, where one did.
715    #[serde(skip_serializing_if = "Option::is_none")]
716    request: Option<String>,
717    /// Why the worktree stays, or what is still owed.
718    #[serde(skip_serializing_if = "Option::is_none")]
719    detail: Option<String>,
720}
721
722impl PruneRow {
723    /// The human tail of a row line.
724    fn describe(&self) -> String {
725        match self.status {
726            "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
727            "stale" => {
728                "stale: the registered directory is missing; apply clears the record".to_owned()
729            }
730            "confirmed" => format!(
731                "confirmed: merged request {} matches this tip",
732                self.request.as_deref().unwrap_or("")
733            ),
734            "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
735            "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
736            "pruned" => {
737                let mut line = self.request.as_deref().map_or_else(
738                    || "pruned".to_owned(),
739                    |request| format!("pruned (merged request {request})"),
740                );
741                if let Some(detail) = &self.detail {
742                    line.push_str("; ");
743                    line.push_str(detail);
744                }
745                line
746            }
747            "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
748            "branch-delete-failed" => format!(
749                "branch delete failed: {}",
750                self.detail.as_deref().unwrap_or("")
751            ),
752            _ => "candidate".to_owned(),
753        }
754    }
755}
756
757/// The machine form of a prune report.
758#[derive(Debug, Serialize)]
759struct PruneReport {
760    /// The shape version of this document.
761    schema: &'static str,
762    /// Which mode produced it: preview, verify, or apply.
763    mode: &'static str,
764    /// Every reportable worktree, judged; empty when the clone is clean.
765    worktrees: Vec<PruneRow>,
766    /// What plausibly follows.
767    next: Vec<String>,
768}
769
770/// One reportable worktree, carried from classification to the report.
771struct Judged {
772    worktree: Worktree,
773    /// The branch observation, where the join found one.
774    tip: Option<String>,
775    class: WtClass,
776}
777
778/// The ordered cleanup: report stale records and gone-upstream worktrees
779/// — never the main checkout or a healthy linked one — confirm against
780/// the forge under `--verify`, and remove worktree before branch under
781/// `--apply`, re-observing at the moment of action.
782#[allow(clippy::too_many_lines)]
783fn prune(
784    target: &Utf8Path,
785    repo_flag: Option<&str>,
786    forge_flag: Option<&str>,
787    verify: bool,
788    apply: bool,
789    quiet: bool,
790    out: Output,
791) -> Result<(), RkError> {
792    let worktrees = inventory(target)?;
793    let layout = layout_of(&worktrees)?;
794    let branches = branch_inventory(target)?;
795    // The join fails closed as a whole: no branch lines where the
796    // inventory names checked-out branches means the observation itself
797    // cannot be trusted, and no judgment is made over it.
798    if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
799        return Err(RkError::refusal(
800            Diagnostic::new(
801                Reason::PrerequisiteUnmet,
802                "the branch inventory did not parse, and no worktree is judged without its branch observation",
803            )
804            .expected("a branch listing covering the checked-out branches")
805            .target_state("unchanged"),
806        ));
807    }
808    let seat_paths = seats(target);
809    let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
810
811    // The reportable set: stale-eligible records, and linked worktrees
812    // whose branch observation is gone — or missing, which is kept by
813    // name, never guessed. A healthy seat is never a row.
814    let mut judged: Vec<Judged> = Vec::new();
815    for worktree in worktrees.iter().skip(1) {
816        let observation = worktree
817            .branch
818            .as_deref()
819            .and_then(|name| branches.iter().find(|branch| branch.name == name));
820        let reportable = worktree.prunable.is_some()
821            || worktree
822                .branch
823                .as_deref()
824                .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
825        if !reportable {
826            continue;
827        }
828        let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
829        let class = classify(
830            worktree,
831            observation,
832            &layout,
833            &seat_refs,
834            TRUNK_BRANCH,
835            dirty,
836        );
837        judged.push(Judged {
838            worktree: worktree.clone(),
839            tip: observation.map(|branch| branch.tip.clone()),
840            class,
841        });
842    }
843
844    // The forge is asked only where a candidate exists to confirm.
845    if (verify || apply)
846        && judged
847            .iter()
848            .any(|row| matches!(row.class, WtClass::Candidate))
849    {
850        let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
851        let forge = Forge::parse(&resolved.forge)
852            .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
853        let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
854        let cli = resolve_cli(forge)?;
855        for row in &mut judged {
856            if matches!(row.class, WtClass::Candidate) {
857                let Some(tip) = row.tip.as_deref() else {
858                    continue;
859                };
860                row.class = WtClass::Judged(merged_request_for(
861                    &cli,
862                    target.as_std_path(),
863                    forge,
864                    &repo,
865                    tip,
866                ));
867            }
868        }
869    }
870
871    let mut rows: Vec<PruneRow> = judged
872        .iter()
873        .map(|row| {
874            let (status, request, detail) = match &row.class {
875                WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
876                WtClass::Candidate => ("candidate", None, None),
877                WtClass::Stale => ("stale", None, None),
878                WtClass::Judged(Class::Confirmed { request }) => {
879                    ("confirmed", Some(request.clone()), None)
880                }
881                WtClass::Judged(Class::Unconfirmed { detail }) => {
882                    ("unconfirmed", None, Some(detail.clone()))
883                }
884                WtClass::Judged(Class::Unknown { detail }) => {
885                    ("unknown", None, Some(detail.clone()))
886                }
887                WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
888            };
889            PruneRow {
890                path: row.worktree.path.to_string(),
891                branch: row.worktree.branch.clone(),
892                tip: row.tip.clone(),
893                status,
894                request,
895                detail,
896            }
897        })
898        .collect();
899
900    let mut failures = 0usize;
901    if apply {
902        for row in &mut rows {
903            if row.status != "confirmed" {
904                continue;
905            }
906            if let Err(count) = retire(target, row) {
907                failures += count;
908            }
909        }
910        failures += sweep_stale(target, &mut rows)?;
911    }
912
913    let mode = if apply {
914        "apply"
915    } else if verify {
916        "verify"
917    } else {
918        "preview"
919    };
920    let next = next_lines(mode);
921    render(out, &rows, &next, quiet);
922    out.emit(&PruneReport {
923        schema: "rk.worktree-prune/1",
924        mode,
925        worktrees: rows,
926        next,
927    })?;
928    if failures > 0 {
929        return Err(RkError::subprocess(
930            Diagnostic::new(
931                Reason::SubprocessFailed,
932                format!("git refused {failures} cleanup actions"),
933            )
934            .expected("every confirmed worktree removed; the report names each outcome"),
935        ));
936    }
937    Ok(())
938}
939
940/// Retire one confirmed worktree, ordered, each outcome independent:
941/// re-observe at the last moment — verification authorizes only the
942/// state it saw — then remove the worktree, then delete its branch
943/// through the shared compare-and-swap helper. A failed remove leaves
944/// the branch and its configuration untouched.
945fn retire(target: &Utf8Path, row: &mut PruneRow) -> Result<(), usize> {
946    let Some(branch) = row.branch.clone() else {
947        return Ok(());
948    };
949    let Some(tip) = row.tip.clone() else {
950        return Ok(());
951    };
952    let keep = |row: &mut PruneRow, moved: &str| {
953        row.status = "kept";
954        row.detail = Some(format!(
955            "{moved} after verification; rk worktree prune --verify re-confirms"
956        ));
957    };
958    let reread = git(
959        target,
960        &[
961            "for-each-ref",
962            &format!("refs/heads/{branch}"),
963            "--format",
964            "%(objectname)",
965        ],
966    )
967    .map_err(|_| 1usize)?;
968    let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
969    if !reread.status.success() || fresh_tip != tip {
970        keep(row, "the tip moved");
971        return Ok(());
972    }
973    // The fresh inventory fails closed: an unobservable state clears no
974    // removal, and the record must still be the same resource — the very
975    // branch the merge proof named, unlocked, its directory standing.
976    let path = Utf8PathBuf::from(&row.path);
977    let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
978    if !fresh.status.success() {
979        keep(row, "the worktree inventory could not be re-read");
980        return Ok(());
981    }
982    let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
983        keep(row, "the worktree inventory could not be re-read");
984        return Ok(());
985    };
986    let seat = inventory.iter().find(|worktree| worktree.path == path);
987    if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
988        keep(row, &reason);
989        return Ok(());
990    }
991    if is_dirty(&path) {
992        keep(row, "uncommitted changes arrived");
993        return Ok(());
994    }
995    let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
996    if !removed.status.success() {
997        row.status = "remove-failed";
998        row.detail = Some(format!(
999            "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
1000            last_line(&removed.stderr)
1001        ));
1002        return Err(1);
1003    }
1004    match maintenance::delete_branch(target, &branch, &tip) {
1005        maintenance::Deletion::Deleted => {
1006            row.status = "pruned";
1007            Ok(())
1008        }
1009        maintenance::Deletion::ConfigSurvived { detail } => {
1010            row.status = "pruned";
1011            row.detail = Some(detail);
1012            Ok(())
1013        }
1014        maintenance::Deletion::Refused { detail } => {
1015            // Reported truthfully: the worktree is already gone, the
1016            // branch and its work survive, and the recovery is named.
1017            row.status = "branch-delete-failed";
1018            row.detail = Some(format!(
1019                "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1020            ));
1021            Err(1)
1022        }
1023    }
1024}
1025
1026/// Clear the stale records, once, after the loop: plain `git worktree
1027/// prune` is expiration-gated, so `--expire now` is the form that
1028/// guarantees the missing-directory records go — and only those; a
1029/// locked record is never touched and was never a stale row. Because it
1030/// is one command over many rows, its outcome is read per row from a
1031/// fresh inventory rather than assumed.
1032fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1033    if !rows.iter().any(|row| row.status == "stale") {
1034        return Ok(0);
1035    }
1036    let mut failures = 0usize;
1037    let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1038    // Fail closed: only an inventory that was actually re-read proves a
1039    // record gone, so an unreadable one marks every stale row failed
1040    // rather than claiming a sweep nothing observed.
1041    let survivors: Option<Vec<Utf8PathBuf>> =
1042        git(target, &["worktree", "list", "--porcelain", "-z"])
1043            .ok()
1044            .filter(|fresh| fresh.status.success())
1045            .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1046            .map(|inventory| {
1047                inventory
1048                    .into_iter()
1049                    .map(|worktree| worktree.path)
1050                    .collect()
1051            });
1052    for row in rows.iter_mut().filter(|row| row.status == "stale") {
1053        let survived = survivors
1054            .as_ref()
1055            .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1056        if survived {
1057            row.status = "remove-failed";
1058            row.detail = Some(if survivors.is_none() {
1059                "the record's fate could not be observed; re-run rk worktree prune --apply"
1060                    .to_owned()
1061            } else if swept.status.success() {
1062                "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1063            } else {
1064                format!(
1065                    "{}; re-run rk worktree prune --apply",
1066                    last_line(&swept.stderr)
1067                )
1068            });
1069            failures += 1;
1070        } else {
1071            row.status = "pruned";
1072        }
1073    }
1074    if !swept.status.success() && failures == 0 {
1075        failures = 1;
1076    }
1077    Ok(failures)
1078}
1079
1080/// What plausibly follows each mode; an apply is its own conclusion.
1081fn next_lines(mode: &str) -> Vec<String> {
1082    let verify = "rk worktree prune --verify confirms each candidate against the forge";
1083    let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1084    match mode {
1085        "preview" => vec![verify.to_owned(), apply.to_owned()],
1086        "verify" => vec![apply.to_owned()],
1087        _ => Vec::new(),
1088    }
1089}
1090
1091/// The human report: silent under `--quiet` when nothing is reportable —
1092/// the clean-clone guarantee the reminder hook rests on — one judged line
1093/// per reportable worktree otherwise, closed by who owns the removal only
1094/// while some row still names a move.
1095fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1096    if quiet && rows.is_empty() {
1097        return;
1098    }
1099    if rows.is_empty() {
1100        out.result_line("no worktree needs cleanup");
1101    } else {
1102        out.result_line(header(rows.len()));
1103        let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1104        for row in rows {
1105            let tip = row
1106                .tip
1107                .as_deref()
1108                .map_or("        ", |tip| tip.get(..8).unwrap_or(tip));
1109            out.result_line(format!("  {:width$}  {tip}  {}", row.path, row.describe()));
1110        }
1111    }
1112    out.next(next);
1113    if rows
1114        .iter()
1115        .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1116    {
1117        out.result_line(OPERATOR_LINE);
1118    }
1119}
1120
1121/// The count-bearing first line.
1122fn header(count: usize) -> String {
1123    if count == 1 {
1124        "1 worktree reports cleanup (a candidate, not proof):".to_owned()
1125    } else {
1126        format!("{count} worktrees report cleanup (a candidate, not proof):")
1127    }
1128}
1129
1130/// Run one git command against the target, spawn failure typed. The
1131/// hook variables are scrubbed: a run from inside a git hook must act
1132/// on the named target, never on the hook's own repository.
1133fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1134    let mut command = std::process::Command::new("git");
1135    for var in maintenance::GIT_HOOK_VARS {
1136        command.env_remove(var);
1137    }
1138    command
1139        .arg("-C")
1140        .arg(target.as_std_path())
1141        .args(args)
1142        .output()
1143        .map_err(|source| {
1144            RkError::subprocess(
1145                Diagnostic::new(
1146                    Reason::SubprocessSpawn,
1147                    format!("git did not run: {source}"),
1148                )
1149                .expected("git installed and on PATH"),
1150            )
1151        })
1152}
1153
1154/// The last non-empty stderr line, for a one-line detail.
1155fn last_line(bytes: &[u8]) -> String {
1156    maintenance::last_line(bytes)
1157}
1158
1159#[cfg(test)]
1160mod tests {
1161    #![allow(clippy::expect_used)]
1162
1163    use super::{ListReport, ListRow, PruneReport, PruneRow};
1164
1165    /// The complete `rk.worktree-list/1` shape, held by snapshot in the
1166    /// populated and empty-next forms.
1167    #[test]
1168    fn the_worktree_list_schema_snapshot_holds() {
1169        let populated = ListReport {
1170            schema: "rk.worktree-list/1",
1171            worktrees: vec![
1172                ListRow {
1173                    path: "/srv/widget".into(),
1174                    branch: Some("master".into()),
1175                    head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1176                    kind: "main",
1177                    state: "clean",
1178                    canonical: true,
1179                },
1180                ListRow {
1181                    path: "/srv/elsewhere".into(),
1182                    branch: None,
1183                    head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1184                    kind: "linked",
1185                    state: "detached",
1186                    canonical: true,
1187                },
1188            ],
1189            next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1190        };
1191        assert_eq!(
1192            serde_json::to_string(&populated).expect("a report serializes"),
1193            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"]}"#,
1194            "a detached row must omit branch rather than serializing null"
1195        );
1196    }
1197
1198    /// The complete `rk.worktree-add/1` shape, held by snapshot in the
1199    /// apply form with every optional field present and the preview form
1200    /// with each absent — so a field rename, removal, or a null leaking
1201    /// from an optional fails here, not at some agent's parser.
1202    #[test]
1203    fn the_worktree_add_schema_snapshot_holds() {
1204        let apply = super::AddReport {
1205            schema: "rk.worktree-add/1",
1206            mode: "apply",
1207            branch: "feat/x".into(),
1208            path: "/srv/widget-feat-x".into(),
1209            created: "branch",
1210            source: "remote",
1211            base: Some("origin/feat/x".into()),
1212            upstream: Some("origin/feat/x".into()),
1213            detail: Some("the fetch failed; the run proceeded on local refs".into()),
1214            next: vec!["cd /srv/widget-feat-x".into()],
1215        };
1216        assert_eq!(
1217            serde_json::to_string(&apply).expect("a report serializes"),
1218            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"]}"#
1219        );
1220        let preview = super::AddReport {
1221            mode: "preview",
1222            created: "nothing",
1223            source: "adopted",
1224            base: None,
1225            upstream: None,
1226            detail: None,
1227            ..apply
1228        };
1229        assert_eq!(
1230            serde_json::to_string(&preview).expect("a report serializes"),
1231            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"]}"#,
1232            "an absent option must be omitted rather than serializing null"
1233        );
1234    }
1235
1236    /// The complete `rk.worktree-prune/1` shape, held by snapshot in the
1237    /// populated and clean forms.
1238    #[test]
1239    fn the_worktree_prune_schema_snapshot_holds() {
1240        let populated = PruneReport {
1241            schema: "rk.worktree-prune/1",
1242            mode: "verify",
1243            worktrees: vec![
1244                PruneRow {
1245                    path: "/srv/widget-feat-x".into(),
1246                    branch: Some("feat/x".into()),
1247                    tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1248                    status: "confirmed",
1249                    request: Some("#8".into()),
1250                    detail: None,
1251                },
1252                PruneRow {
1253                    path: "/srv/widget-fix-y".into(),
1254                    branch: None,
1255                    tip: None,
1256                    status: "stale",
1257                    request: None,
1258                    detail: None,
1259                },
1260            ],
1261            next: vec![
1262                "rk worktree prune --apply verifies, then removes each worktree before its branch"
1263                    .into(),
1264            ],
1265        };
1266        assert_eq!(
1267            serde_json::to_string(&populated).expect("a report serializes"),
1268            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"]}"##
1269        );
1270        let clean = PruneReport {
1271            schema: "rk.worktree-prune/1",
1272            mode: "preview",
1273            worktrees: vec![],
1274            next: vec![
1275                "rk worktree prune --verify confirms each candidate against the forge".into(),
1276            ],
1277        };
1278        assert_eq!(
1279            serde_json::to_string(&clean).expect("a report serializes"),
1280            r#"{"schema":"rk.worktree-prune/1","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1281            "a clean clone reports one empty list a caller can branch on"
1282        );
1283    }
1284}