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 mut seats = Vec::new();
129 if let Some(seat) = toplevel(
130 std::process::Command::new("git")
131 .args(["rev-parse", "--show-toplevel"])
132 .output(),
133 ) {
134 seats.push(seat);
135 }
136 if let Some(seat) = toplevel(
137 std::process::Command::new("git")
138 .arg("-C")
139 .arg(target.as_std_path())
140 .args(["rev-parse", "--show-toplevel"])
141 .output(),
142 ) {
143 if !seats.contains(&seat) {
144 seats.push(seat);
145 }
146 }
147 seats
148}
149
150fn is_dirty(path: &Utf8Path) -> bool {
153 git(path, &["status", "--porcelain"]).map_or(true, |probed| {
154 !probed.status.success() || !probed.stdout.is_empty()
155 })
156}
157
158fn branch_inventory(target: &Utf8Path) -> Result<Vec<Branch>, RkError> {
161 let listed = git(
162 target,
163 &[
164 "for-each-ref",
165 "refs/heads",
166 "--format",
167 FOR_EACH_REF_FORMAT,
168 ],
169 )?;
170 if !listed.status.success() {
171 return Err(RkError::refusal(
172 Diagnostic::new(
173 Reason::PrerequisiteUnmet,
174 format!("target {target} is not a git repository"),
175 )
176 .expected("a repository whose branches git can list"),
177 ));
178 }
179 Ok(crate::branches::parse_branches(&String::from_utf8_lossy(
180 &listed.stdout,
181 )))
182}
183
184#[derive(Debug, Serialize)]
189struct ListRow {
190 path: String,
192 #[serde(skip_serializing_if = "Option::is_none")]
194 branch: Option<String>,
195 head: String,
197 kind: &'static str,
199 state: &'static str,
202 canonical: bool,
204}
205
206#[derive(Debug, Serialize)]
208struct ListReport {
209 schema: &'static str,
211 worktrees: Vec<ListRow>,
213 next: Vec<String>,
215}
216
217fn list(target: &Utf8Path, out: Output) -> Result<(), RkError> {
219 let worktrees = inventory(target)?;
220 let layout = layout_of(&worktrees)?;
221 let rows: Vec<ListRow> = worktrees
222 .iter()
223 .enumerate()
224 .map(|(index, worktree)| {
225 let state = if worktree.locked.is_some() {
229 "locked"
230 } else if worktree.prunable.is_some() {
231 "missing"
232 } else if worktree.branch.is_none() {
233 "detached"
234 } else if is_dirty(&worktree.path) {
235 "dirty"
236 } else {
237 "clean"
238 };
239 let canonical = index == 0
240 || worktree
241 .branch
242 .as_deref()
243 .is_none_or(|branch| derived_path(&layout, branch) == worktree.path);
244 ListRow {
245 path: worktree.path.to_string(),
246 branch: worktree.branch.clone(),
247 head: worktree.head.clone(),
248 kind: if index == 0 { "main" } else { "linked" },
249 state,
250 canonical,
251 }
252 })
253 .collect();
254 let next = vec![
255 "rk worktree add <branch> creates or adopts a branch's worktree".to_owned(),
256 "rk worktree prune reports the worktrees a squash merge retired".to_owned(),
257 ];
258 out.result_line(format!(
259 "{} worktree{} of {}:",
260 rows.len(),
261 if rows.len() == 1 { "" } else { "s" },
262 layout.main
263 ));
264 let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
265 for row in &rows {
266 let head = row.head.get(..8).unwrap_or(&row.head);
267 let mut line = format!(
268 " {:width$} {head} {} {}",
269 row.path,
270 row.branch.as_deref().unwrap_or("(detached)"),
271 row.state
272 );
273 if !row.canonical {
274 if let Some(branch) = &row.branch {
275 use std::fmt::Write as _;
276 let expected = derived_path(&layout, branch);
277 let _ = write!(
278 line,
279 " off-path: expected ../{}",
280 expected.file_name().unwrap_or_default()
281 );
282 }
283 }
284 out.result_line(line);
285 }
286 out.next(&next);
287 out.emit(&ListReport {
288 schema: "rk.worktree-list/1",
289 worktrees: rows,
290 next,
291 })
292}
293
294#[derive(Debug, Serialize)]
299struct AddReport {
300 schema: &'static str,
302 mode: &'static str,
304 branch: String,
306 path: String,
308 created: &'static str,
312 source: &'static str,
315 #[serde(skip_serializing_if = "Option::is_none")]
317 base: Option<String>,
318 #[serde(skip_serializing_if = "Option::is_none")]
320 upstream: Option<String>,
321 #[serde(skip_serializing_if = "Option::is_none")]
323 detail: Option<String>,
324 next: Vec<String>,
326}
327
328struct Source {
330 kind: &'static str,
332 created: &'static str,
334 base: Option<String>,
336 upstream: Option<String>,
338 command: Vec<String>,
340}
341
342#[allow(clippy::too_many_lines)]
344fn add(
345 target: &Utf8Path,
346 branch: &str,
347 base: Option<&str>,
348 apply: bool,
349 out: Output,
350) -> Result<(), RkError> {
351 let worktrees = inventory(target)?;
352 let layout = layout_of(&worktrees)?;
353
354 if !matches_grammar(branch) {
358 return Err(RkError::Usage(format!(
359 "branch '{branch}' is none of the three forms — <type>/<slug>, <issue-id>-<slug>, or release/<line> — the landed grammar admits"
360 )));
361 }
362 let checked = git(target, &["check-ref-format", "--branch", branch])?;
363 if !checked.status.success() {
364 return Err(RkError::Usage(format!(
365 "git refuses the branch name '{branch}': {}",
366 last_line(&checked.stderr)
367 )));
368 }
369 if branch == TRUNK_BRANCH {
370 return Err(RkError::refusal(
371 Diagnostic::new(
372 Reason::PrerequisiteUnmet,
373 format!("{TRUNK_BRANCH} takes no worktree; the main checkout is its seat"),
374 )
375 .expected("a short-lived branch to seat")
376 .target_state("unchanged"),
377 ));
378 }
379 if let Some(base) = base {
380 if base.starts_with('-') {
381 return Err(RkError::Usage(format!(
382 "--base '{base}' is option-shaped; pass a commit-ish"
383 )));
384 }
385 }
386 let path = derived_path(&layout, branch);
387
388 let registered = worktrees
391 .iter()
392 .find(|worktree| worktree.branch.as_deref() == Some(branch));
393 if let Some(seat) = registered {
394 if seat.path == path {
395 if seat.prunable.is_some() || !path.is_dir() {
403 let recovery = if seat.locked.is_some() {
404 format!(
405 "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"
406 )
407 } else {
408 "rk worktree prune --apply clears the stale record, then re-run; git worktree repair recovers a moved directory instead".to_owned()
409 };
410 return Err(RkError::refusal(
411 Diagnostic::new(
412 Reason::StateDrift,
413 format!("{path} is registered to {branch} and its directory is missing"),
414 )
415 .expected("the canonical worktree standing, or its stale record cleared")
416 .action(recovery)
417 .target_state("unchanged"),
418 ));
419 }
420 return report_satisfied(out, branch, &path, apply);
421 }
422 let move_hint = if seat.path == layout.main {
423 format!("; git switch {TRUNK_BRANCH} there, then re-run")
424 } else {
425 String::new()
426 };
427 return Err(RkError::refusal(
428 Diagnostic::new(
429 Reason::StateDrift,
430 format!(
431 "branch {branch} is checked out at {}, and one branch has one seat{move_hint}",
432 seat.path
433 ),
434 )
435 .expected("the branch free, or already at its derived path")
436 .target_state("unchanged"),
437 ));
438 }
439 if path.exists() {
440 let occupant = worktrees
441 .iter()
442 .find(|worktree| worktree.path == path)
443 .and_then(|worktree| worktree.branch.clone())
444 .map_or_else(
445 || "a directory this repository does not register".to_owned(),
446 |other| format!("the worktree of branch {other}"),
447 );
448 return Err(RkError::refusal(
449 Diagnostic::new(
450 Reason::StateDrift,
451 format!(
452 "{path} already exists as {occupant}; flattening is not injective and nothing is suffixed silently"
453 ),
454 )
455 .expected("the derived path free, or registered to this branch")
456 .target_state("unchanged"),
457 ));
458 }
459
460 let mut detail = None;
465 if apply {
466 let fetched = git(target, &["fetch", "origin"])?;
467 if !fetched.status.success() {
468 detail = Some(format!(
469 "the fetch failed ({}); the run proceeded on local refs",
470 last_line(&fetched.stderr)
471 ));
472 }
473 }
474 let source = resolve_source(target, branch, base, &path)?;
475
476 if !apply {
477 out.result_line(format!(
478 "branch: {branch} ({})",
479 match source.kind {
480 "adopted" => "existing, adopted".to_owned(),
481 "remote" => format!(
482 "remote, from {}",
483 source.upstream.as_deref().unwrap_or("origin")
484 ),
485 _ => format!("new, from {}", source.base.as_deref().unwrap_or("?")),
486 }
487 ));
488 out.result_line(format!(
489 "path: ../{}",
490 path.file_name().unwrap_or_default()
491 ));
492 if let Some(base) = &source.base {
493 out.result_line(format!("base: {base}"));
494 }
495 out.result_line(format!("would run: git {}", source.command.join(" ")));
496 let base_flag = base.map_or_else(String::new, |base| format!(" --base {base}"));
497 let next = vec![format!(
498 "rk worktree add {branch}{base_flag} --target {target} --apply creates it; the apply refreshes the remote refs and re-resolves"
499 )];
500 out.next(&next);
501 return out.emit(&AddReport {
502 schema: "rk.worktree-add/1",
503 mode: "preview",
504 branch: branch.to_owned(),
505 path: path.to_string(),
506 created: source.created,
507 source: source.kind,
508 base: source.base,
509 upstream: source.upstream,
510 detail: Some(
511 "a preview decides from the local refs as they stand; apply refreshes and re-resolves"
512 .to_owned(),
513 ),
514 next,
515 });
516 }
517
518 let argv: Vec<&str> = source.command.iter().map(String::as_str).collect();
519 let created = git(target, &argv)?;
520 if !created.status.success() {
521 return Err(RkError::subprocess(
522 Diagnostic::new(
523 Reason::SubprocessFailed,
524 format!("git worktree add refused: {}", last_line(&created.stderr)),
525 )
526 .expected("the worktree created at the derived path")
527 .target_state("unchanged"),
528 ));
529 }
530 out.result_line(&path);
531 let next = vec![
532 format!("cd {path}"),
533 "rk worktree list reports every seat".to_owned(),
534 ];
535 out.next(&next);
536 out.emit(&AddReport {
537 schema: "rk.worktree-add/1",
538 mode: "apply",
539 branch: branch.to_owned(),
540 path: path.to_string(),
541 created: source.created,
542 source: source.kind,
543 base: source.base,
544 upstream: source.upstream,
545 detail,
546 next,
547 })
548}
549
550fn report_satisfied(
552 out: Output,
553 branch: &str,
554 path: &Utf8Path,
555 apply: bool,
556) -> Result<(), RkError> {
557 out.result_line(format!("{path} already seats {branch}; nothing to create"));
558 let next = vec![format!("cd {path}")];
559 out.next(&next);
560 out.emit(&AddReport {
561 schema: "rk.worktree-add/1",
562 mode: if apply { "apply" } else { "preview" },
563 branch: branch.to_owned(),
564 path: path.to_string(),
565 created: "nothing",
566 source: "adopted",
567 base: None,
568 upstream: None,
569 detail: None,
570 next,
571 })
572}
573
574fn resolve_source(
583 target: &Utf8Path,
584 branch: &str,
585 base: Option<&str>,
586 path: &Utf8Path,
587) -> Result<Source, RkError> {
588 let resolve = |name: &str| -> Result<Option<String>, RkError> {
589 let resolved = git(
590 target,
591 &[
592 "rev-parse",
593 "--verify",
594 "--quiet",
595 "--end-of-options",
596 &format!("{name}^{{commit}}"),
597 ],
598 )?;
599 Ok(resolved
600 .status
601 .success()
602 .then(|| String::from_utf8_lossy(&resolved.stdout).trim().to_owned()))
603 };
604
605 if resolve(&format!("refs/heads/{branch}"))?.is_some() {
608 return Ok(Source {
609 kind: "adopted",
610 created: "worktree",
611 base: None,
612 upstream: None,
613 command: vec![
614 "worktree".into(),
615 "add".into(),
616 path.to_string(),
617 branch.to_owned(),
618 ],
619 });
620 }
621
622 let remote_ref = format!("refs/remotes/origin/{branch}");
625 if resolve(&remote_ref)?.is_some() {
626 return Ok(Source {
627 kind: "remote",
628 created: "branch",
629 base: Some(format!("origin/{branch}")),
630 upstream: Some(format!("origin/{branch}")),
631 command: vec![
632 "worktree".into(),
633 "add".into(),
634 "--track".into(),
635 "-b".into(),
636 branch.to_owned(),
637 path.to_string(),
638 remote_ref,
639 ],
640 });
641 }
642
643 if branch.starts_with(crate::branches::PROTECTED_PREFIX) && base.is_none() {
647 return Err(RkError::refusal(
648 Diagnostic::new(
649 Reason::PrerequisiteUnmet,
650 format!(
651 "release line {branch} takes an explicit --base; a line is cut from a tag, never the tip"
652 ),
653 )
654 .expected("--base \"v<version>\" naming the tag the line patches")
655 .target_state("unchanged"),
656 ));
657 }
658 let (kind, shown) = base.map_or_else(
659 || ("trunk", format!("origin/{TRUNK_BRANCH}")),
660 |base| ("base", base.to_owned()),
661 );
662 let resolved = match resolve(&shown)? {
663 Some(oid) => Some(oid),
664 None if kind == "trunk" => resolve(TRUNK_BRANCH)?,
666 None => None,
667 };
668 let oid = resolved.ok_or_else(|| {
669 RkError::refusal(
670 Diagnostic::new(
671 Reason::PrerequisiteUnmet,
672 format!("{shown} does not resolve to a commit"),
673 )
674 .expected("a commit-ish the new branch can start from")
675 .target_state("unchanged"),
676 )
677 })?;
678 Ok(Source {
679 kind,
680 created: "branch",
681 base: Some(shown),
682 upstream: None,
683 command: vec![
684 "worktree".into(),
685 "add".into(),
686 path.to_string(),
687 "-b".into(),
688 branch.to_owned(),
689 oid,
690 ],
691 })
692}
693
694#[derive(Debug, Serialize)]
699struct PruneRow {
700 path: String,
702 #[serde(skip_serializing_if = "Option::is_none")]
704 branch: Option<String>,
705 #[serde(skip_serializing_if = "Option::is_none")]
707 tip: Option<String>,
708 status: &'static str,
711 #[serde(skip_serializing_if = "Option::is_none")]
713 request: Option<String>,
714 #[serde(skip_serializing_if = "Option::is_none")]
716 detail: Option<String>,
717}
718
719impl PruneRow {
720 fn describe(&self) -> String {
722 match self.status {
723 "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
724 "stale" => {
725 "stale: the registered directory is missing; apply clears the record".to_owned()
726 }
727 "confirmed" => format!(
728 "confirmed: merged request {} matches this tip",
729 self.request.as_deref().unwrap_or("")
730 ),
731 "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
732 "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
733 "pruned" => {
734 let mut line = self.request.as_deref().map_or_else(
735 || "pruned".to_owned(),
736 |request| format!("pruned (merged request {request})"),
737 );
738 if let Some(detail) = &self.detail {
739 line.push_str("; ");
740 line.push_str(detail);
741 }
742 line
743 }
744 "remove-failed" => format!("remove failed: {}", self.detail.as_deref().unwrap_or("")),
745 "branch-delete-failed" => format!(
746 "branch delete failed: {}",
747 self.detail.as_deref().unwrap_or("")
748 ),
749 _ => "candidate".to_owned(),
750 }
751 }
752}
753
754#[derive(Debug, Serialize)]
756struct PruneReport {
757 schema: &'static str,
759 mode: &'static str,
761 worktrees: Vec<PruneRow>,
763 next: Vec<String>,
765}
766
767struct Judged {
769 worktree: Worktree,
770 tip: Option<String>,
772 class: WtClass,
773}
774
775#[allow(clippy::too_many_lines)]
780fn prune(
781 target: &Utf8Path,
782 repo_flag: Option<&str>,
783 forge_flag: Option<&str>,
784 verify: bool,
785 apply: bool,
786 quiet: bool,
787 out: Output,
788) -> Result<(), RkError> {
789 let worktrees = inventory(target)?;
790 let layout = layout_of(&worktrees)?;
791 let branches = branch_inventory(target)?;
792 if branches.is_empty() && worktrees.iter().any(|worktree| worktree.branch.is_some()) {
796 return Err(RkError::refusal(
797 Diagnostic::new(
798 Reason::PrerequisiteUnmet,
799 "the branch inventory did not parse, and no worktree is judged without its branch observation",
800 )
801 .expected("a branch listing covering the checked-out branches")
802 .target_state("unchanged"),
803 ));
804 }
805 let seat_paths = seats(target);
806 let seat_refs: Vec<&Utf8Path> = seat_paths.iter().map(Utf8PathBuf::as_path).collect();
807
808 let mut judged: Vec<Judged> = Vec::new();
812 for worktree in worktrees.iter().skip(1) {
813 let observation = worktree
814 .branch
815 .as_deref()
816 .and_then(|name| branches.iter().find(|branch| branch.name == name));
817 let reportable = worktree.prunable.is_some()
818 || worktree
819 .branch
820 .as_deref()
821 .is_some_and(|_| observation.is_none_or(|branch| branch.gone));
822 if !reportable {
823 continue;
824 }
825 let dirty = worktree.prunable.is_none() && is_dirty(&worktree.path);
826 let class = classify(
827 worktree,
828 observation,
829 &layout,
830 &seat_refs,
831 TRUNK_BRANCH,
832 dirty,
833 );
834 judged.push(Judged {
835 worktree: worktree.clone(),
836 tip: observation.map(|branch| branch.tip.clone()),
837 class,
838 });
839 }
840
841 if (verify || apply)
843 && judged
844 .iter()
845 .any(|row| matches!(row.class, WtClass::Candidate))
846 {
847 let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
848 let forge = Forge::parse(&resolved.forge)
849 .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
850 let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
851 let cli = resolve_cli(forge)?;
852 for row in &mut judged {
853 if matches!(row.class, WtClass::Candidate) {
854 let Some(tip) = row.tip.as_deref() else {
855 continue;
856 };
857 row.class = WtClass::Judged(merged_request_for(
858 &cli,
859 target.as_std_path(),
860 forge,
861 &repo,
862 tip,
863 ));
864 }
865 }
866 }
867
868 let mut rows: Vec<PruneRow> = judged
869 .iter()
870 .map(|row| {
871 let (status, request, detail) = match &row.class {
872 WtClass::Kept { reason } => ("kept", None, Some(reason.clone())),
873 WtClass::Candidate => ("candidate", None, None),
874 WtClass::Stale => ("stale", None, None),
875 WtClass::Judged(Class::Confirmed { request }) => {
876 ("confirmed", Some(request.clone()), None)
877 }
878 WtClass::Judged(Class::Unconfirmed { detail }) => {
879 ("unconfirmed", None, Some(detail.clone()))
880 }
881 WtClass::Judged(Class::Unknown { detail }) => {
882 ("unknown", None, Some(detail.clone()))
883 }
884 WtClass::Judged(_) => ("kept", None, Some("guarded".to_owned())),
885 };
886 PruneRow {
887 path: row.worktree.path.to_string(),
888 branch: row.worktree.branch.clone(),
889 tip: row.tip.clone(),
890 status,
891 request,
892 detail,
893 }
894 })
895 .collect();
896
897 let mut failures = 0usize;
898 if apply {
899 for row in &mut rows {
900 if row.status != "confirmed" {
901 continue;
902 }
903 if let Err(count) = retire(target, row) {
904 failures += count;
905 }
906 }
907 failures += sweep_stale(target, &mut rows)?;
908 }
909
910 let mode = if apply {
911 "apply"
912 } else if verify {
913 "verify"
914 } else {
915 "preview"
916 };
917 let next = next_lines(mode);
918 render(out, &rows, &next, quiet);
919 out.emit(&PruneReport {
920 schema: "rk.worktree-prune/1",
921 mode,
922 worktrees: rows,
923 next,
924 })?;
925 if failures > 0 {
926 return Err(RkError::subprocess(
927 Diagnostic::new(
928 Reason::SubprocessFailed,
929 format!("git refused {failures} cleanup actions"),
930 )
931 .expected("every confirmed worktree removed; the report names each outcome"),
932 ));
933 }
934 Ok(())
935}
936
937fn retire(target: &Utf8Path, row: &mut PruneRow) -> Result<(), usize> {
943 let Some(branch) = row.branch.clone() else {
944 return Ok(());
945 };
946 let Some(tip) = row.tip.clone() else {
947 return Ok(());
948 };
949 let keep = |row: &mut PruneRow, moved: &str| {
950 row.status = "kept";
951 row.detail = Some(format!(
952 "{moved} after verification; rk worktree prune --verify re-confirms"
953 ));
954 };
955 let reread = git(
956 target,
957 &[
958 "for-each-ref",
959 &format!("refs/heads/{branch}"),
960 "--format",
961 "%(objectname)",
962 ],
963 )
964 .map_err(|_| 1usize)?;
965 let fresh_tip = String::from_utf8_lossy(&reread.stdout).trim().to_owned();
966 if !reread.status.success() || fresh_tip != tip {
967 keep(row, "the tip moved");
968 return Ok(());
969 }
970 let path = Utf8PathBuf::from(&row.path);
974 let fresh = git(target, &["worktree", "list", "--porcelain", "-z"]).map_err(|_| 1usize)?;
975 if !fresh.status.success() {
976 keep(row, "the worktree inventory could not be re-read");
977 return Ok(());
978 }
979 let Ok(inventory) = crate::worktree::parse_worktrees(&fresh.stdout) else {
980 keep(row, "the worktree inventory could not be re-read");
981 return Ok(());
982 };
983 let seat = inventory.iter().find(|worktree| worktree.path == path);
984 if let Some(reason) = crate::worktree::reobservation(seat, &branch) {
985 keep(row, &reason);
986 return Ok(());
987 }
988 if is_dirty(&path) {
989 keep(row, "uncommitted changes arrived");
990 return Ok(());
991 }
992 let removed = git(target, &["worktree", "remove", row.path.as_str()]).map_err(|_| 1usize)?;
993 if !removed.status.success() {
994 row.status = "remove-failed";
995 row.detail = Some(format!(
996 "{}; clear what holds it — the dirt, the lock, the process in the directory — and re-run rk worktree prune --apply",
997 last_line(&removed.stderr)
998 ));
999 return Err(1);
1000 }
1001 match maintenance::delete_branch(target, &branch, &tip) {
1002 maintenance::Deletion::Deleted => {
1003 row.status = "pruned";
1004 Ok(())
1005 }
1006 maintenance::Deletion::ConfigSurvived { detail } => {
1007 row.status = "pruned";
1008 row.detail = Some(detail);
1009 Ok(())
1010 }
1011 maintenance::Deletion::Refused { detail } => {
1012 row.status = "branch-delete-failed";
1015 row.detail = Some(format!(
1016 "{detail}; the worktree is removed and the branch survives with its work: rk worktree add {branch} --apply re-seats it"
1017 ));
1018 Err(1)
1019 }
1020 }
1021}
1022
1023fn sweep_stale(target: &Utf8Path, rows: &mut [PruneRow]) -> Result<usize, RkError> {
1030 if !rows.iter().any(|row| row.status == "stale") {
1031 return Ok(0);
1032 }
1033 let mut failures = 0usize;
1034 let swept = git(target, &["worktree", "prune", "--expire", "now"])?;
1035 let survivors: Option<Vec<Utf8PathBuf>> =
1039 git(target, &["worktree", "list", "--porcelain", "-z"])
1040 .ok()
1041 .filter(|fresh| fresh.status.success())
1042 .and_then(|fresh| crate::worktree::parse_worktrees(&fresh.stdout).ok())
1043 .map(|inventory| {
1044 inventory
1045 .into_iter()
1046 .map(|worktree| worktree.path)
1047 .collect()
1048 });
1049 for row in rows.iter_mut().filter(|row| row.status == "stale") {
1050 let survived = survivors
1051 .as_ref()
1052 .is_none_or(|paths| paths.iter().any(|path| *path == row.path));
1053 if survived {
1054 row.status = "remove-failed";
1055 row.detail = Some(if survivors.is_none() {
1056 "the record's fate could not be observed; re-run rk worktree prune --apply"
1057 .to_owned()
1058 } else if swept.status.success() {
1059 "the record survived the sweep; re-run rk worktree prune --apply".to_owned()
1060 } else {
1061 format!(
1062 "{}; re-run rk worktree prune --apply",
1063 last_line(&swept.stderr)
1064 )
1065 });
1066 failures += 1;
1067 } else {
1068 row.status = "pruned";
1069 }
1070 }
1071 if !swept.status.success() && failures == 0 {
1072 failures = 1;
1073 }
1074 Ok(failures)
1075}
1076
1077fn next_lines(mode: &str) -> Vec<String> {
1079 let verify = "rk worktree prune --verify confirms each candidate against the forge";
1080 let apply = "rk worktree prune --apply verifies, then removes each worktree before its branch";
1081 match mode {
1082 "preview" => vec![verify.to_owned(), apply.to_owned()],
1083 "verify" => vec![apply.to_owned()],
1084 _ => Vec::new(),
1085 }
1086}
1087
1088fn render(out: Output, rows: &[PruneRow], next: &[String], quiet: bool) {
1093 if quiet && rows.is_empty() {
1094 return;
1095 }
1096 if rows.is_empty() {
1097 out.result_line("no worktree needs cleanup");
1098 } else {
1099 out.result_line(header(rows.len()));
1100 let width = rows.iter().map(|row| row.path.len()).max().unwrap_or(0);
1101 for row in rows {
1102 let tip = row
1103 .tip
1104 .as_deref()
1105 .map_or(" ", |tip| tip.get(..8).unwrap_or(tip));
1106 out.result_line(format!(" {:width$} {tip} {}", row.path, row.describe()));
1107 }
1108 }
1109 out.next(next);
1110 if rows
1111 .iter()
1112 .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
1113 {
1114 out.result_line(OPERATOR_LINE);
1115 }
1116}
1117
1118fn header(count: usize) -> String {
1120 if count == 1 {
1121 "1 worktree reports cleanup (a candidate, not proof):".to_owned()
1122 } else {
1123 format!("{count} worktrees report cleanup (a candidate, not proof):")
1124 }
1125}
1126
1127fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1129 std::process::Command::new("git")
1130 .arg("-C")
1131 .arg(target.as_std_path())
1132 .args(args)
1133 .output()
1134 .map_err(|source| {
1135 RkError::subprocess(
1136 Diagnostic::new(
1137 Reason::SubprocessSpawn,
1138 format!("git did not run: {source}"),
1139 )
1140 .expected("git installed and on PATH"),
1141 )
1142 })
1143}
1144
1145fn last_line(bytes: &[u8]) -> String {
1147 maintenance::last_line(bytes)
1148}
1149
1150#[cfg(test)]
1151mod tests {
1152 #![allow(clippy::expect_used)]
1153
1154 use super::{ListReport, ListRow, PruneReport, PruneRow};
1155
1156 #[test]
1159 fn the_worktree_list_schema_snapshot_holds() {
1160 let populated = ListReport {
1161 schema: "rk.worktree-list/1",
1162 worktrees: vec![
1163 ListRow {
1164 path: "/srv/widget".into(),
1165 branch: Some("master".into()),
1166 head: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
1167 kind: "main",
1168 state: "clean",
1169 canonical: true,
1170 },
1171 ListRow {
1172 path: "/srv/elsewhere".into(),
1173 branch: None,
1174 head: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
1175 kind: "linked",
1176 state: "detached",
1177 canonical: true,
1178 },
1179 ],
1180 next: vec!["rk worktree prune reports the worktrees a squash merge retired".into()],
1181 };
1182 assert_eq!(
1183 serde_json::to_string(&populated).expect("a report serializes"),
1184 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"]}"#,
1185 "a detached row must omit branch rather than serializing null"
1186 );
1187 }
1188
1189 #[test]
1194 fn the_worktree_add_schema_snapshot_holds() {
1195 let apply = super::AddReport {
1196 schema: "rk.worktree-add/1",
1197 mode: "apply",
1198 branch: "feat/x".into(),
1199 path: "/srv/widget-feat-x".into(),
1200 created: "branch",
1201 source: "remote",
1202 base: Some("origin/feat/x".into()),
1203 upstream: Some("origin/feat/x".into()),
1204 detail: Some("the fetch failed; the run proceeded on local refs".into()),
1205 next: vec!["cd /srv/widget-feat-x".into()],
1206 };
1207 assert_eq!(
1208 serde_json::to_string(&apply).expect("a report serializes"),
1209 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"]}"#
1210 );
1211 let preview = super::AddReport {
1212 mode: "preview",
1213 created: "nothing",
1214 source: "adopted",
1215 base: None,
1216 upstream: None,
1217 detail: None,
1218 ..apply
1219 };
1220 assert_eq!(
1221 serde_json::to_string(&preview).expect("a report serializes"),
1222 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"]}"#,
1223 "an absent option must be omitted rather than serializing null"
1224 );
1225 }
1226
1227 #[test]
1230 fn the_worktree_prune_schema_snapshot_holds() {
1231 let populated = PruneReport {
1232 schema: "rk.worktree-prune/1",
1233 mode: "verify",
1234 worktrees: vec![
1235 PruneRow {
1236 path: "/srv/widget-feat-x".into(),
1237 branch: Some("feat/x".into()),
1238 tip: Some("aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into()),
1239 status: "confirmed",
1240 request: Some("#8".into()),
1241 detail: None,
1242 },
1243 PruneRow {
1244 path: "/srv/widget-fix-y".into(),
1245 branch: None,
1246 tip: None,
1247 status: "stale",
1248 request: None,
1249 detail: None,
1250 },
1251 ],
1252 next: vec![
1253 "rk worktree prune --apply verifies, then removes each worktree before its branch"
1254 .into(),
1255 ],
1256 };
1257 assert_eq!(
1258 serde_json::to_string(&populated).expect("a report serializes"),
1259 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"]}"##
1260 );
1261 let clean = PruneReport {
1262 schema: "rk.worktree-prune/1",
1263 mode: "preview",
1264 worktrees: vec![],
1265 next: vec![
1266 "rk worktree prune --verify confirms each candidate against the forge".into(),
1267 ],
1268 };
1269 assert_eq!(
1270 serde_json::to_string(&clean).expect("a report serializes"),
1271 r#"{"schema":"rk.worktree-prune/1","mode":"preview","worktrees":[],"next":["rk worktree prune --verify confirms each candidate against the forge"]}"#,
1272 "a clean clone reports one empty list a caller can branch on"
1273 );
1274 }
1275}