release_kit/maintenance.rs
1//! The shared process-side discipline of local-resource cleanup.
2//!
3//! `rk branches prune` and `rk worktree prune` retire the same resource
4//! pair — a branch, and the worktree that seats one — so the deletion
5//! discipline has one implementation here, and exactly two callers invoke
6//! it: `crate::commands::branches` and `crate::commands::worktree`. The
7//! module spawns git, which is why it sits beside the pure `branches` and
8//! `worktree` modules rather than inside either: both declare themselves
9//! parsing and classification only. The report-closing rule the two prune
10//! verbs share lives here too, so the pair cannot fork.
11
12use camino::Utf8Path;
13
14/// The outcome of deleting one branch at a verified tip.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Deletion {
17 /// The ref and its configuration section are gone.
18 Deleted,
19 /// The ref is gone and a `branch.<name>` configuration section
20 /// survives; something is still owed, and the detail names the move.
21 ConfigSurvived {
22 /// What survived and the command that clears it.
23 detail: String,
24 },
25 /// The compare-and-swap refused: the tip moved, or git did not run.
26 Refused {
27 /// Git's own reason, last line.
28 detail: String,
29 },
30}
31
32/// Delete one branch whose tip verification authorized, compare-and-swap.
33///
34/// `git update-ref -d` carries the verified tip, so a ref that moved
35/// after verification is refused, never lost. A deleted ref then drops
36/// its `branch.<name>` configuration section — what `git branch -d`
37/// would have removed beside it — so a later branch under the reused
38/// name inherits nothing stale.
39#[must_use]
40pub fn delete_branch(target: &Utf8Path, branch: &str, verified_tip: &str) -> Deletion {
41 let ref_name = format!("refs/heads/{branch}");
42 let deleted = match git(target, &["update-ref", "-d", &ref_name, verified_tip]) {
43 Ok(output) => output,
44 Err(detail) => return Deletion::Refused { detail },
45 };
46 if !deleted.status.success() {
47 return Deletion::Refused {
48 detail: last_line(&deleted.stderr),
49 };
50 }
51 // A section that was never written makes the removal fail, which is
52 // the common clean case; entries that survive the attempt are the
53 // reportable residue.
54 let section = format!("branch.{branch}");
55 let survives = match git(target, &["config", "--remove-section", §ion]) {
56 Ok(removed) if removed.status.success() => false,
57 Ok(_) => {
58 // Enumerate rather than pattern-match: a branch name can carry
59 // regex metacharacters, so the filter is an exact prefix test
60 // over the fixed-pattern listing.
61 let prefix = format!("branch.{branch}.");
62 match git(target, &["config", "--get-regexp", "^branch\\."]) {
63 Ok(leftover) => {
64 leftover.status.success()
65 && String::from_utf8_lossy(&leftover.stdout)
66 .lines()
67 .any(|line| line.starts_with(&prefix))
68 }
69 Err(_) => true,
70 }
71 }
72 Err(_) => true,
73 };
74 if survives {
75 return Deletion::ConfigSurvived {
76 detail: format!(
77 "the branch configuration survives: git config --remove-section branch.{branch}"
78 ),
79 };
80 }
81 Deletion::Deleted
82}
83
84/// Whether one report row still names a move the operator may make.
85///
86/// The closing operator line of both prune reports rides this predicate,
87/// never the mode: a preview's candidates, every kept and judged row, and
88/// every failure row owe — each failure's `detail` is required to carry
89/// its recovery, which is why it owes despite the exit code — and so does
90/// a `deleted` row whose detail reports surviving configuration. Done is
91/// done: `deleted` with no residue and `pruned` owe nothing.
92#[must_use]
93pub fn row_owes(status: &str, detail: Option<&str>) -> bool {
94 match status {
95 "deleted" | "pruned" => detail.is_some(),
96 _ => true,
97 }
98}
99
100/// The variables a running git hook exports; a child inheriting them
101/// would resolve the hook's repository instead of the `-C` target, so
102/// every git this crate spawns against a named target scrubs them.
103pub(crate) const GIT_HOOK_VARS: [&str; 4] = [
104 "GIT_DIR",
105 "GIT_WORK_TREE",
106 "GIT_INDEX_FILE",
107 "GIT_COMMON_DIR",
108];
109
110/// Run one git command against the target; a spawn failure is the detail.
111fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, String> {
112 let mut command = std::process::Command::new("git");
113 for var in GIT_HOOK_VARS {
114 command.env_remove(var);
115 }
116 command
117 .arg("-C")
118 .arg(target.as_std_path())
119 .args(args)
120 .output()
121 .map_err(|source| format!("git did not run: {source}"))
122}
123
124/// The last non-empty stderr line, for a one-line detail.
125pub(crate) fn last_line(bytes: &[u8]) -> String {
126 String::from_utf8_lossy(bytes)
127 .lines()
128 .rev()
129 .find(|line| !line.trim().is_empty())
130 .unwrap_or("no output")
131 .to_owned()
132}
133
134#[cfg(test)]
135mod tests {
136 use super::row_owes;
137
138 /// The `(status, detail)` matrix behind the closing line: every row
139 /// that still names a move owes, and only finished rows do not.
140 #[test]
141 fn a_row_owes_until_nothing_is_left_to_ask() {
142 for status in [
143 "candidate",
144 "kept",
145 "stale",
146 "confirmed",
147 "unconfirmed",
148 "unknown",
149 "worktree-bound",
150 "delete-failed",
151 "remove-failed",
152 "branch-delete-failed",
153 ] {
154 assert!(row_owes(status, None), "{status} names a move");
155 assert!(row_owes(status, Some("detail")), "{status} names a move");
156 }
157 for finished in ["deleted", "pruned"] {
158 assert!(
159 row_owes(finished, Some("the branch configuration survives")),
160 "surviving residue is still owed"
161 );
162 assert!(!row_owes(finished, None), "done is done");
163 }
164 }
165}