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::{TRUNK_BRANCH, 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
115fn 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
153fn 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
161fn 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#[derive(Debug, Serialize)]
192struct ListRow {
193 path: String,
195 #[serde(skip_serializing_if = "Option::is_none")]
197 branch: Option<String>,
198 head: String,
200 kind: &'static str,
202 state: &'static str,
205 canonical: bool,
207}
208
209#[derive(Debug, Serialize)]
211struct ListReport {
212 schema: &'static str,
214 worktrees: Vec<ListRow>,
216 next: Vec<String>,
218}
219
220fn 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 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#[derive(Debug, Serialize)]
302struct AddReport {
303 schema: &'static str,
305 mode: &'static str,
307 branch: String,
309 path: String,
311 created: &'static str,
315 source: &'static str,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 base: Option<String>,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 upstream: Option<String>,
324 #[serde(skip_serializing_if = "Option::is_none")]
326 detail: Option<String>,
327 next: Vec<String>,
329}
330
331struct Source {
333 kind: &'static str,
335 created: &'static str,
337 base: Option<String>,
339 upstream: Option<String>,
341 command: Vec<String>,
343}
344
345#[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 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 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 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 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
553fn 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
577fn 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 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 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 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 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#[derive(Debug, Serialize)]
702struct PruneRow {
703 path: String,
705 #[serde(skip_serializing_if = "Option::is_none")]
707 branch: Option<String>,
708 #[serde(skip_serializing_if = "Option::is_none")]
710 tip: Option<String>,
711 status: &'static str,
714 #[serde(skip_serializing_if = "Option::is_none")]
716 request: Option<String>,
717 #[serde(skip_serializing_if = "Option::is_none")]
719 detail: Option<String>,
720}
721
722impl PruneRow {
723 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#[derive(Debug, Serialize)]
759struct PruneReport {
760 schema: &'static str,
762 mode: &'static str,
764 worktrees: Vec<PruneRow>,
766 next: Vec<String>,
768}
769
770struct Judged {
772 worktree: Worktree,
773 tip: Option<String>,
775 class: WtClass,
776}
777
778#[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 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 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 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
940fn 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 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 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
1026fn 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 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
1080fn 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
1091fn 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
1121fn 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
1130fn 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
1154fn 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 #[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 #[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 #[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}