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/// Run one git command against the target; a spawn failure is the detail.
101fn git(target: &Utf8Path, args: &[&str]) -> Result<std::process::Output, String> {
102    std::process::Command::new("git")
103        .arg("-C")
104        .arg(target.as_std_path())
105        .args(args)
106        .output()
107        .map_err(|source| format!("git did not run: {source}"))
108}
109
110/// The last non-empty stderr line, for a one-line detail.
111pub(crate) fn last_line(bytes: &[u8]) -> String {
112    String::from_utf8_lossy(bytes)
113        .lines()
114        .rev()
115        .find(|line| !line.trim().is_empty())
116        .unwrap_or("no output")
117        .to_owned()
118}
119
120#[cfg(test)]
121mod tests {
122    use super::row_owes;
123
124    /// The `(status, detail)` matrix behind the closing line: every row
125    /// that still names a move owes, and only finished rows do not.
126    #[test]
127    fn a_row_owes_until_nothing_is_left_to_ask() {
128        for status in [
129            "candidate",
130            "kept",
131            "stale",
132            "confirmed",
133            "unconfirmed",
134            "unknown",
135            "worktree-bound",
136            "delete-failed",
137            "remove-failed",
138            "branch-delete-failed",
139        ] {
140            assert!(row_owes(status, None), "{status} names a move");
141            assert!(row_owes(status, Some("detail")), "{status} names a move");
142        }
143        for finished in ["deleted", "pruned"] {
144            assert!(
145                row_owes(finished, Some("the branch configuration survives")),
146                "surviving residue is still owed"
147            );
148            assert!(!row_owes(finished, None), "done is done");
149        }
150    }
151}