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