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(crate::probes::git_bin());
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 clone's local integration ledger, keeping only the entries the
125/// trunk actually carries.
126///
127/// Both prune verbs read this, so the read has one owner beside the
128/// deletion discipline they already share. A clone that never integrated
129/// locally, a git that cannot answer, and a ledger this binary cannot
130/// parse all read as empty here: an empty ledger authorizes no deletion,
131/// which is the safe direction, and `rk integrate` is what refuses on an
132/// unreadable ledger, where refusing costs nothing.
133///
134/// An entry whose commit the trunk does not reach is dropped. Evidence is
135/// staged before its publication, so an integration whose publication
136/// failed leaves an entry naming a commit no ref carries, and an entry
137/// can also outlive a trunk someone reset. Reading reachability here is
138/// what makes that residue inert: the proof is the trunk carrying the
139/// work, never the ledger saying so.
140#[must_use]
141pub fn integration_ledger(target: &Utf8Path, trunk: &str) -> crate::integrate::Ledger {
142 let Some(path) = ledger_path(target) else {
143 return crate::integrate::Ledger::default();
144 };
145 let mut ledger: crate::integrate::Ledger = std::fs::read_to_string(path)
146 .ok()
147 .and_then(|text| crate::integrate::Ledger::parse(&text).ok())
148 .unwrap_or_default();
149 ledger
150 .entries
151 .retain(|entry| trunk_reaches(target, trunk, &entry.trunk_commit));
152 ledger
153}
154
155/// Whether the trunk reaches one commit.
156///
157/// A git that cannot answer reads as unreachable, which keeps the branch
158/// rather than deleting it.
159fn trunk_reaches(target: &Utf8Path, trunk: &str, commit: &str) -> bool {
160 git(target, &["merge-base", "--is-ancestor", commit, trunk])
161 .is_ok_and(|answer| answer.status.success())
162}
163
164/// Drop one branch's entry from the ledger, after the branch is gone.
165///
166/// A retired branch's evidence has nothing left to prove, and a name is
167/// reused: keeping the entry would leave the ledger growing and a stale
168/// answer standing. A failure here is silent, because the deletion it
169/// follows already succeeded and the residue proves nothing.
170pub fn forget_integration(target: &Utf8Path, branch: &str) {
171 let Some(path) = ledger_path(target) else {
172 return;
173 };
174 let Ok(text) = std::fs::read_to_string(&path) else {
175 return;
176 };
177 let Ok(mut ledger) = crate::integrate::Ledger::parse(&text) else {
178 return;
179 };
180 if !ledger.names(branch) {
181 return;
182 }
183 ledger.forget(branch);
184 if let Ok(rendered) = ledger.render() {
185 let _ = crate::atomic::write(&path, rendered.as_bytes());
186 }
187}
188
189/// The ledger's path in this clone, where git can name its common
190/// directory.
191fn ledger_path(target: &Utf8Path) -> Option<std::path::PathBuf> {
192 let answer = git(
193 target,
194 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
195 )
196 .ok()
197 .filter(|answer| answer.status.success())?;
198 let dir = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
199 Some(std::path::Path::new(&dir).join(crate::integrate::LEDGER_PATH))
200}
201
202/// The last non-empty stderr line, for a one-line detail.
203pub(crate) fn last_line(bytes: &[u8]) -> String {
204 String::from_utf8_lossy(bytes)
205 .lines()
206 .rev()
207 .find(|line| !line.trim().is_empty())
208 .unwrap_or("no output")
209 .to_owned()
210}
211
212#[cfg(test)]
213mod tests {
214 use super::row_owes;
215
216 /// The `(status, detail)` matrix behind the closing line: every row
217 /// that still names a move owes, and only finished rows do not.
218 #[test]
219 fn a_row_owes_until_nothing_is_left_to_ask() {
220 for status in [
221 "candidate",
222 "kept",
223 "stale",
224 "confirmed",
225 "unconfirmed",
226 "unknown",
227 "worktree-bound",
228 "delete-failed",
229 "remove-failed",
230 "branch-delete-failed",
231 ] {
232 assert!(row_owes(status, None), "{status} names a move");
233 assert!(row_owes(status, Some("detail")), "{status} names a move");
234 }
235 for finished in ["deleted", "pruned"] {
236 assert!(
237 row_owes(finished, Some("the branch configuration survives")),
238 "surviving residue is still owed"
239 );
240 assert!(!row_owes(finished, None), "done is done");
241 }
242 }
243}