Skip to main content

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", &section]) {
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}