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 ) {
177 if !seats.contains(&seat) {
178 seats.push(seat);
179 }
180 }
181 seats
182}
183
184fn is_dirty(path: &Utf8Path) -> bool {
187 git(path, &["status", "--porcelain"]).map_or(true, |probed| {
188 !probed.status.success() || !probed.stdout.is_empty()
189 })
190}
191
192fn branch_inventory(target: &Utf8Path) -> Result<Vec<Branch>, RkError> {
195 let listed = git(
196 target,
197 &[
198 "for-each-ref",
199 "refs/heads",
200 "--format",
201 FOR_EACH_REF_FORMAT,
202 ],
203 )?;
204 if !listed.status.success() {
205 return Err(RkError::refusal(
206 Diagnostic::new(
207 Reason::PrerequisiteUnmet,
208 format!("target {target} is not a git repository"),
209 )
210 .expected("a repository whose branches git can list"),
211 ));
212 }
213 Ok(crate::branches::parse_branches(&String::from_utf8_lossy(
214 &listed.stdout,
215 )))
216}
217
218#[derive(Debug, Serialize)]
223struct ListRow {
224 path: String,
226 #[serde(skip_serializing_if = "Option::is_none")]
228 branch: Option<String>,
229 head: String,
231 kind: &'static str,
233 state: &'static str,
236 canonical: bool,
238}
239
240#[derive(Debug, Serialize)]
242struct ListReport {
243 schema: &'static str,
245 worktrees: Vec<ListRow>,
247 next: Vec<String>,
249}
250
251fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
253 let worktrees = inventory(target)?;
254 let layout = layout_of(&worktrees)?;
255 let rows: Vec<ListRow> = worktrees
256 .iter()
257 .enumerate()
258 .map(|(index, worktree)| {
259 let state = if worktree.locked.is_some() {
263 "locked"
264 } else if worktree.prunable.is_some() {
265 "missing"
266 } else if worktree.branch.is_none() {
267 "detached"
268 } else if is_dirty(&worktree.path) {
269 "dirty"
270 } else {
271 "clean"
272 };
273 let canonical = index == 0
274 || worktree
275 .branch
276 .as_deref()
277 .is_none_or(|branch| derived_path(&layout, branch) == worktree.path);
278 ListRow {
279 path: worktree.path.to_string(),
280 branch: worktree.branch.clone(),
281 head: worktree.head.clone(),
282 kind: if index == 0 { "main" } else { "linked" },
283 state,
284 canonical,
285 }
286 })
287 .collect();
288 let next = vec![
289 "rk worktree add <branch> creates or adopts a branch's worktree".to_owned(),
290 "rk worktree prune reports the worktrees a squash merge retired".to_owned(),
291 ];
292 out.result_line(format!(
293 "{} worktree{} of {}:",
294 rows.len(),
295 if rows.len() == 1 { "" } else { "s" },
296 layout.main
297 ));
298 let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
299 for row in &rows {
300 let head = row.head.get(..8).unwrap_or(&row.head);
301 let mut line = format!(
302 " {:width$} {head} {} {}",
303 row.path,
304 row.branch.as_deref().unwrap_or("(detached)"),
305 row.state
306 );
307 if !row.canonical {
308 if let Some(branch) = &row.branch {
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 }
318 out.result_line(line);
319 }
320 out.next(&next);
321 out.emit(&ListReport {
322 schema: "rk.worktree-list/1",
323 worktrees: rows,
324 next,
325 })
326}
327
328#[derive(Debug, Serialize)]
333struct AddReport {
334 schema: &'static str,
336 mode: &'static str,
338 branch: String,
340 path: String,
342 created: &'static str,
346 source: &'static str,
349 #[serde(skip_serializing_if = "Option::is_none")]
351 base: Option<String>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 upstream: Option<String>,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 detail: Option<String>,
358 next: Vec<String>,
360}
361
362pub(crate) struct Source {
364 pub(crate) kind: &'static str,
366 pub(crate) created: &'static str,
368 pub(crate) base: Option<String>,
370 pub(crate) upstream: Option<String>,
372 pub(crate) command: Vec<String>,
374}
375
376pub(crate) enum Seat {
378 Satisfied {
380 path: Utf8PathBuf,
382 },
383 Fresh {
385 path: Utf8PathBuf,
387 source: Source,
389 detail: Option<String>,
391 },
392}
393
394pub(crate) fn plan_seat(
406 target: &Utf8Path,
407 branch: &str,
408 base: Option<&str>,
409 apply: bool,
410) -> Result<Seat, RkError> {
411 let trunk = crate::config::trunk_of(target.as_std_path())?;
412 let worktrees = inventory(target)?;
413 let layout = layout_of(&worktrees)?;
414
415 if !matches_grammar(branch) {
419 return Err(RkError::Usage(format!(
420 "branch '{branch}' is none of the three forms — <type>/<slug>, <issue-id>-<slug>, or release/<line> — the landed grammar admits"
421 )));
422 }
423 let checked = git(target, &["check-ref-format", "--branch", branch])?;
424 if !checked.status.success() {
425 return Err(RkError::Usage(format!(
426 "git refuses the branch name '{branch}': {}",
427 last_line(&checked.stderr)
428 )));
429 }
430 if branch == trunk {
431 return Err(RkError::refusal(
432 Diagnostic::new(
433 Reason::PrerequisiteUnmet,
434 format!("{trunk} takes no worktree; the main checkout is its seat"),
435 )
436 .expected("a short-lived branch to seat")
437 .target_state("unchanged"),
438 ));
439 }
440 if let Some(base) = base {
441 if base.starts_with('-') {
442 return Err(RkError::Usage(format!(
443 "--base '{base}' is option-shaped; pass a commit-ish"
444 )));
445 }
446 }
447 let path = derived_path(&layout, branch);
448
449 let registered = worktrees
452 .iter()
453 .find(|worktree| worktree.branch.as_deref() == Some(branch));
454 if let Some(seat) = registered {
455 return judge_registered(seat, branch, &path, &layout.main);
456 }
457 if path.exists() {
458 let occupant = worktrees
459 .iter()
460 .find(|worktree| worktree.path == path)
461 .and_then(|worktree| worktree.branch.clone())
462 .map_or_else(
463 || "a directory this repository does not register".to_owned(),
464 |other| format!("the worktree of branch {other}"),
465 );
466 return Err(RkError::refusal(
467 Diagnostic::new(
468 Reason::StateDrift,
469 format!(
470 "{path} already exists as {occupant}; flattening is not injective and nothing is suffixed silently"
471 ),
472 )
473 .expected("the derived path free, or registered to this branch")
474 .target_state("unchanged"),
475 ));
476 }
477
478 let mut detail = None;
483 if apply {
484 let fetched = git(target, &["fetch", "origin"])?;
485 if !fetched.status.success() {
486 detail = Some(format!(
487 "the fetch failed ({}); the run proceeded on local refs",
488 last_line(&fetched.stderr)
489 ));
490 }
491 }
492 let source = resolve_source(target, branch, base, &path)?;
493 Ok(Seat::Fresh {
494 path,
495 source,
496 detail,
497 })
498}
499
500fn judge_registered(
504 seat: &Worktree,
505 branch: &str,
506 path: &Utf8Path,
507 main: &Utf8Path,
508) -> Result<Seat, RkError> {
509 let trunk = crate::config::trunk_of(main.as_std_path())?;
510 if seat.path == path {
511 if seat.prunable.is_some() || !path.is_dir() {
519 let recovery = if seat.locked.is_some() {
520 format!(
521 "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"
522 )
523 } else {
524 "rk worktree prune --apply clears the stale record, then re-run; git worktree repair recovers a moved directory instead".to_owned()
525 };
526 return Err(RkError::refusal(
527 Diagnostic::new(
528 Reason::StateDrift,
529 format!("{path} is registered to {branch} and its directory is missing"),
530 )
531 .expected("the canonical worktree standing, or its stale record cleared")
532 .action(recovery)
533 .target_state("unchanged"),
534 ));
535 }
536 return Ok(Seat::Satisfied {
537 path: path.to_owned(),
538 });
539 }
540 let recovery = if seat.path == main {
544 format!("git switch {trunk} there, then re-run")
545 } else {
546 format!("git worktree move {} {path}", seat.path)
547 };
548 Err(RkError::refusal(
549 Diagnostic::new(
550 Reason::StateDrift,
551 format!(
552 "branch {branch} is checked out at {}, and one branch has one seat",
553 seat.path
554 ),
555 )
556 .expected("the branch free, or already at its derived path")
557 .action(recovery)
558 .target_state("unchanged"),
559 ))
560}
561
562pub(crate) fn create_seat(target: &Utf8Path, source: &Source) -> Result<(), RkError> {
568 let argv: Vec<&str> = source.command.iter().map(String::as_str).collect();
569 let created = git(target, &argv)?;
570 if created.status.success() {
571 return Ok(());
572 }
573 Err(RkError::subprocess(
574 Diagnostic::new(
575 Reason::SubprocessFailed,
576 format!("git worktree add refused: {}", last_line(&created.stderr)),
577 )
578 .expected("the worktree created at the derived path")
579 .target_state("unchanged"),
580 ))
581}
582
583fn add(
585 target: &Utf8Path,
586 branch: &str,
587 base: Option<&str>,
588 apply: bool,
589 out: Output,
590) -> Result<(), RkError> {
591 let (path, source, detail) = match plan_seat(target, branch, base, apply)? {
592 Seat::Satisfied { path } => return report_satisfied(out, branch, &path, apply),
593 Seat::Fresh {
594 path,
595 source,
596 detail,
597 } => (path, source, detail),
598 };
599
600 if !apply {
601 out.result_line(format!(
602 "branch: {branch} ({})",
603 match source.kind {
604 "adopted" => "existing, adopted".to_owned(),
605 "remote" => format!(
606 "remote, from {}",
607 source.upstream.as_deref().unwrap_or("origin")
608 ),
609 _ => format!("new, from {}", source.base.as_deref().unwrap_or("?")),
610 }
611 ));
612 out.result_line(format!(
613 "path: ../{}",
614 path.file_name().unwrap_or_default()
615 ));
616 if let Some(base) = &source.base {
617 out.result_line(format!("base: {base}"));
618 }
619 out.result_line(format!("would run: git {}", source.command.join(" ")));
620 let base_flag = base.map_or_else(String::new, |base| format!(" --base {base}"));
621 let next = vec![format!(
622 "rk worktree add {branch}{base_flag} --target {target} --apply creates it; the apply refreshes the remote refs and re-resolves"
623 )];
624 out.next(&next);
625 return out.emit(&AddReport {
626 schema: "rk.worktree-add/1",
627 mode: "preview",
628 branch: branch.to_owned(),
629 path: path.to_string(),
630 created: source.created,
631 source: source.kind,
632 base: source.base,
633 upstream: source.upstream,
634 detail: Some(
635 "a preview decides from the local refs as they stand; apply refreshes and re-resolves"
636 .to_owned(),
637 ),
638 next,
639 });
640 }
641
642 create_seat(target, &source)?;
643 out.result_line(&path);
644 let next = vec![
645 format!("cd {path}"),
646 "rk worktree list reports every seat".to_owned(),
647 ];
648 out.next(&next);
649 out.emit(&AddReport {
650 schema: "rk.worktree-add/1",
651 mode: "apply",
652 branch: branch.to_owned(),
653 path: path.to_string(),
654 created: source.created,
655 source: source.kind,
656 base: source.base,
657 upstream: source.upstream,
658 detail,
659 next,
660 })
661}
662
663fn report_satisfied(
665 out: Output,
666 branch: &str,
667 path: &Utf8Path,
668 apply: bool,
669) -> Result<(), RkError> {
670 out.result_line(format!("{path} already seats {branch}; nothing to create"));
671 let next = vec![format!("cd {path}")];
672 out.next(&next);
673 out.emit(&AddReport {
674 schema: "rk.worktree-add/1",
675 mode: if apply { "apply" } else { "preview" },
676 branch: branch.to_owned(),
677 path: path.to_string(),
678 created: "nothing",
679 source: "adopted",
680 base: None,
681 upstream: None,
682 detail: None,
683 next,
684 })
685}
686
687fn resolve_source(
696 target: &Utf8Path,
697 branch: &str,
698 base: Option<&str>,
699 path: &Utf8Path,
700) -> Result<Source, RkError> {
701 let trunk = crate::config::trunk_of(target.as_std_path())?;
702 let resolve = |name: &str| -> Result<Option<String>, RkError> {
703 let resolved = git(
704 target,
705 &[
706 "rev-parse",
707 "--verify",
708 "--quiet",
709 "--end-of-options",
710 &format!("{name}^{{commit}}"),
711 ],
712 )?;
713 Ok(resolved
714 .status
715 .success()
716 .then(|| String::from_utf8_lossy(&resolved.stdout).trim().to_owned()))
717 };
718
719 if resolve(&format!("refs/heads/{branch}"))?.is_some() {
722 return Ok(Source {
723 kind: "adopted",
724 created: "worktree",
725 base: None,
726 upstream: None,
727 command: vec![
728 "worktree".into(),
729 "add".into(),
730 path.to_string(),
731 branch.to_owned(),
732 ],
733 });
734 }
735
736 let remote_ref = format!("refs/remotes/origin/{branch}");
739 if resolve(&remote_ref)?.is_some() {
740 return Ok(Source {
741 kind: "remote",
742 created: "branch",
743 base: Some(format!("origin/{branch}")),
744 upstream: Some(format!("origin/{branch}")),
745 command: vec![
746 "worktree".into(),
747 "add".into(),
748 "--track".into(),
749 "-b".into(),
750 branch.to_owned(),
751 path.to_string(),
752 remote_ref,
753 ],
754 });
755 }
756
757 if branch.starts_with(crate::branches::PROTECTED_PREFIX) && base.is_none() {
761 return Err(RkError::refusal(
762 Diagnostic::new(
763 Reason::PrerequisiteUnmet,
764 format!(
765 "release line {branch} takes an explicit --base; a line is cut from a tag, never the tip"
766 ),
767 )
768 .expected("--base \"v<version>\" naming the tag the line patches")
769 .target_state("unchanged"),
770 ));
771 }
772 let (kind, shown) = base.map_or_else(
773 || ("trunk", format!("origin/{trunk}")),
774 |base| ("base", base.to_owned()),
775 );
776 let resolved = match resolve(&shown)? {
777 Some(oid) => Some(oid),
778 None if kind == "trunk" => resolve(&trunk)?,
780 None => None,
781 };
782 let oid = resolved.ok_or_else(|| {
783 RkError::refusal(
784 Diagnostic::new(
785 Reason::PrerequisiteUnmet,
786 format!("{shown} does not resolve to a commit"),
787 )
788 .expected("a commit-ish the new branch can start from")
789 .target_state("unchanged"),
790 )
791 })?;
792 Ok(Source {
793 kind,
794 created: "branch",
795 base: Some(shown),
796 upstream: None,
797 command: vec![
798 "worktree".into(),
799 "add".into(),
800 path.to_string(),
801 "-b".into(),
802 branch.to_owned(),
803 oid,
804 ],
805 })
806}
807
808#[derive(Debug, Serialize)]
813struct PruneRow {
814 path: String,
816 #[serde(skip_serializing_if = "Option::is_none")]
818 branch: Option<String>,
819 #[serde(skip_serializing_if = "Option::is_none")]
821 tip: Option<String>,
822 status: &'static str,
825 #[serde(skip_serializing_if = "Option::is_none")]
827 request: Option<String>,
828 #[serde(skip_serializing_if = "Option::is_none")]
830 detail: Option<String>,
831}
832
833impl PruneRow {
834 fn describe(&self) -> String {
836 match self.status {
837 "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
838 "stale" => {
839 "stale: the registered directory is missing; apply clears the record".to_owned()
840 }
841 "confirmed" => format!(
842 "confirmed: merged request {} matches this tip",
843 self.request.as_deref().unwrap_or("")
844 ),
845 "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
846 "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
847 "pruned" => {
848 let mut line = self.request.as_deref().map_or_else(
849 || "pruned".to_owned(),
850 |request| format!("pruned (merged request {request})"),
851 );
852 if let Some(detail) = &self.detail {
853 line.push_str("; ");
854 line.push_str(detail);
855 }
856 line
857 }
858 "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
859 "branch-delete-failed" => format!(
860 "branch delete failed: {}",
861 self.detail.as_deref().unwrap_or("")
862 ),
863 _ => "candidate".to_owned(),
864 }
865 }
866}
867
868#[derive(Debug, Serialize)]
870struct PruneReport {
871 schema: &'static str,
873 mode: &'static str,
875 worktrees: Vec<PruneRow>,
877 next: Vec<String>,
879}
880
881struct Judged {
883 worktree: Worktree,
884 tip: Option<String>,
886 class: WtClass,
887}
888
889#[allow(
894 clippy::too_many_lines,
895 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"
896)]
897fn prune(
898 target: &Utf8Path,
899 repo_flag: Option<&str>,
900 forge_flag: Option<&str>,
901 verify: bool,
902 apply: bool,
903 quiet: bool,
904 out: Output,
905) -> Result<(), RkError> {
906 let trunk = crate::config::trunk_of(target.as_std_path())?;
907 let worktrees = inventory(target)?;
908 let layout = layout_of(&worktrees)?;
909 let branches = branch_inventory(target)?;
910 if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
914 return Err(RkError::refusal(
915 Diagnostic::new(
916 Reason::PrerequisiteUnmet,
917 "the branch inventory did not parse, and no worktree is judged without its branch observation",
918 )
919 .expected("a branch listing covering the checked-out branches")
920 .target_state("unchanged"),
921 ));
922 }
923 let seat_paths = seats(target);
924 let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
925
926 let mut judged: Vec<Judged> = Vec::new();
930 for worktree in worktrees.iter().skip(1) {
931 let observation = worktree
932 .branch
933 .as_deref()
934 .and_then(|name| branches.iter().find(|branch| branch.name == name));
935 let reportable = worktree.prunable.is_some()
936 || worktree
937 .branch
938 .as_deref()
939 .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
940 if !reportable {
941 continue;
942 }
943 let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
944 let class = classify(worktree, observation, &layout, &seat_refs, &trunk, dirty);
945 judged.push(Judged {
946 worktree: worktree.clone(),
947 tip: observation.map(|branch| branch.tip.clone()),
948 class,
949 });
950 }
951
952 if (verify || apply)
954 && judged
955 .iter()
956 .any(|row| matches!(row.class, WtClass::Candidate))
957 {
958 let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
959 let forge = Forge::parse(&resolved.forge)
960 .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
961 let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
962 let cli = resolve_cli(forge)?;
963 for row in &mut judged {
964 if matches!(row.class, WtClass::Candidate) {
965 let Some(tip) = row.tip.as_deref() else {
966 continue;
967 };
968 row.class = WtClass::Judged(merged_request_for(
969 &cli,
970 target.as_std_path(),
971 forge,
972 &repo,
973 tip,
974 ));
975 }
976 }
977 }
978
979 let mut rows: Vec<PruneRow> = judged
980 .iter()
981 .map(|row| {
982 let (status, request, detail) = match &row.class {
983 WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
984 WtClass::Candidate => ("candidate", None, None),
985 WtClass::Stale => ("stale", None, None),
986 WtClass::Judged(Class::Confirmed { request }) => {
987 ("confirmed", Some(request.clone()), None)
988 }
989 WtClass::Judged(Class::Unconfirmed { detail }) => {
990 ("unconfirmed", None, Some(detail.clone()))
991 }
992 WtClass::Judged(Class::Unknown { detail }) => {
993 ("unknown", None, Some(detail.clone()))
994 }
995 WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
996 };
997 PruneRow {
998 path: row.worktree.path.to_string(),
999 branch: row.worktree.branch.clone(),
1000 tip: row.tip.clone(),
1001 status,
1002 request,
1003 detail,
1004 }
1005 })
1006 .collect();
1007
1008 let mut failures = 0usize;
1009 if apply {
1010 for row in &mut rows {
1011 if row.status != "confirmed" {
1012 continue;
1013 }
1014 if let Err(count) = retire(target, row) {
1015 failures += count;
1016 }
1017 }
1018 failures += sweep_stale(target, &mut rows)?;
1019 }
1020
1021 let mode = if apply {
1022 "apply"
1023 } else if verify {
1024 "verify"
1025 } else {
1026 "preview"
1027 };
1028 let next = next_lines(mode);
1029 render(out, &rows, &next, quiet);
1030 out.emit(&PruneReport {
1031 schema: "rk.worktree-prune/1",
1032 mode,
1033 worktrees: rows,
1034 next,
1035 })?;
1036 if failures > 0 {
1037 return Err(RkError::subprocess(
1038 Diagnostic::new(
1039 Reason::SubprocessFailed,
1040 format!("git refused {failures} cleanup actions"),
1041 )
1042 .expected("every confirmed worktree removed; the report names each outcome"),
1043 ));
1044 }
1045 Ok(())
1046}
1047
1048fn retire(target: &Utf8Path, row: &mut PruneRow) -> Result<(), usize> {
1054 let Some(branch) = row.branch.clone() else {
1055 return Ok(());
1056 };
1057 let Some(tip) = row.tip.clone() else {
1058 return Ok(());
1059 };
1060 let keep = |row: &mut PruneRow, moved: &str| {
1061 row.status = "kept";
1062 row.detail = Some(format!(
1063 "{moved} after verification; rk worktree prune --verify re-confirms"
1064 ));
1065 };
1066 let reread = git(
1067 target,
1068 &[
1069 "for-each-ref",
1070 &format!("refs/heads/{branch}"),
1071 "--format",
1072 "%(objectname)",
1073 ],
1074 )
1075 .map_err(|_| 1usize)?;
1076 let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
1077 if !reread.status.success() || fresh_tip != tip {
1078 keep(row, "the tip moved");
1079 return Ok(());
1080 }
1081 let path = Utf8PathBuf::from(&row.path);
1085 let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
1086 if !fresh.status.success() {
1087 keep(row, "the worktree inventory could not be re-read");
1088 return Ok(());
1089 }
1090 let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
1091 keep(row, "the worktree inventory could not be re-read");
1092 return Ok(());
1093 };
1094 let seat = inventory.iter().find(|worktree| worktree.path == path);
1095 if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
1096 keep(row, &reason);
1097 return Ok(());
1098 }
1099 if is_dirty(&path) {
1100 keep(row, "uncommitted changes arrived");
1101 return Ok(());
1102 }
1103 let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
1104 if !removed.status.success() {
1105 row.status = "remove-failed";
1106 row.detail = Some(format!(
1107 "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
1108 last_line(&removed.stderr)
1109 ));
1110 return Err(1);
1111 }
1112 match maintenance::delete_branch(target, &branch, &tip) {
1113 maintenance::Deletion::Deleted => {
1114 row.status = "pruned";
1115 Ok(())
1116 }
1117 maintenance::Deletion::ConfigSurvived { detail } => {
1118 row.status = "pruned";
1119 row.detail = Some(detail);
1120 Ok(())
1121 }
1122 maintenance::Deletion::Refused { detail } => {
1123 row.status = "branch-delete-failed";
1126 row.detail = Some(format!(
1127 "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1128 ));
1129 Err(1)
1130 }
1131 }
1132}
1133
1134fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1141 if !rows.iter().any(|row| row.status == "stale") {
1142 return Ok(0);
1143 }
1144 let mut failures = 0usize;
1145 let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1146 let survivors: Option<Vec<Utf8PathBuf>> =
1150 git(target, &["worktree", "list", "--porcelain", "-z"])
1151 .ok()
1152 .filter(|fresh| fresh.status.success())
1153 .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1154 .map(|inventory| {
1155 inventory
1156 .into_iter()
1157 .map(|worktree| worktree.path)
1158 .collect()
1159 });
1160 for row in rows.iter_mut().filter(|row| row.status == "stale") {
1161 let survived = survivors
1162 .as_ref()
1163 .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1164 if survived {
1165 row.status = "remove-failed";
1166 row.detail = Some(if survivors.is_none() {
1167 "the record's fate could not be observed; re-run rk worktree prune --apply"
1168 .to_owned()
1169 } else if swept.status.success() {
1170 "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1171 } else {
1172 format!(
1173 "{}; re-run rk worktree prune --apply",
1174 last_line(&swept.stderr)
1175 )
1176 });
1177 failures += 1;
1178 } else {
1179 row.status = "pruned";
1180 }
1181 }
1182 if !swept.status.success() && failures == 0 {
1183 failures = 1;
1184 }
1185 Ok(failures)
1186}
1187
1188fn next_lines(mode: &str) -> Vec<String> {
1190 let verify = "rk worktree prune --verify confirms each candidate against the forge";
1191 let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1192 match mode {
1193 "preview" => vec![verify.to_owned(), apply.to_owned()],
1194 "verify" => vec![apply.to_owned()],
1195 _ => Vec::new(),
1196 }
1197}
1198
1199fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1204 if quiet && rows.is_empty() {
1205 return;
1206 }
1207 if rows.is_empty() {
1208 out.result_line("no worktree needs cleanup");
1209 } else {
1210 out.result_line(header(rows.len()));
1211 let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1212 for row in rows {
1213 let tip = row
1214 .tip
1215 .as_deref()
1216 .map_or(" ", |tip| tip.get(..8).unwrap_or(tip));
1217 out.result_line(format!(" {:width$} {tip} {}", row.path, row.describe()));
1218 }
1219 }
1220 out.next(next);
1221 if rows
1222 .iter()
1223 .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1224 {
1225 out.result_line(OPERATOR_LINE);
1226 }
1227}
1228
1229fn header(count: usize) -> String {
1231 if count == 1 {
1232 "1 worktree reports cleanup (a candidate, not proof):".to_owned()
1233 } else {
1234 format!("{count} worktrees report cleanup (a candidate, not proof):")
1235 }
1236}
1237
1238pub(crate) fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1249 let mut command = std::process::Command::new(crate::probes::git_bin());
1250 for var in maintenance::GIT_HOOK_VARS {
1251 command.env_remove(var);
1252 }
1253 command
1254 .arg("-C")
1255 .arg(target.as_std_path())
1256 .args(args)
1257 .output()
1258 .map_err(|source| {
1259 RkError::subprocess(
1260 Diagnostic::new(
1261 Reason::SubprocessSpawn,
1262 format!("git did not run: {source}"),
1263 )
1264 .expected("git installed and on PATH"),
1265 )
1266 })
1267}
1268
1269fn last_line(bytes: &[u8]) -> String {
1271 maintenance::last_line(bytes)
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276 use super::{ListReport, ListRow, PruneReport, PruneRow};
1277
1278 #[test]
1281 fn the_worktree_list_schema_snapshot_holds() {
1282 let populated = ListReport {
1283 schema: "rk.worktree-list/1",
1284 worktrees: vec![
1285 ListRow {
1286 path: "/srv/widget".into(),
1287 branch: Some("master".into()),
1288 head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1289 kind: "main",
1290 state: "clean",
1291 canonical: true,
1292 },
1293 ListRow {
1294 path: "/srv/elsewhere".into(),
1295 branch: None,
1296 head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1297 kind: "linked",
1298 state: "detached",
1299 canonical: true,
1300 },
1301 ],
1302 next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1303 };
1304 assert_eq!(
1305 serde_json::to_string(&populated).expect("a report serializes"),
1306 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"]}"#,
1307 "a detached row must omit branch rather than serializing null"
1308 );
1309 }
1310
1311 #[test]
1316 fn the_worktree_add_schema_snapshot_holds() {
1317 let apply = super::AddReport {
1318 schema: "rk.worktree-add/1",
1319 mode: "apply",
1320 branch: "feat/x".into(),
1321 path: "/srv/widget@feat-x".into(),
1322 created: "branch",
1323 source: "remote",
1324 base: Some("origin/feat/x".into()),
1325 upstream: Some("origin/feat/x".into()),
1326 detail: Some("the fetch failed; the run proceeded on local refs".into()),
1327 next: vec!["cd /srv/widget@feat-x".into()],
1328 };
1329 assert_eq!(
1330 serde_json::to_string(&apply).expect("a report serializes"),
1331 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"]}"#
1332 );
1333 let preview = super::AddReport {
1334 mode: "preview",
1335 created: "nothing",
1336 source: "adopted",
1337 base: None,
1338 upstream: None,
1339 detail: None,
1340 ..apply
1341 };
1342 assert_eq!(
1343 serde_json::to_string(&preview).expect("a report serializes"),
1344 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"]}"#,
1345 "an absent option must be omitted rather than serializing null"
1346 );
1347 }
1348
1349 #[test]
1352 fn the_worktree_prune_schema_snapshot_holds() {
1353 let populated = PruneReport {
1354 schema: "rk.worktree-prune/1",
1355 mode: "verify",
1356 worktrees: vec![
1357 PruneRow {
1358 path: "/srv/widget@feat-x".into(),
1359 branch: Some("feat/x".into()),
1360 tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1361 status: "confirmed",
1362 request: Some("#8".into()),
1363 detail: None,
1364 },
1365 PruneRow {
1366 path: "/srv/widget@fix-y".into(),
1367 branch: None,
1368 tip: None,
1369 status: "stale",
1370 request: None,
1371 detail: None,
1372 },
1373 ],
1374 next: vec![
1375 "rk worktree prune --apply verifies, then removes each worktree before its branch"
1376 .into(),
1377 ],
1378 };
1379 assert_eq!(
1380 serde_json::to_string(&populated).expect("a report serializes"),
1381 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"]}"##
1382 );
1383 let clean = PruneReport {
1384 schema: "rk.worktree-prune/1",
1385 mode: "preview",
1386 worktrees: vec![],
1387 next: vec![
1388 "rk worktree prune --verify confirms each candidate against the forge".into(),
1389 ],
1390 };
1391 assert_eq!(
1392 serde_json::to_string(&clean).expect("a report serializes"),
1393 r#"{"schema":"rk.worktree-prune/1","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1394 "a clean clone reports one empty list a caller can branch on"
1395 );
1396 }
1397}