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, and the observation of the proof no record holds, have one
6//! implementation here, and exactly two callers invoke
7//! it: `crate::commands::branches` and `crate::commands::worktree`. The
8//! module spawns git, which is why it sits beside the pure `branches` and
9//! `worktree` modules rather than inside either: both declare themselves
10//! parsing and classification only. The report-closing rule the two prune
11//! verbs share lives here too, so the pair cannot fork.
12
13use camino::Utf8Path;
14
15/// The outcome of deleting one branch at a verified tip.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Deletion {
18 /// The ref and its configuration section are gone.
19 Deleted,
20 /// The ref is gone and a `branch.<name>` configuration section
21 /// survives; something is still owed, and the detail names the move.
22 ConfigSurvived {
23 /// What survived and the command that clears it.
24 detail: String,
25 },
26 /// The compare-and-swap refused: the tip moved, or git did not run.
27 Refused {
28 /// Git's own reason, last line.
29 detail: String,
30 },
31}
32
33/// Delete one branch whose tip verification authorized, compare-and-swap.
34///
35/// `git update-ref -d` carries the verified tip, so a ref that moved
36/// after verification is refused, never lost. A deleted ref then drops
37/// its `branch.<name>` configuration section — what `git branch -d`
38/// would have removed beside it — so a later branch under the reused
39/// name inherits nothing stale.
40#[must_use]
41pub fn delete_branch(target: &Utf8Path, branch: &str, verified_tip: &str) -> Deletion {
42 let ref_name = format!("refs/heads/{branch}");
43 let deleted = match git(target, &["update-ref", "-d", &ref_name, verified_tip]) {
44 Ok(output) => output,
45 Err(detail) => return Deletion::Refused { detail },
46 };
47 if !deleted.status.success() {
48 return Deletion::Refused {
49 detail: last_line(&deleted.stderr),
50 };
51 }
52 // A section that was never written makes the removal fail, which is
53 // the common clean case; entries that survive the attempt are the
54 // reportable residue.
55 let section = format!("branch.{branch}");
56 let survives = match git(target, &["config", "--remove-section", §ion]) {
57 Ok(removed) if removed.status.success() => false,
58 Ok(_) => {
59 // Enumerate rather than pattern-match: a branch name can carry
60 // regex metacharacters, so the filter is an exact prefix test
61 // over the fixed-pattern listing.
62 let prefix = format!("branch.{branch}.");
63 match git(target, &["config", "--get-regexp", "^branch\\."]) {
64 Ok(leftover) => {
65 leftover.status.success()
66 && String::from_utf8_lossy(&leftover.stdout)
67 .lines()
68 .any(|line| line.starts_with(&prefix))
69 }
70 Err(_) => true,
71 }
72 }
73 Err(_) => true,
74 };
75 if survives {
76 return Deletion::ConfigSurvived {
77 detail: format!(
78 "the branch configuration survives: git config --remove-section branch.{branch}"
79 ),
80 };
81 }
82 Deletion::Deleted
83}
84
85/// Whether one report row still names a move the operator may make.
86///
87/// The closing operator line of both prune reports rides this predicate,
88/// never the mode: a preview's candidates, every kept and judged row, and
89/// every failure row owe — each failure's `detail` is required to carry
90/// its recovery, which is why it owes despite the exit code — and so does
91/// a `deleted` row whose detail reports surviving configuration. Done is
92/// done: `deleted` with no residue and `pruned` owe nothing.
93#[must_use]
94pub fn row_owes(status: &str, detail: Option<&str>) -> bool {
95 match status {
96 "deleted" | "pruned" => detail.is_some(),
97 _ => true,
98 }
99}
100
101/// The variables a running git hook exports; a child inheriting them
102/// would resolve the hook's repository instead of the `-C` target, so
103/// every git this crate spawns against a named target scrubs them.
104pub(crate) const GIT_HOOK_VARS: [&str; 4] = [
105 "GIT_DIR",
106 "GIT_WORK_TREE",
107 "GIT_INDEX_FILE",
108 "GIT_COMMON_DIR",
109];
110
111/// Run one git command against the target; a spawn failure is the detail.
112fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, String> {
113 let mut command = std::process::Command::new(crate::probes::git_bin());
114 for var in GIT_HOOK_VARS {
115 command.env_remove(var);
116 }
117 command
118 .arg("-C")
119 .arg(target.as_std_path())
120 .args(args)
121 .output()
122 .map_err(|source| format!("git did not run: {source}"))
123}
124
125/// The clone's local integration ledger, keeping only the entries the
126/// trunk actually carries.
127///
128/// Both prune verbs read this, so the read has one owner beside the
129/// deletion discipline they already share. A clone that never integrated
130/// locally, a git that cannot answer, and a ledger this binary cannot
131/// parse all read as empty here: an empty ledger authorizes no deletion,
132/// which is the safe direction, and `rk integrate` is what refuses on an
133/// unreadable ledger, where refusing costs nothing.
134///
135/// An entry whose commit the trunk does not reach is dropped. Evidence is
136/// staged before its publication, so an integration whose publication
137/// failed leaves an entry naming a commit no ref carries, and an entry
138/// can also outlive a trunk someone reset. Reading reachability here is
139/// what makes that residue inert: the proof is the trunk carrying the
140/// work, never the ledger saying so.
141#[must_use]
142pub fn integration_ledger(target: &Utf8Path, trunk: &str) -> crate::integrate::Ledger {
143 let Some(path) = ledger_path(target) else {
144 return crate::integrate::Ledger::default();
145 };
146 let mut ledger: crate::integrate::Ledger = std::fs::read_to_string(path)
147 .ok()
148 .and_then(|text| crate::integrate::Ledger::parse(&text).ok())
149 .unwrap_or_default();
150 ledger
151 .entries
152 .retain(|entry| trunk_reaches(target, trunk, &entry.trunk_commit));
153 ledger
154}
155
156/// The third proof for one branch, observed: the trunk reaches its tip and
157/// its reflog's creation entry names another commit.
158///
159/// The trunk and the release lines never carry it: they are guarded out
160/// of every prune, and a trunk always reaches its own tip, so asking would
161/// only put a protected branch in every report. Every read that cannot
162/// answer — no reflog, a reflog git keeps outside the files backend, a
163/// git that does not run — reads as no proof, which keeps the branch.
164///
165/// SATISFIES maintenance:one-merge-proof-authorizes-both-removals
166#[must_use]
167pub fn reachable_tip(
168 target: &Utf8Path,
169 trunk: &str,
170 branch: &str,
171 tip: &str,
172) -> Option<crate::branches::Proof> {
173 if branch == trunk || branch.starts_with(crate::branches::PROTECTED_PREFIX) {
174 return None;
175 }
176 if !trunk_reaches(target, trunk, tip) {
177 return None;
178 }
179 let created = creation_of(target, branch);
180 crate::branches::reachable_tip(created.as_deref(), tip, true)
181}
182
183/// The commit a branch's reflog creation entry names, where one survives.
184///
185/// git names the reflog's path itself, which resolves to the common
186/// directory from any worktree; the files backend keeps it there as the
187/// raw `<old> <new>` lines this reads. A ref store with no such file
188/// answers nothing, and the proof is then unavailable.
189fn creation_of(target: &Utf8Path, branch: &str) -> Option<String> {
190 let answer = git(
191 target,
192 &[
193 "rev-parse",
194 "--path-format=absolute",
195 "--git-path",
196 &format!("logs/refs/heads/{branch}"),
197 ],
198 )
199 .ok()
200 .filter(|answer| answer.status.success())?;
201 let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
202 let text = std::fs::read_to_string(path).ok()?;
203 crate::branches::creation_entry(&text)
204}
205
206/// Whether the trunk reaches one commit.
207///
208/// A git that cannot answer reads as unreachable, which keeps the branch
209/// rather than deleting it. The prune verbs re-ask it at the moment of
210/// action for the third proof, which no record holds.
211#[must_use]
212pub fn trunk_reaches(target: &Utf8Path, trunk: &str, commit: &str) -> bool {
213 git(target, &["merge-base", "--is-ancestor", commit, trunk])
214 .is_ok_and(|answer| answer.status.success())
215}
216
217/// Drop one branch's entry from the ledger, after the branch is gone.
218///
219/// A retired branch's evidence has nothing left to prove, and a name is
220/// reused: keeping the entry would leave the ledger growing and a stale
221/// answer standing. A failure here is silent, because the deletion it
222/// follows already succeeded and the residue proves nothing.
223pub fn forget_integration(target: &Utf8Path, branch: &str) {
224 let Some(path) = ledger_path(target) else {
225 return;
226 };
227 let Ok(text) = std::fs::read_to_string(&path) else {
228 return;
229 };
230 let Ok(mut ledger) = crate::integrate::Ledger::parse(&text) else {
231 return;
232 };
233 if !ledger.names(branch) {
234 return;
235 }
236 ledger.forget(branch);
237 if let Ok(rendered) = ledger.render() {
238 let _ = crate::atomic::write(&path, rendered.as_bytes());
239 }
240}
241
242/// The ledger's path in this clone, where git can name its common
243/// directory.
244fn ledger_path(target: &Utf8Path) -> Option<std::path::PathBuf> {
245 let answer = git(
246 target,
247 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
248 )
249 .ok()
250 .filter(|answer| answer.status.success())?;
251 let dir = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
252 Some(std::path::Path::new(&dir).join(crate::integrate::LEDGER_PATH))
253}
254
255/// The last non-empty stderr line, for a one-line detail.
256pub(crate) fn last_line(bytes: &[u8]) -> String {
257 String::from_utf8_lossy(bytes)
258 .lines()
259 .rev()
260 .find(|line| !line.trim().is_empty())
261 .unwrap_or("no output")
262 .to_owned()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::row_owes;
268
269 /// The `(status, detail)` matrix behind the closing line: every row
270 /// that still names a move owes, and only finished rows do not.
271 #[test]
272 fn a_row_owes_until_nothing_is_left_to_ask() {
273 for status in [
274 "candidate",
275 "kept",
276 "stale",
277 "confirmed",
278 "unconfirmed",
279 "unknown",
280 "worktree-bound",
281 "delete-failed",
282 "remove-failed",
283 "branch-delete-failed",
284 ] {
285 assert!(row_owes(status, None), "{status} names a move");
286 assert!(row_owes(status, Some("detail")), "{status} names a move");
287 }
288 for finished in ["deleted", "pruned"] {
289 assert!(
290 row_owes(finished, Some("the branch configuration survives")),
291 "surviving residue is still owed"
292 );
293 assert!(!row_owes(finished, None), "done is done");
294 }
295 }
296}