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