1use camino::{Utf8Path, Utf8PathBuf};
13use serde::Serialize;
14
15use crate::branches::{Branch, Class, FOR_EACH_REF_FORMAT, merged_request_for};
16use crate::cli::worktree::{WorktreeAction, WorktreeArgs};
17use crate::detect::Forge;
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::error::RkError;
20use crate::maintenance;
21use crate::output::Output;
22use crate::setup::context::resolve_cli;
23use crate::worktree::{Layout, Worktree, WtClass, classify, derived_path, matches_grammar};
24
25const 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
30pub 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
68fn 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
105fn 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
115pub(crate) fn main_checkout(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
126 let worktrees = inventory(target)?;
127 Ok(layout_of(&worktrees)?.main)
128}
129
130pub(crate) fn seat_of(target: &Utf8Path, branch: &str) -> Result<Option<Utf8PathBuf>, RkError> {
140 Ok(inventory(target)?
141 .into_iter()
142 .find(|worktree| worktree.branch.as_deref() == Some(branch))
143 .map(|worktree| worktree.path))
144}
145
146fn seats(target: &Utf8Path) -> Vec<Utf8PathBuf> {
151 let toplevel = |output: std::io::Result<std::process::Output>| {
152 output
153 .ok()
154 .filter(|answer| answer.status.success())
155 .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
156 .filter(|path| !path.is_empty())
157 .map(Utf8PathBuf::from)
158 };
159 let scrubbed = || {
160 let mut command = std::process::Command::new(crate::probes::git_bin());
161 for var in maintenance::GIT_HOOK_VARS {
162 command.env_remove(var);
163 }
164 command
165 };
166 let mut seats = Vec::new();
167 if let Some(seat) = toplevel(scrubbed().args(["rev-parse", "--show-toplevel"]).output()) {
168 seats.push(seat);
169 }
170 if let Some(seat) = toplevel(
171 scrubbed()
172 .arg("-C")
173 .arg(target.as_std_path())
174 .args(["rev-parse", "--show-toplevel"])
175 .output(),
176 ) && !seats.contains(&seat)
177 {
178 seats.push(seat);
179 }
180 seats
181}
182
183fn is_dirty(path: &Utf8Path) -> bool {
186 git(path, &["status", "--porcelain"]).map_or(true, |probed| {
187 !probed.status.success() || !probed.stdout.is_empty()
188 })
189}
190
191fn branch_inventory(target: &Utf8Path) -> Result<Vec<Branch>, RkError> {
194 let listed = git(
195 target,
196 &[
197 "for-each-ref",
198 "refs/heads",
199 "--format",
200 FOR_EACH_REF_FORMAT,
201 ],
202 )?;
203 if !listed.status.success() {
204 return Err(RkError::refusal(
205 Diagnostic::new(
206 Reason::PrerequisiteUnmet,
207 format!("target {target} is not a git repository"),
208 )
209 .expected("a repository whose branches git can list"),
210 ));
211 }
212 Ok(crate::branches::parse_branches(&String::from_utf8_lossy(
213 &listed.stdout,
214 )))
215}
216
217#[derive(Debug, Serialize)]
222struct ListRow {
223 path: String,
225 #[serde(skip_serializing_if = "Option::is_none")]
227 branch: Option<String>,
228 head: String,
230 kind: &'static str,
232 state: &'static str,
235 canonical: bool,
237}
238
239#[derive(Debug, Serialize)]
241struct ListReport {
242 schema: &'static str,
244 worktrees: Vec<ListRow>,
246 next: Vec<String>,
248}
249
250fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
252 let worktrees = inventory(target)?;
253 let layout = layout_of(&worktrees)?;
254 let rows: Vec<ListRow> = worktrees
255 .iter()
256 .enumerate()
257 .map(|(index, worktree)| {
258 let state = if worktree.locked.is_some() {
262 "locked"
263 } else if worktree.prunable.is_some() {
264 "missing"
265 } else if worktree.branch.is_none() {
266 "detached"
267 } else if is_dirty(&worktree.path) {
268 "dirty"
269 } else {
270 "clean"
271 };
272 let canonical = index == 0
273 || worktree
274 .branch
275 .as_deref()
276 .is_none_or(|branch| derived_path(&layout, branch) == worktree.path);
277 ListRow {
278 path: worktree.path.to_string(),
279 branch: worktree.branch.clone(),
280 head: worktree.head.clone(),
281 kind: if index == 0 { "main" } else { "linked" },
282 state,
283 canonical,
284 }
285 })
286 .collect();
287 let next = vec![
288 "rk worktree add <branch> creates or adopts a branch's worktree".to_owned(),
289 "rk worktree prune reports the worktrees a squash merge retired".to_owned(),
290 ];
291 out.result_line(format!(
292 "{} worktree{} of {}:",
293 rows.len(),
294 if rows.len() == 1 { "" } else { "s" },
295 layout.main
296 ));
297 let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
298 for row in &rows {
299 let head = row.head.get(..8).unwrap_or(&row.head);
300 let mut line = format!(
301 " {:width$} {head} {} {}",
302 row.path,
303 row.branch.as_deref().unwrap_or("(detached)"),
304 row.state
305 );
306 if !row.canonical
307 && let Some(branch) = &row.branch
308 {
309 use std::fmt::Write as _;
310 let expected = derived_path(&layout, branch);
311 let _ = write!(
312 line,
313 " off-path: expected ../{}",
314 expected.file_name().unwrap_or_default()
315 );
316 }
317 out.result_line(line);
318 }
319 out.next(&next);
320 out.emit(&ListReport {
321 schema: "rk.worktree-list/1",
322 worktrees: rows,
323 next,
324 })
325}
326
327#[derive(Debug, Serialize)]
332struct AddReport {
333 schema: &'static str,
335 mode: &'static str,
337 branch: String,
339 path: String,
341 created: &'static str,
345 source: &'static str,
348 #[serde(skip_serializing_if = "Option::is_none")]
350 base: Option<String>,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 upstream: Option<String>,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 detail: Option<String>,
357 next: Vec<String>,
359}
360
361pub(crate) struct Source {
363 pub(crate) kind: &'static str,
365 pub(crate) created: &'static str,
367 pub(crate) base: Option<String>,
369 pub(crate) upstream: Option<String>,
371 pub(crate) command: Vec<String>,
373}
374
375pub(crate) enum Seat {
377 Satisfied {
379 path: Utf8PathBuf,
381 },
382 Fresh {
384 path: Utf8PathBuf,
386 source: Source,
388 detail: Option<String>,
390 },
391}
392
393pub(crate) fn plan_seat(
405 target: &Utf8Path,
406 branch: &str,
407 base: Option<&str>,
408 apply: bool,
409) -> Result<Seat, RkError> {
410 let trunk = crate::config::trunk_of(target.as_std_path())?;
411 let worktrees = inventory(target)?;
412 let layout = layout_of(&worktrees)?;
413
414 if !matches_grammar(branch) {
418 return Err(RkError::Usage(format!(
419 "branch '{branch}' is none of the three forms — <type>/<slug>, <issue-id>-<slug>, or release/<line> — the landed grammar admits"
420 )));
421 }
422 let checked = git(target, &["check-ref-format", "--branch", branch])?;
423 if !checked.status.success() {
424 return Err(RkError::Usage(format!(
425 "git refuses the branch name '{branch}': {}",
426 last_line(&checked.stderr)
427 )));
428 }
429 if branch == trunk {
430 return Err(RkError::refusal(
431 Diagnostic::new(
432 Reason::PrerequisiteUnmet,
433 format!("{trunk} takes no worktree; the main checkout is its seat"),
434 )
435 .expected("a short-lived branch to seat")
436 .target_state("unchanged"),
437 ));
438 }
439 if let Some(base) = base
440 && base.starts_with('-')
441 {
442 return Err(RkError::Usage(format!(
443 "--base '{base}' is option-shaped; pass a commit-ish"
444 )));
445 }
446 let path = derived_path(&layout, branch);
447
448 let registered = worktrees
451 .iter()
452 .find(|worktree| worktree.branch.as_deref() == Some(branch));
453 if let Some(seat) = registered {
454 return judge_registered(seat, branch, &path, &layout.main);
455 }
456 if path.exists() {
457 let occupant = worktrees
458 .iter()
459 .find(|worktree| worktree.path == path)
460 .and_then(|worktree| worktree.branch.clone())
461 .map_or_else(
462 || "a directory this repository does not register".to_owned(),
463 |other| format!("the worktree of branch {other}"),
464 );
465 return Err(RkError::refusal(
466 Diagnostic::new(
467 Reason::StateDrift,
468 format!(
469 "{path} already exists as {occupant}; flattening is not injective and nothing is suffixed silently"
470 ),
471 )
472 .expected("the derived path free, or registered to this branch")
473 .target_state("unchanged"),
474 ));
475 }
476
477 let mut detail = None;
482 if apply {
483 let fetched = git(target, &["fetch", "origin"])?;
484 if !fetched.status.success() {
485 detail = Some(format!(
486 "the fetch failed ({}); the run proceeded on local refs",
487 last_line(&fetched.stderr)
488 ));
489 }
490 }
491 let source = resolve_source(target, branch, base, &path)?;
492 Ok(Seat::Fresh {
493 path,
494 source,
495 detail,
496 })
497}
498
499fn judge_registered(
503 seat: &Worktree,
504 branch: &str,
505 path: &Utf8Path,
506 main: &Utf8Path,
507) -> Result<Seat, RkError> {
508 let trunk = crate::config::trunk_of(main.as_std_path())?;
509 if seat.path == path {
510 if seat.prunable.is_some() || !path.is_dir() {
518 let recovery = if seat.locked.is_some() {
519 format!(
520 "the record is locked, which prune keeps unconditionally: git worktree repair recovers a moved directory, or git worktree unlock {path} — for a lock you own — then rk worktree prune --apply clears it"
521 )
522 } else {
523 "rk worktree prune --apply clears the stale record, then re-run; git worktree repair recovers a moved directory instead".to_owned()
524 };
525 return Err(RkError::refusal(
526 Diagnostic::new(
527 Reason::StateDrift,
528 format!("{path} is registered to {branch} and its directory is missing"),
529 )
530 .expected("the canonical worktree standing, or its stale record cleared")
531 .action(recovery)
532 .target_state("unchanged"),
533 ));
534 }
535 return Ok(Seat::Satisfied {
536 path: path.to_owned(),
537 });
538 }
539 let recovery = if seat.path == main {
543 format!("git switch {trunk} there, then re-run")
544 } else {
545 format!("git worktree move {} {path}", seat.path)
546 };
547 Err(RkError::refusal(
548 Diagnostic::new(
549 Reason::StateDrift,
550 format!(
551 "branch {branch} is checked out at {}, and one branch has one seat",
552 seat.path
553 ),
554 )
555 .expected("the branch free, or already at its derived path")
556 .action(recovery)
557 .target_state("unchanged"),
558 ))
559}
560
561pub(crate) fn create_seat(target: &Utf8Path, source: &Source) -> Result<(), RkError> {
567 let argv: Vec<&str> = source.command.iter().map(String::as_str).collect();
568 let created = git(target, &argv)?;
569 if created.status.success() {
570 return Ok(());
571 }
572 Err(RkError::subprocess(
573 Diagnostic::new(
574 Reason::SubprocessFailed,
575 format!("git worktree add refused: {}", last_line(&created.stderr)),
576 )
577 .expected("the worktree created at the derived path")
578 .target_state("unchanged"),
579 ))
580}
581
582fn add(
584 target: &Utf8Path,
585 branch: &str,
586 base: Option<&str>,
587 apply: bool,
588 out: Output,
589) -> Result<(), RkError> {
590 let (path, source, detail) = match plan_seat(target, branch, base, apply)? {
591 Seat::Satisfied { path } => return report_satisfied(out, branch, &path, apply),
592 Seat::Fresh {
593 path,
594 source,
595 detail,
596 } => (path, source, detail),
597 };
598
599 if !apply {
600 out.result_line(format!(
601 "branch: {branch} ({})",
602 match source.kind {
603 "adopted" => "existing, adopted".to_owned(),
604 "remote" => format!(
605 "remote, from {}",
606 source.upstream.as_deref().unwrap_or("origin")
607 ),
608 _ => format!("new, from {}", source.base.as_deref().unwrap_or("?")),
609 }
610 ));
611 out.result_line(format!(
612 "path: ../{}",
613 path.file_name().unwrap_or_default()
614 ));
615 if let Some(base) = &source.base {
616 out.result_line(format!("base: {base}"));
617 }
618 out.result_line(format!("would run: git {}", source.command.join(" ")));
619 let base_flag = base.map_or_else(String::new, |base| format!(" --base {base}"));
620 let next = vec![format!(
621 "rk worktree add {branch}{base_flag} --target {target} --apply creates it; the apply refreshes the remote refs and re-resolves"
622 )];
623 out.next(&next);
624 return out.emit(&AddReport {
625 schema: "rk.worktree-add/1",
626 mode: "preview",
627 branch: branch.to_owned(),
628 path: path.to_string(),
629 created: source.created,
630 source: source.kind,
631 base: source.base,
632 upstream: source.upstream,
633 detail: Some(
634 "a preview decides from the local refs as they stand; apply refreshes and re-resolves"
635 .to_owned(),
636 ),
637 next,
638 });
639 }
640
641 create_seat(target, &source)?;
642 out.result_line(&path);
643 let next = vec![
644 format!("cd {path}"),
645 "rk worktree list reports every seat".to_owned(),
646 ];
647 out.next(&next);
648 out.emit(&AddReport {
649 schema: "rk.worktree-add/1",
650 mode: "apply",
651 branch: branch.to_owned(),
652 path: path.to_string(),
653 created: source.created,
654 source: source.kind,
655 base: source.base,
656 upstream: source.upstream,
657 detail,
658 next,
659 })
660}
661
662fn report_satisfied(
664 out: Output,
665 branch: &str,
666 path: &Utf8Path,
667 apply: bool,
668) -> Result<(), RkError> {
669 out.result_line(format!("{path} already seats {branch}; nothing to create"));
670 let next = vec![format!("cd {path}")];
671 out.next(&next);
672 out.emit(&AddReport {
673 schema: "rk.worktree-add/1",
674 mode: if apply { "apply" } else { "preview" },
675 branch: branch.to_owned(),
676 path: path.to_string(),
677 created: "nothing",
678 source: "adopted",
679 base: None,
680 upstream: None,
681 detail: None,
682 next,
683 })
684}
685
686fn resolve_source(
695 target: &Utf8Path,
696 branch: &str,
697 base: Option<&str>,
698 path: &Utf8Path,
699) -> Result<Source, RkError> {
700 let trunk = crate::config::trunk_of(target.as_std_path())?;
701 let resolve = |name: &str| -> Result<Option<String>, RkError> {
702 let resolved = git(
703 target,
704 &[
705 "rev-parse",
706 "--verify",
707 "--quiet",
708 "--end-of-options",
709 &format!("{name}^{{commit}}"),
710 ],
711 )?;
712 Ok(resolved
713 .status
714 .success()
715 .then(|| String::from_utf8_lossy(&resolved.stdout).trim().to_owned()))
716 };
717
718 if resolve(&format!("refs/heads/{branch}"))?.is_some() {
721 return Ok(Source {
722 kind: "adopted",
723 created: "worktree",
724 base: None,
725 upstream: None,
726 command: vec![
727 "worktree".into(),
728 "add".into(),
729 path.to_string(),
730 branch.to_owned(),
731 ],
732 });
733 }
734
735 let remote_ref = format!("refs/remotes/origin/{branch}");
738 if resolve(&remote_ref)?.is_some() {
739 return Ok(Source {
740 kind: "remote",
741 created: "branch",
742 base: Some(format!("origin/{branch}")),
743 upstream: Some(format!("origin/{branch}")),
744 command: vec![
745 "worktree".into(),
746 "add".into(),
747 "--track".into(),
748 "-b".into(),
749 branch.to_owned(),
750 path.to_string(),
751 remote_ref,
752 ],
753 });
754 }
755
756 if branch.starts_with(crate::branches::PROTECTED_PREFIX) && base.is_none() {
760 return Err(RkError::refusal(
761 Diagnostic::new(
762 Reason::PrerequisiteUnmet,
763 format!(
764 "release line {branch} takes an explicit --base; a line is cut from a tag, never the tip"
765 ),
766 )
767 .expected("--base \"v<version>\" naming the tag the line patches")
768 .target_state("unchanged"),
769 ));
770 }
771 let (kind, shown) = base.map_or_else(
772 || ("trunk", format!("origin/{trunk}")),
773 |base| ("base", base.to_owned()),
774 );
775 let resolved = match resolve(&shown)? {
776 Some(oid) => Some(oid),
777 None if kind == "trunk" => resolve(&trunk)?,
779 None => None,
780 };
781 let oid = resolved.ok_or_else(|| {
782 RkError::refusal(
783 Diagnostic::new(
784 Reason::PrerequisiteUnmet,
785 format!("{shown} does not resolve to a commit"),
786 )
787 .expected("a commit-ish the new branch can start from")
788 .target_state("unchanged"),
789 )
790 })?;
791 Ok(Source {
792 kind,
793 created: "branch",
794 base: Some(shown),
795 upstream: None,
796 command: vec![
797 "worktree".into(),
798 "add".into(),
799 path.to_string(),
800 "-b".into(),
801 branch.to_owned(),
802 oid,
803 ],
804 })
805}
806
807#[derive(Debug, Serialize)]
812struct PruneRow {
813 path: String,
815 #[serde(skip_serializing_if = "Option::is_none")]
817 branch: Option<String>,
818 #[serde(skip_serializing_if = "Option::is_none")]
820 tip: Option<String>,
821 status: &'static str,
824 #[serde(skip_serializing_if = "Option::is_none")]
828 proof: Option<String>,
829 #[serde(skip_serializing_if = "Option::is_none")]
832 proof_kind: Option<&'static str>,
833 #[serde(skip_serializing_if = "Option::is_none")]
835 detail: Option<String>,
836}
837
838impl PruneRow {
839 fn describe(&self) -> String {
841 match self.status {
842 "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
843 "stale" => {
844 "stale: the registered directory is missing; apply clears the record".to_owned()
845 }
846 "confirmed" if self.proof_kind == Some("reachable-tip") => {
847 format!("confirmed: {}", self.proof.as_deref().unwrap_or(""))
848 }
849 "confirmed" => format!(
850 "confirmed: {} matches this tip",
851 self.proof.as_deref().unwrap_or("")
852 ),
853 "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
854 "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
855 "pruned" => {
856 let mut line = self
857 .proof
858 .as_deref()
859 .map_or_else(|| "pruned".to_owned(), |proof| format!("pruned ({proof})"));
860 if let Some(detail) = &self.detail {
861 line.push_str("; ");
862 line.push_str(detail);
863 }
864 line
865 }
866 "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
867 "branch-delete-failed" => format!(
868 "branch delete failed: {}",
869 self.detail.as_deref().unwrap_or("")
870 ),
871 _ => "candidate".to_owned(),
872 }
873 }
874}
875
876#[derive(Debug, Serialize)]
878struct PruneReport {
879 schema: &'static str,
881 mode: &'static str,
883 worktrees: Vec<PruneRow>,
885 next: Vec<String>,
887}
888
889struct Judged {
891 worktree: Worktree,
892 tip: Option<String>,
894 class: WtClass,
895}
896
897#[allow(
902 clippy::too_many_lines,
903 reason = "the prune order is the safety property, so report, confirm, and remove worktree before branch stay in one body where the order cannot drift"
904)]
905fn prune(
906 target: &Utf8Path,
907 repo_flag: Option<&str>,
908 forge_flag: Option<&str>,
909 verify: bool,
910 apply: bool,
911 quiet: bool,
912 out: Output,
913) -> Result<(), RkError> {
914 let trunk = crate::config::trunk_of(target.as_std_path())?;
915 let worktrees = inventory(target)?;
916 let layout = layout_of(&worktrees)?;
917 let branches = branch_inventory(target)?;
918 if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
922 return Err(RkError::refusal(
923 Diagnostic::new(
924 Reason::PrerequisiteUnmet,
925 "the branch inventory did not parse, and no worktree is judged without its branch observation",
926 )
927 .expected("a branch listing covering the checked-out branches")
928 .target_state("unchanged"),
929 ));
930 }
931 let ledger = crate::maintenance::integration_ledger(target, &trunk);
932 let seat_paths = seats(target);
933 let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
934
935 let mut judged: Vec<Judged> = Vec::new();
939 for worktree in worktrees.iter().skip(1) {
940 let observation = worktree
941 .branch
942 .as_deref()
943 .and_then(|name| branches.iter().find(|branch| branch.name == name));
944 let local = observation.and_then(|branch| {
949 maintenance::reachable_tip(target, &trunk, &branch.name, &branch.tip).or_else(|| {
950 ledger.proof(&branch.name, &branch.tip).map(|entry| {
951 crate::branches::Proof::LocalIntegration(entry.trunk_commit.clone())
952 })
953 })
954 });
955 let reportable = worktree.prunable.is_some()
956 || local.is_some()
957 || worktree
958 .branch
959 .as_deref()
960 .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
961 if !reportable {
962 continue;
963 }
964 let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
965 let class = classify(
966 worktree,
967 observation,
968 &layout,
969 &seat_refs,
970 &trunk,
971 dirty,
972 local,
973 );
974 judged.push(Judged {
975 worktree: worktree.clone(),
976 tip: observation.map(|branch| branch.tip.clone()),
977 class,
978 });
979 }
980
981 if (verify || apply)
983 && judged
984 .iter()
985 .any(|row| matches!(row.class, WtClass::Candidate))
986 {
987 let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
988 let forge = Forge::parse(&resolved.forge)
989 .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
990 let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
991 let cli = resolve_cli(forge)?;
992 for row in &mut judged {
993 if matches!(row.class, WtClass::Candidate) {
994 let Some(tip) = row.tip.as_deref() else {
995 continue;
996 };
997 row.class = WtClass::Judged(merged_request_for(
998 &cli,
999 target.as_std_path(),
1000 forge,
1001 &repo,
1002 tip,
1003 ));
1004 }
1005 }
1006 }
1007
1008 let mut rows: Vec<PruneRow> = judged
1009 .iter()
1010 .map(|row| {
1011 let (status, proof, detail) = match &row.class {
1012 WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
1013 WtClass::Candidate => ("candidate", None, None),
1014 WtClass::Stale => ("stale", None, None),
1015 WtClass::Judged(Class::Confirmed { proof }) => {
1016 ("confirmed", Some(proof.clone()), None)
1017 }
1018 WtClass::Judged(Class::Unconfirmed { detail }) => {
1019 ("unconfirmed", None, Some(detail.clone()))
1020 }
1021 WtClass::Judged(Class::Unknown { detail }) => {
1022 ("unknown", None, Some(detail.clone()))
1023 }
1024 WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
1025 };
1026 PruneRow {
1027 path: row.worktree.path.to_string(),
1028 branch: row.worktree.branch.clone(),
1029 tip: row.tip.clone(),
1030 status,
1031 proof_kind: proof.as_ref().map(crate::branches::Proof::kind),
1032 proof: proof.as_ref().map(crate::branches::Proof::detail),
1033 detail,
1034 }
1035 })
1036 .collect();
1037
1038 let mut failures = 0usize;
1039 if apply {
1040 for row in &mut rows {
1041 if row.status != "confirmed" {
1042 continue;
1043 }
1044 if let Err(count) = retire(target, &trunk, row) {
1045 failures += count;
1046 }
1047 }
1048 failures += sweep_stale(target, &mut rows)?;
1049 }
1050
1051 let mode = if apply {
1052 "apply"
1053 } else if verify {
1054 "verify"
1055 } else {
1056 "preview"
1057 };
1058 let next = next_lines(mode);
1059 render(out, &rows, &next, quiet);
1060 out.emit(&PruneReport {
1061 schema: "rk.worktree-prune/2",
1062 mode,
1063 worktrees: rows,
1064 next,
1065 })?;
1066 if failures > 0 {
1067 return Err(RkError::subprocess(
1068 Diagnostic::new(
1069 Reason::SubprocessFailed,
1070 format!("git refused {failures} cleanup actions"),
1071 )
1072 .expected("every confirmed worktree removed; the report names each outcome"),
1073 ));
1074 }
1075 Ok(())
1076}
1077
1078fn retire(target: &Utf8Path, trunk: &str, row: &mut PruneRow) -> Result<(), usize> {
1085 let Some(branch) = row.branch.clone() else {
1086 return Ok(());
1087 };
1088 let Some(tip) = row.tip.clone() else {
1089 return Ok(());
1090 };
1091 let keep = |row: &mut PruneRow, moved: &str| {
1092 row.status = "kept";
1093 row.detail = Some(format!(
1094 "{moved} after verification; rk worktree prune --verify re-confirms"
1095 ));
1096 };
1097 let reread = git(
1098 target,
1099 &[
1100 "for-each-ref",
1101 &format!("refs/heads/{branch}"),
1102 "--format",
1103 "%(objectname)",
1104 ],
1105 )
1106 .map_err(|_| 1usize)?;
1107 let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
1108 if !reread.status.success() || fresh_tip != tip {
1109 keep(row, "the tip moved");
1110 return Ok(());
1111 }
1112 if row.proof_kind == Some("reachable-tip") && !maintenance::trunk_reaches(target, trunk, &tip) {
1113 keep(row, "the trunk no longer reaches the tip");
1114 return Ok(());
1115 }
1116 let path = Utf8PathBuf::from(&row.path);
1120 let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
1121 if !fresh.status.success() {
1122 keep(row, "the worktree inventory could not be re-read");
1123 return Ok(());
1124 }
1125 let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
1126 keep(row, "the worktree inventory could not be re-read");
1127 return Ok(());
1128 };
1129 let seat = inventory.iter().find(|worktree| worktree.path == path);
1130 if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
1131 keep(row, &reason);
1132 return Ok(());
1133 }
1134 if is_dirty(&path) {
1135 keep(row, "uncommitted changes arrived");
1136 return Ok(());
1137 }
1138 let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
1139 if !removed.status.success() {
1140 row.status = "remove-failed";
1141 row.detail = Some(format!(
1142 "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
1143 last_line(&removed.stderr)
1144 ));
1145 return Err(1);
1146 }
1147 match maintenance::delete_branch(target, &branch, &tip) {
1148 maintenance::Deletion::Deleted => {
1149 maintenance::forget_integration(target, &branch);
1150 row.status = "pruned";
1151 Ok(())
1152 }
1153 maintenance::Deletion::ConfigSurvived { detail } => {
1154 maintenance::forget_integration(target, &branch);
1155 row.status = "pruned";
1156 row.detail = Some(detail);
1157 Ok(())
1158 }
1159 maintenance::Deletion::Refused { detail } => {
1160 row.status = "branch-delete-failed";
1163 row.detail = Some(format!(
1164 "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1165 ));
1166 Err(1)
1167 }
1168 }
1169}
1170
1171fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1178 if !rows.iter().any(|row| row.status == "stale") {
1179 return Ok(0);
1180 }
1181 let mut failures = 0usize;
1182 let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1183 let survivors: Option<Vec<Utf8PathBuf>> =
1187 git(target, &["worktree", "list", "--porcelain", "-z"])
1188 .ok()
1189 .filter(|fresh| fresh.status.success())
1190 .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1191 .map(|inventory| {
1192 inventory
1193 .into_iter()
1194 .map(|worktree| worktree.path)
1195 .collect()
1196 });
1197 for row in rows.iter_mut().filter(|row| row.status == "stale") {
1198 let survived = survivors
1199 .as_ref()
1200 .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1201 if survived {
1202 row.status = "remove-failed";
1203 row.detail = Some(if survivors.is_none() {
1204 "the record's fate could not be observed; re-run rk worktree prune --apply"
1205 .to_owned()
1206 } else if swept.status.success() {
1207 "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1208 } else {
1209 format!(
1210 "{}; re-run rk worktree prune --apply",
1211 last_line(&swept.stderr)
1212 )
1213 });
1214 failures += 1;
1215 } else {
1216 row.status = "pruned";
1217 }
1218 }
1219 if !swept.status.success() && failures == 0 {
1220 failures = 1;
1221 }
1222 Ok(failures)
1223}
1224
1225fn next_lines(mode: &str) -> Vec<String> {
1227 let verify = "rk worktree prune --verify confirms each candidate against the forge";
1228 let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1229 match mode {
1230 "preview" => vec![verify.to_owned(), apply.to_owned()],
1231 "verify" => vec![apply.to_owned()],
1232 _ => Vec::new(),
1233 }
1234}
1235
1236fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1241 if quiet && rows.is_empty() {
1242 return;
1243 }
1244 if rows.is_empty() {
1245 out.result_line("no worktree needs cleanup");
1246 } else {
1247 out.result_line(header(
1248 rows.len(),
1249 rows.iter()
1250 .filter(|row| row.proof_kind == Some("local-integration"))
1251 .count(),
1252 rows.iter()
1253 .filter(|row| row.proof_kind == Some("reachable-tip"))
1254 .count(),
1255 ));
1256 let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1257 for row in rows {
1258 let tip = row
1259 .tip
1260 .as_deref()
1261 .map_or(" ", |tip| tip.get(..8).unwrap_or(tip));
1262 out.result_line(format!(" {:width$} {tip} {}", row.path, row.describe()));
1263 }
1264 }
1265 out.next(next);
1266 if rows
1267 .iter()
1268 .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1269 {
1270 out.result_line(OPERATOR_LINE);
1271 }
1272}
1273
1274fn header(count: usize, local: usize, reachable: usize) -> String {
1280 let (noun, reports, carry) = if count == 1 {
1281 ("1 worktree".to_owned(), "reports", "carries")
1282 } else {
1283 (format!("{count} worktrees"), "report", "carry")
1284 };
1285 match (local, reachable, count - local - reachable) {
1286 (0, 0, _) => format!("{noun} {reports} cleanup (a candidate, not proof):"),
1287 (_, 0, 0) => format!("{noun} {carry} a local integration this clone recorded:"),
1288 (0, _, 0) => format!("{noun} {carry} a branch tip the trunk reaches:"),
1289 (_, _, 0) => format!(
1290 "{noun}: {local} locally integrated, {reachable} with a branch tip the trunk reaches:"
1291 ),
1292 (_, 0, other) => {
1293 format!("{noun}: {other} reporting cleanup, {local} locally integrated:")
1294 }
1295 (_, _, other) => format!(
1296 "{noun}: {other} reporting cleanup, {local} locally integrated, {reachable} with a branch tip the trunk reaches:"
1297 ),
1298 }
1299}
1300
1301pub(crate) fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1312 let mut command = std::process::Command::new(crate::probes::git_bin());
1313 for var in maintenance::GIT_HOOK_VARS {
1314 command.env_remove(var);
1315 }
1316 command
1317 .arg("-C")
1318 .arg(target.as_std_path())
1319 .args(args)
1320 .output()
1321 .map_err(|source| {
1322 RkError::subprocess(
1323 Diagnostic::new(
1324 Reason::SubprocessSpawn,
1325 format!("git did not run: {source}"),
1326 )
1327 .expected("git installed and on PATH"),
1328 )
1329 })
1330}
1331
1332fn last_line(bytes: &[u8]) -> String {
1334 maintenance::last_line(bytes)
1335}
1336
1337#[cfg(test)]
1338mod tests {
1339 use super::{ListReport, ListRow, PruneReport, PruneRow};
1340
1341 #[test]
1344 fn the_worktree_list_schema_snapshot_holds() {
1345 let populated = ListReport {
1346 schema: "rk.worktree-list/1",
1347 worktrees: vec![
1348 ListRow {
1349 path: "/srv/widget".into(),
1350 branch: Some("master".into()),
1351 head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1352 kind: "main",
1353 state: "clean",
1354 canonical: true,
1355 },
1356 ListRow {
1357 path: "/srv/elsewhere".into(),
1358 branch: None,
1359 head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1360 kind: "linked",
1361 state: "detached",
1362 canonical: true,
1363 },
1364 ],
1365 next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1366 };
1367 assert_eq!(
1368 serde_json::to_string(&populated).expect("a report serializes"),
1369 r#"{"schema":"rk.worktree-list/1","worktrees":[{"path":"/srv/widget","branch":"master","head":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","kind":"main","state":"clean","canonical":true},{"path":"/srv/elsewhere","head":"bbbbccccddddaaaabbbbccccddddaaaabbbbcccc","kind":"linked","state":"detached","canonical":true}],"next":["rk worktree prune reports the worktrees a squash merge retired"]}"#,
1370 "a detached row must omit branch rather than serializing null"
1371 );
1372 }
1373
1374 #[test]
1379 fn the_worktree_add_schema_snapshot_holds() {
1380 let apply = super::AddReport {
1381 schema: "rk.worktree-add/1",
1382 mode: "apply",
1383 branch: "feat/x".into(),
1384 path: "/srv/widget@feat-x".into(),
1385 created: "branch",
1386 source: "remote",
1387 base: Some("origin/feat/x".into()),
1388 upstream: Some("origin/feat/x".into()),
1389 detail: Some("the fetch failed; the run proceeded on local refs".into()),
1390 next: vec!["cd /srv/widget@feat-x".into()],
1391 };
1392 assert_eq!(
1393 serde_json::to_string(&apply).expect("a report serializes"),
1394 r#"{"schema":"rk.worktree-add/1","mode":"apply","branch":"feat/x","path":"/srv/widget@feat-x","created":"branch","source":"remote","base":"origin/feat/x","upstream":"origin/feat/x","detail":"the fetch failed; the run proceeded on local refs","next":["cd /srv/widget@feat-x"]}"#
1395 );
1396 let preview = super::AddReport {
1397 mode: "preview",
1398 created: "nothing",
1399 source: "adopted",
1400 base: None,
1401 upstream: None,
1402 detail: None,
1403 ..apply
1404 };
1405 assert_eq!(
1406 serde_json::to_string(&preview).expect("a report serializes"),
1407 r#"{"schema":"rk.worktree-add/1","mode":"preview","branch":"feat/x","path":"/srv/widget@feat-x","created":"nothing","source":"adopted","next":["cd /srv/widget@feat-x"]}"#,
1408 "an absent option must be omitted rather than serializing null"
1409 );
1410 }
1411
1412 #[test]
1415 fn the_worktree_prune_schema_snapshot_holds() {
1416 let populated = PruneReport {
1417 schema: "rk.worktree-prune/2",
1418 mode: "verify",
1419 worktrees: vec![
1420 PruneRow {
1421 path: "/srv/widget@feat-x".into(),
1422 branch: Some("feat/x".into()),
1423 tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1424 status: "confirmed",
1425 proof: Some("#8".into()),
1426 proof_kind: Some("request"),
1427 detail: None,
1428 },
1429 PruneRow {
1430 path: "/srv/widget@fix-y".into(),
1431 branch: None,
1432 tip: None,
1433 status: "stale",
1434 proof: None,
1435 proof_kind: None,
1436 detail: None,
1437 },
1438 ],
1439 next: vec![
1440 "rk worktree prune --apply verifies, then removes each worktree before its branch"
1441 .into(),
1442 ],
1443 };
1444 assert_eq!(
1445 serde_json::to_string(&populated).expect("a report serializes"),
1446 r##"{"schema":"rk.worktree-prune/2","mode":"verify","worktrees":[{"path":"/srv/widget@feat-x","branch":"feat/x","tip":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","status":"confirmed","proof":"#8","proof_kind":"request"},{"path":"/srv/widget@fix-y","status":"stale"}],"next":["rk worktree prune --apply verifies, then removes each worktree before its branch"]}"##
1447 );
1448 let clean = PruneReport {
1449 schema: "rk.worktree-prune/2",
1450 mode: "preview",
1451 worktrees: vec![],
1452 next: vec![
1453 "rk worktree prune --verify confirms each candidate against the forge".into(),
1454 ],
1455 };
1456 assert_eq!(
1457 serde_json::to_string(&clean).expect("a report serializes"),
1458 r#"{"schema":"rk.worktree-prune/2","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1459 "a clean clone reports one empty list a caller can branch on"
1460 );
1461 }
1462}