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