1use camino::Utf8Path;
12use serde::Serialize;
13
14use crate::branches::{Branch, Class, FOR_EACH_REF_FORMAT, classify, merged_request_for};
15use crate::cli::branches::{BranchesAction, BranchesArgs};
16use crate::detect::Forge;
17use crate::diagnostic::{Diagnostic, Reason};
18use crate::error::RkError;
19use crate::maintenance;
20use crate::output::Output;
21use crate::setup::context::resolve_cli;
22
23const OPERATOR_LINE: &str = "Deleting a branch is the operator's action: an agent reading this states the command and waits to be asked.";
29
30#[derive(Debug, Serialize)]
32struct Report {
33 schema: &'static str,
35 mode: &'static str,
37 branches: Vec<Row>,
39 next: Vec<String>,
41}
42
43#[derive(Debug, Serialize)]
45struct Row {
46 name: String,
48 tip: String,
50 status: &'static str,
53 #[serde(skip_serializing_if = "Option::is_none")]
57 proof: Option<String>,
58 #[serde(skip_serializing_if = "Option::is_none")]
61 proof_kind: Option<&'static str>,
62 #[serde(skip_serializing_if = "Option::is_none")]
64 detail: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
68 worktree: Option<String>,
69}
70
71impl Row {
72 fn from(branch: &Branch, class: Class) -> Self {
74 let (status, proof, detail, worktree) = match class {
75 Class::Kept { reason } => ("kept", None, Some(reason), None),
76 Class::Candidate => ("candidate", None, None, None),
77 Class::WorktreeBound { path } => ("worktree-bound", None, None, Some(path)),
78 Class::Confirmed { proof } => ("confirmed", Some(proof), None, None),
79 Class::Unconfirmed { detail } => ("unconfirmed", None, Some(detail), None),
80 Class::Unknown { detail } => ("unknown", None, Some(detail), None),
81 };
82 Self {
83 name: branch.name.clone(),
84 tip: branch.tip.clone(),
85 status,
86 proof_kind: proof.as_ref().map(crate::branches::Proof::kind),
87 proof: proof.as_ref().map(crate::branches::Proof::detail),
88 detail,
89 worktree,
90 }
91 }
92
93 fn describe(&self) -> String {
95 match self.status {
96 "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
97 "worktree-bound" => format!(
98 "worktree-bound: checked out at {}; its worktree owns the cleanup",
99 self.worktree.as_deref().unwrap_or("")
100 ),
101 "confirmed" if self.proof_kind == Some("reachable-tip") => {
102 format!("confirmed: {}", self.proof.as_deref().unwrap_or(""))
103 }
104 "confirmed" => format!(
105 "confirmed: {} matches this tip",
106 self.proof.as_deref().unwrap_or("")
107 ),
108 "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
109 "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
110 "deleted" => {
111 let mut line = format!("deleted ({})", self.proof.as_deref().unwrap_or(""));
112 if let Some(detail) = &self.detail {
113 line.push_str("; ");
114 line.push_str(detail);
115 }
116 line
117 }
118 "delete-failed" => format!("delete failed: {}", self.detail.as_deref().unwrap_or("")),
119 _ => "candidate".to_owned(),
120 }
121 }
122}
123
124pub fn run(args: &BranchesArgs) -> Result<(), RkError> {
133 match &args.action {
134 BranchesAction::Prune {
135 target,
136 repo,
137 forge,
138 verify,
139 apply,
140 quiet,
141 json,
142 } => prune(
143 target,
144 repo.as_deref(),
145 forge.as_deref(),
146 *verify,
147 *apply,
148 *quiet,
149 Output::new(*json),
150 ),
151 }
152}
153
154fn prune(
157 target: &Utf8Path,
158 repo_flag: Option<&str>,
159 forge_flag: Option<&str>,
160 verify: bool,
161 apply: bool,
162 quiet: bool,
163 out: Output,
164) -> Result<(), RkError> {
165 let trunk = crate::config::trunk_of(target.as_std_path())?;
166 if !target.is_dir() {
167 return Err(RkError::missing(
168 Diagnostic::new(
169 Reason::TargetNotFound,
170 format!("target {target} is not a directory"),
171 )
172 .expected("an existing repository to read"),
173 ));
174 }
175 let listed = git(
176 target,
177 &[
178 "for-each-ref",
179 "refs/heads",
180 "--format",
181 FOR_EACH_REF_FORMAT,
182 ],
183 )?;
184 if !listed.status.success() {
185 return Err(RkError::refusal(
186 Diagnostic::new(
187 Reason::PrerequisiteUnmet,
188 format!("target {target} is not a git repository"),
189 )
190 .expected("a repository whose branches git can list"),
191 ));
192 }
193 let branches = crate::branches::parse_branches(&String::from_utf8_lossy(&listed.stdout));
194 let current = git(target, &["symbolic-ref", "--quiet", "--short", "HEAD"])
195 .ok()
196 .filter(|answer| answer.status.success())
197 .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
198 .filter(|name| !name.is_empty());
199 let ledger = crate::maintenance::integration_ledger(target, &trunk);
206 let mut judged: Vec<(&Branch, Class)> = branches
207 .iter()
208 .filter_map(|branch| {
209 let local = maintenance::reachable_tip(target, &trunk, &branch.name, &branch.tip)
210 .or_else(|| {
211 ledger.proof(&branch.name, &branch.tip).map(|entry| {
212 crate::branches::Proof::LocalIntegration(entry.trunk_commit.clone())
213 })
214 });
215 classify(branch, current.as_deref(), &trunk, local).map(|class| (branch, class))
216 })
217 .collect();
218
219 if (verify || apply)
222 && judged
223 .iter()
224 .any(|(_, class)| matches!(class, Class::Candidate))
225 {
226 confirm_candidates(target, forge_flag, repo_flag, &mut judged)?;
227 }
228
229 let mut rows: Vec<Row> = judged
230 .iter()
231 .map(|(branch, class)| Row::from(branch, class.clone()))
232 .collect();
233
234 let mut failed_deletes = 0usize;
235 if apply {
236 for row in &mut rows {
237 if row.status != "confirmed" {
238 continue;
239 }
240 if let Err(count) = delete_branch(target, &trunk, row) {
241 failed_deletes += count;
242 }
243 }
244 }
245
246 let mode = if apply {
247 "apply"
248 } else if verify {
249 "verify"
250 } else {
251 "preview"
252 };
253 let bound = rows.iter().any(|row| row.status == "worktree-bound");
254 let next = next_lines(mode, bound);
255 render(out, &rows, &next, quiet);
256 out.emit(&Report {
257 schema: "rk.branches-prune/2",
258 mode,
259 branches: rows,
260 next,
261 })?;
262 if failed_deletes > 0 {
263 return Err(RkError::subprocess(
264 Diagnostic::new(
265 Reason::SubprocessFailed,
266 format!("git refused to delete {failed_deletes} confirmed branches"),
267 )
268 .expected("every confirmed branch deleted; the report names each outcome"),
269 ));
270 }
271 Ok(())
272}
273
274fn confirm_candidates(
276 target: &Utf8Path,
277 forge_flag: Option<&str>,
278 repo_flag: Option<&str>,
279 judged: &mut [(&Branch, Class)],
280) -> Result<(), RkError> {
281 let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
282 let forge = Forge::parse(&resolved.forge)
283 .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
284 let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
285 let cli = resolve_cli(forge)?;
286 for (branch, class) in judged {
287 if matches!(class, Class::Candidate) {
288 *class = merged_request_for(&cli, target.as_std_path(), forge, &repo, &branch.tip);
289 }
290 }
291 Ok(())
292}
293
294fn delete_branch(target: &Utf8Path, trunk: &str, row: &mut Row) -> Result<(), usize> {
307 let ref_name = format!("refs/heads/{}", row.name);
308 let rechecked = git(
309 target,
310 &[
311 "for-each-ref",
312 &ref_name,
313 "--format",
314 "%(objectname)%09%(worktreepath)",
315 ],
316 )
317 .map_err(|_| 1usize)?;
318 match recheck_verdict(&rechecked) {
319 Err(detail) => {
320 row.status = "delete-failed";
323 row.detail = Some(format!("{detail}; rk branches prune --verify re-runs it"));
324 return Err(1);
325 }
326 Ok(Some(worktree)) => {
327 row.status = "worktree-bound";
328 row.worktree = Some(worktree);
329 return Ok(());
330 }
331 Ok(None) => {}
332 }
333 if row.proof_kind == Some("reachable-tip")
334 && !maintenance::trunk_reaches(target, trunk, &row.tip)
335 {
336 row.status = "kept";
337 row.detail = Some(
338 "the trunk no longer reaches the tip after verification; rk branches prune --verify re-confirms"
339 .to_owned(),
340 );
341 return Ok(());
342 }
343 match maintenance::delete_branch(target, &row.name, &row.tip) {
344 maintenance::Deletion::Deleted => {
345 maintenance::forget_integration(target, &row.name);
346 row.status = "deleted";
347 Ok(())
348 }
349 maintenance::Deletion::ConfigSurvived { detail } => {
350 maintenance::forget_integration(target, &row.name);
351 row.status = "deleted";
352 row.detail = Some(detail);
353 Ok(())
354 }
355 maintenance::Deletion::Refused { detail } => {
356 row.status = "delete-failed";
357 row.detail = Some(format!(
358 "{detail}; the tip moved after verification: rk branches prune --verify re-confirms it"
359 ));
360 Err(1)
361 }
362 }
363}
364
365fn recheck_verdict(probe: &std::process::Output) -> Result<Option<String>, String> {
368 if !probe.status.success() {
369 return Err(format!(
370 "the checkout recheck failed: {}",
371 last_line(&probe.stderr)
372 ));
373 }
374 let answer = String::from_utf8_lossy(&probe.stdout);
375 let worktree = answer
376 .trim_end()
377 .split_once('\t')
378 .map(|(_, worktree)| worktree.to_owned())
379 .unwrap_or_default();
380 Ok((!worktree.is_empty()).then_some(worktree))
381}
382
383fn next_lines(mode: &str, worktree_bound: bool) -> Vec<String> {
386 let verify = "rk branches prune --verify confirms each candidate against the forge";
387 let apply = "rk branches prune --apply verifies, then deletes the confirmed branches";
388 let mut next = match mode {
389 "preview" => vec![verify.to_owned(), apply.to_owned()],
390 "verify" => vec![apply.to_owned()],
391 _ => Vec::new(),
392 };
393 if worktree_bound {
394 next.push(
395 "rk worktree prune --verify confirms the worktree-bound branches and their worktrees"
396 .to_owned(),
397 );
398 }
399 next
400}
401
402fn render(out: Output, rows: &[Row], next: &[String], quiet: bool) {
406 if quiet && rows.is_empty() {
407 return;
408 }
409 if rows.is_empty() {
410 out.result_line("no local branch tracks a gone remote branch");
411 } else {
412 out.result_line(header(
413 rows.len(),
414 rows.iter()
415 .filter(|row| row.proof_kind == Some("local-integration"))
416 .count(),
417 rows.iter()
418 .filter(|row| row.proof_kind == Some("reachable-tip"))
419 .count(),
420 ));
421 let width = rows.iter().map(|row| row.name.len()).max().unwrap_or(0);
422 for row in rows {
423 let tip = row.tip.get(..8).unwrap_or(&row.tip);
424 out.result_line(format!(" {:width$} {tip} {}", row.name, row.describe()));
425 }
426 }
427 out.next(next);
428 if rows
429 .iter()
430 .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
431 {
432 out.result_line(OPERATOR_LINE);
433 }
434}
435
436fn header(count: usize, local: usize, reachable: usize) -> String {
445 let (noun, verb, carry) = if count == 1 {
446 ("1 local branch", "tracks", "carries")
447 } else {
448 ("local branches", "track", "carry")
449 };
450 let noun = if count == 1 {
451 noun.to_owned()
452 } else {
453 format!("{count} {noun}")
454 };
455 match (local, reachable, count - local - reachable) {
456 (0, 0, _) => {
457 format!("{noun} {verb} a remote branch that is gone (a candidate, not proof):")
458 }
459 (_, 0, 0) => format!("{noun} {carry} a local integration this clone recorded:"),
460 (0, _, 0) => format!("{noun} {carry} a tip the trunk reaches:"),
461 (_, _, 0) => {
462 format!("{noun}: {local} locally integrated, {reachable} with a tip the trunk reaches:")
463 }
464 (_, 0, gone) => {
465 format!("{noun}: {gone} with a gone upstream, {local} locally integrated:")
466 }
467 (_, _, gone) => format!(
468 "{noun}: {gone} with a gone upstream, {local} locally integrated, {reachable} with a tip the trunk reaches:"
469 ),
470 }
471}
472
473fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
478 let mut command = std::process::Command::new(crate::probes::git_bin());
479 for var in maintenance::GIT_HOOK_VARS {
480 command.env_remove(var);
481 }
482 command
483 .arg("-C")
484 .arg(target.as_std_path())
485 .args(args)
486 .output()
487 .map_err(|source| {
488 RkError::subprocess(
489 Diagnostic::new(
490 Reason::SubprocessSpawn,
491 format!("git did not run: {source}"),
492 )
493 .expected("git installed and on PATH"),
494 )
495 })
496}
497
498fn last_line(bytes: &[u8]) -> String {
500 String::from_utf8_lossy(bytes)
501 .lines()
502 .rev()
503 .find(|line| !line.trim().is_empty())
504 .unwrap_or("no output")
505 .to_owned()
506}
507
508#[cfg(test)]
509mod tests {
510 use super::{Report, Row, recheck_verdict};
511
512 #[cfg(unix)]
515 #[test]
516 fn the_recheck_verdict_fails_closed() {
517 use std::os::unix::process::ExitStatusExt as _;
518 let output = |code: i32, stdout: &str, stderr: &str| std::process::Output {
519 status: std::process::ExitStatus::from_raw(code << 8),
520 stdout: stdout.as_bytes().to_vec(),
521 stderr: stderr.as_bytes().to_vec(),
522 };
523 let failed = recheck_verdict(&output(128, "", "fatal: not a git repository"));
524 assert!(
525 failed.is_err_and(|detail| detail.contains("not a git repository")),
526 "a probe that cannot answer proves nothing"
527 );
528 assert_eq!(
529 recheck_verdict(&output(
530 0,
531 "aaaa /srv/checkouts/wt
532",
533 ""
534 )),
535 Ok(Some("/srv/checkouts/wt".to_owned()))
536 );
537 assert_eq!(
538 recheck_verdict(&output(
539 0, "aaaa
540", ""
541 )),
542 Ok(None)
543 );
544 }
545
546 #[test]
549 fn the_branches_prune_schema_snapshot_holds() {
550 let populated = Report {
551 schema: "rk.branches-prune/2",
552 mode: "verify",
553 branches: vec![
554 Row {
555 name: "feat/x".into(),
556 tip: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
557 status: "confirmed",
558 proof: Some("#8".into()),
559 proof_kind: Some("request"),
560 detail: None,
561 worktree: None,
562 },
563 Row {
564 name: "fix/y".into(),
565 tip: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
566 status: "kept",
567 proof: None,
568 proof_kind: None,
569 detail: Some("the current branch".into()),
570 worktree: None,
571 },
572 Row {
573 name: "fix/z".into(),
574 tip: "ccccddddaaaabbbbccccddddaaaabbbbccccdddd".into(),
575 status: "worktree-bound",
576 proof: None,
577 proof_kind: None,
578 detail: None,
579 worktree: Some("/srv/checkouts/wt".into()),
580 },
581 ],
582 next: vec![
583 "rk branches prune --apply verifies, then deletes the confirmed branches".into(),
584 ],
585 };
586 assert_eq!(
587 serde_json::to_string(&populated).expect("a report serializes"),
588 r##"{"schema":"rk.branches-prune/2","mode":"verify","branches":[{"name":"feat/x","tip":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","status":"confirmed","proof":"#8","proof_kind":"request"},{"name":"fix/y","tip":"bbbbccccddddaaaabbbbccccddddaaaabbbbcccc","status":"kept","detail":"the current branch"},{"name":"fix/z","tip":"ccccddddaaaabbbbccccddddaaaabbbbccccdddd","status":"worktree-bound","worktree":"/srv/checkouts/wt"}],"next":["rk branches prune --apply verifies, then deletes the confirmed branches"]}"##
589 );
590 let clean = Report {
591 schema: "rk.branches-prune/2",
592 mode: "preview",
593 branches: vec![],
594 next: vec![
595 "rk branches prune --verify confirms each candidate against the forge".into(),
596 "rk branches prune --apply verifies, then deletes the confirmed branches".into(),
597 ],
598 };
599 assert_eq!(
600 serde_json::to_string(&clean).expect("a report serializes"),
601 r#"{"schema":"rk.branches-prune/2","mode":"preview","branches":[],"next":["rk branches prune --verify confirms each candidate against the forge","rk branches prune --apply verifies, then deletes the confirmed branches"]}"#,
602 "a clean clone reports one empty list a caller can branch on"
603 );
604 }
605}