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")]
53 request: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 detail: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
60 worktree: Option<String>,
61}
62
63impl Row {
64 fn from(branch: &Branch, class: Class) -> Self {
66 let (status, request, detail, worktree) = match class {
67 Class::Kept { reason } => ("kept", None, Some(reason), None),
68 Class::Candidate => ("candidate", None, None, None),
69 Class::WorktreeBound { path } => ("worktree-bound", None, None, Some(path)),
70 Class::Confirmed { request } => ("confirmed", Some(request), None, None),
71 Class::Unconfirmed { detail } => ("unconfirmed", None, Some(detail), None),
72 Class::Unknown { detail } => ("unknown", None, Some(detail), None),
73 };
74 Self {
75 name: branch.name.clone(),
76 tip: branch.tip.clone(),
77 status,
78 request,
79 detail,
80 worktree,
81 }
82 }
83
84 fn describe(&self) -> String {
86 match self.status {
87 "kept" => format!("kept: {}", self.detail.as_deref().unwrap_or("")),
88 "worktree-bound" => format!(
89 "worktree-bound: checked out at {}; its worktree owns the cleanup",
90 self.worktree.as_deref().unwrap_or("")
91 ),
92 "confirmed" => format!(
93 "confirmed: merged request {} matches this tip",
94 self.request.as_deref().unwrap_or("")
95 ),
96 "unconfirmed" => format!("unconfirmed: {}", self.detail.as_deref().unwrap_or("")),
97 "unknown" => format!("unknown: {}", self.detail.as_deref().unwrap_or("")),
98 "deleted" => {
99 let mut line = format!(
100 "deleted (merged request {})",
101 self.request.as_deref().unwrap_or("")
102 );
103 if let Some(detail) = &self.detail {
104 line.push_str("; ");
105 line.push_str(detail);
106 }
107 line
108 }
109 "delete-failed" => format!("delete failed: {}", self.detail.as_deref().unwrap_or("")),
110 _ => "candidate".to_owned(),
111 }
112 }
113}
114
115pub fn run(args: &BranchesArgs) -> Result<(), RkError> {
124 match &args.action {
125 BranchesAction::Prune {
126 target,
127 repo,
128 forge,
129 verify,
130 apply,
131 quiet,
132 json,
133 } => prune(
134 target,
135 repo.as_deref(),
136 forge.as_deref(),
137 *verify,
138 *apply,
139 *quiet,
140 Output::new(*json),
141 ),
142 }
143}
144
145fn prune(
148 target: &Utf8Path,
149 repo_flag: Option<&str>,
150 forge_flag: Option<&str>,
151 verify: bool,
152 apply: bool,
153 quiet: bool,
154 out: Output,
155) -> Result<(), RkError> {
156 let trunk = crate::config::trunk_of(target.as_std_path())?;
157 if !target.is_dir() {
158 return Err(RkError::missing(
159 Diagnostic::new(
160 Reason::TargetNotFound,
161 format!("target {target} is not a directory"),
162 )
163 .expected("an existing repository to read"),
164 ));
165 }
166 let listed = git(
167 target,
168 &[
169 "for-each-ref",
170 "refs/heads",
171 "--format",
172 FOR_EACH_REF_FORMAT,
173 ],
174 )?;
175 if !listed.status.success() {
176 return Err(RkError::refusal(
177 Diagnostic::new(
178 Reason::PrerequisiteUnmet,
179 format!("target {target} is not a git repository"),
180 )
181 .expected("a repository whose branches git can list"),
182 ));
183 }
184 let branches = crate::branches::parse_branches(&String::from_utf8_lossy(&listed.stdout));
185 let current = git(target, &["symbolic-ref", "--quiet", "--short", "HEAD"])
186 .ok()
187 .filter(|answer| answer.status.success())
188 .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
189 .filter(|name| !name.is_empty());
190 let mut judged: Vec<(&Branch, Class)> = branches
191 .iter()
192 .filter_map(|branch| {
193 classify(branch, current.as_deref(), &trunk).map(|class| (branch, class))
194 })
195 .collect();
196
197 if (verify || apply)
200 && judged
201 .iter()
202 .any(|(_, class)| matches!(class, Class::Candidate))
203 {
204 confirm_candidates(target, forge_flag, repo_flag, &mut judged)?;
205 }
206
207 let mut rows: Vec<Row> = judged
208 .iter()
209 .map(|(branch, class)| Row::from(branch, class.clone()))
210 .collect();
211
212 let mut failed_deletes = 0usize;
213 if apply {
214 for row in &mut rows {
215 if row.status != "confirmed" {
216 continue;
217 }
218 if let Err(count) = delete_branch(target, row) {
219 failed_deletes += count;
220 }
221 }
222 }
223
224 let mode = if apply {
225 "apply"
226 } else if verify {
227 "verify"
228 } else {
229 "preview"
230 };
231 let bound = rows.iter().any(|row| row.status == "worktree-bound");
232 let next = next_lines(mode, bound);
233 render(out, &rows, &next, quiet);
234 out.emit(&Report {
235 schema: "rk.branches-prune/1",
236 mode,
237 branches: rows,
238 next,
239 })?;
240 if failed_deletes > 0 {
241 return Err(RkError::subprocess(
242 Diagnostic::new(
243 Reason::SubprocessFailed,
244 format!("git refused to delete {failed_deletes} confirmed branches"),
245 )
246 .expected("every confirmed branch deleted; the report names each outcome"),
247 ));
248 }
249 Ok(())
250}
251
252fn confirm_candidates(
254 target: &Utf8Path,
255 forge_flag: Option<&str>,
256 repo_flag: Option<&str>,
257 judged: &mut [(&Branch, Class)],
258) -> Result<(), RkError> {
259 let resolved = crate::landing::resolve(target, forge_flag, repo_flag)?;
260 let forge = Forge::parse(&resolved.forge)
261 .ok_or_else(|| RkError::Usage(format!("unknown forge '{}'", resolved.forge)))?;
262 let repo = resolved.repo.ok_or_else(crate::landing::repo_unresolved)?;
263 let cli = resolve_cli(forge)?;
264 for (branch, class) in judged {
265 if matches!(class, Class::Candidate) {
266 *class = merged_request_for(&cli, target.as_std_path(), forge, &repo, &branch.tip);
267 }
268 }
269 Ok(())
270}
271
272fn delete_branch(target: &Utf8Path, row: &mut Row) -> Result<(), usize> {
283 let ref_name = format!("refs/heads/{}", row.name);
284 let rechecked = git(
285 target,
286 &[
287 "for-each-ref",
288 &ref_name,
289 "--format",
290 "%(objectname)%09%(worktreepath)",
291 ],
292 )
293 .map_err(|_| 1usize)?;
294 match recheck_verdict(&rechecked) {
295 Err(detail) => {
296 row.status = "delete-failed";
299 row.detail = Some(format!("{detail}; rk branches prune --verify re-runs it"));
300 return Err(1);
301 }
302 Ok(Some(worktree)) => {
303 row.status = "worktree-bound";
304 row.worktree = Some(worktree);
305 return Ok(());
306 }
307 Ok(None) => {}
308 }
309 match maintenance::delete_branch(target, &row.name, &row.tip) {
310 maintenance::Deletion::Deleted => {
311 row.status = "deleted";
312 Ok(())
313 }
314 maintenance::Deletion::ConfigSurvived { detail } => {
315 row.status = "deleted";
316 row.detail = Some(detail);
317 Ok(())
318 }
319 maintenance::Deletion::Refused { detail } => {
320 row.status = "delete-failed";
321 row.detail = Some(format!(
322 "{detail}; the tip moved after verification: rk branches prune --verify re-confirms it"
323 ));
324 Err(1)
325 }
326 }
327}
328
329fn recheck_verdict(probe: &std::process::Output) -> Result<Option<String>, String> {
332 if !probe.status.success() {
333 return Err(format!(
334 "the checkout recheck failed: {}",
335 last_line(&probe.stderr)
336 ));
337 }
338 let answer = String::from_utf8_lossy(&probe.stdout);
339 let worktree = answer
340 .trim_end()
341 .split_once('\t')
342 .map(|(_, worktree)| worktree.to_owned())
343 .unwrap_or_default();
344 Ok((!worktree.is_empty()).then_some(worktree))
345}
346
347fn next_lines(mode: &str, worktree_bound: bool) -> Vec<String> {
350 let verify = "rk branches prune --verify confirms each candidate against the forge";
351 let apply = "rk branches prune --apply verifies, then deletes the confirmed branches";
352 let mut next = match mode {
353 "preview" => vec![verify.to_owned(), apply.to_owned()],
354 "verify" => vec![apply.to_owned()],
355 _ => Vec::new(),
356 };
357 if worktree_bound {
358 next.push(
359 "rk worktree prune --verify confirms the worktree-bound branches and their worktrees"
360 .to_owned(),
361 );
362 }
363 next
364}
365
366fn render(out: Output, rows: &[Row], next: &[String], quiet: bool) {
370 if quiet && rows.is_empty() {
371 return;
372 }
373 if rows.is_empty() {
374 out.result_line("no local branch tracks a gone remote branch");
375 } else {
376 out.result_line(header(rows.len()));
377 let width = rows.iter().map(|row| row.name.len()).max().unwrap_or(0);
378 for row in rows {
379 let tip = row.tip.get(..8).unwrap_or(&row.tip);
380 out.result_line(format!(" {:width$} {tip} {}", row.name, row.describe()));
381 }
382 }
383 out.next(next);
384 if rows
385 .iter()
386 .any(|row| maintenance::row_owes(row.status, row.detail.as_deref()))
387 {
388 out.result_line(OPERATOR_LINE);
389 }
390}
391
392fn header(count: usize) -> String {
394 if count == 1 {
395 "1 local branch tracks a remote branch that is gone (a candidate, not proof):".to_owned()
396 } else {
397 format!(
398 "{count} local branches track a remote branch that is gone (a candidate, not proof):"
399 )
400 }
401}
402
403fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
408 let mut command = std::process::Command::new(crate::probes::git_bin());
409 for var in maintenance::GIT_HOOK_VARS {
410 command.env_remove(var);
411 }
412 command
413 .arg("-C")
414 .arg(target.as_std_path())
415 .args(args)
416 .output()
417 .map_err(|source| {
418 RkError::subprocess(
419 Diagnostic::new(
420 Reason::SubprocessSpawn,
421 format!("git did not run: {source}"),
422 )
423 .expected("git installed and on PATH"),
424 )
425 })
426}
427
428fn last_line(bytes: &[u8]) -> String {
430 String::from_utf8_lossy(bytes)
431 .lines()
432 .rev()
433 .find(|line| !line.trim().is_empty())
434 .unwrap_or("no output")
435 .to_owned()
436}
437
438#[cfg(test)]
439mod tests {
440 #![allow(clippy::expect_used)]
441
442 use super::{Report, Row, recheck_verdict};
443
444 #[cfg(unix)]
447 #[test]
448 fn the_recheck_verdict_fails_closed() {
449 use std::os::unix::process::ExitStatusExt as _;
450 let output = |code: i32, stdout: &str, stderr: &str| std::process::Output {
451 status: std::process::ExitStatus::from_raw(code << 8),
452 stdout: stdout.as_bytes().to_vec(),
453 stderr: stderr.as_bytes().to_vec(),
454 };
455 let failed = recheck_verdict(&output(128, "", "fatal: not a git repository"));
456 assert!(
457 failed.is_err_and(|detail| detail.contains("not a git repository")),
458 "a probe that cannot answer proves nothing"
459 );
460 assert_eq!(
461 recheck_verdict(&output(
462 0,
463 "aaaa /srv/checkouts/wt
464",
465 ""
466 )),
467 Ok(Some("/srv/checkouts/wt".to_owned()))
468 );
469 assert_eq!(
470 recheck_verdict(&output(
471 0, "aaaa
472", ""
473 )),
474 Ok(None)
475 );
476 }
477
478 #[test]
481 fn the_branches_prune_schema_snapshot_holds() {
482 let populated = Report {
483 schema: "rk.branches-prune/1",
484 mode: "verify",
485 branches: vec![
486 Row {
487 name: "feat/x".into(),
488 tip: "aaaabbbbccccddddaaaabbbbccccddddaaaabbbb".into(),
489 status: "confirmed",
490 request: Some("#8".into()),
491 detail: None,
492 worktree: None,
493 },
494 Row {
495 name: "fix/y".into(),
496 tip: "bbbbccccddddaaaabbbbccccddddaaaabbbbcccc".into(),
497 status: "kept",
498 request: None,
499 detail: Some("the current branch".into()),
500 worktree: None,
501 },
502 Row {
503 name: "fix/z".into(),
504 tip: "ccccddddaaaabbbbccccddddaaaabbbbccccdddd".into(),
505 status: "worktree-bound",
506 request: None,
507 detail: None,
508 worktree: Some("/srv/checkouts/wt".into()),
509 },
510 ],
511 next: vec![
512 "rk branches prune --apply verifies, then deletes the confirmed branches".into(),
513 ],
514 };
515 assert_eq!(
516 serde_json::to_string(&populated).expect("a report serializes"),
517 r##"{"schema":"rk.branches-prune/1","mode":"verify","branches":[{"name":"feat/x","tip":"aaaabbbbccccddddaaaabbbbccccddddaaaabbbb","status":"confirmed","request":"#8"},{"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"]}"##
518 );
519 let clean = Report {
520 schema: "rk.branches-prune/1",
521 mode: "preview",
522 branches: vec![],
523 next: vec![
524 "rk branches prune --verify confirms each candidate against the forge".into(),
525 "rk branches prune --apply verifies, then deletes the confirmed branches".into(),
526 ],
527 };
528 assert_eq!(
529 serde_json::to_string(&clean).expect("a report serializes"),
530 r#"{"schema":"rk.branches-prune/1","mode":"preview","branches":[],"next":["rk branches prune --verify confirms each candidate against the forge","rk branches prune --apply verifies, then deletes the confirmed branches"]}"#,
531 "a clean clone reports one empty list a caller can branch on"
532 );
533 }
534}