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