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