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("git");
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 last non-empty stderr line, for a one-line detail.
125pub(crate) fn last_line(bytes: &[u8]) -> String {
126    String::from_utf8_lossy(bytes)
127        .lines()
128        .rev()
129        .find(|line| !line.trim().is_empty())
130        .unwrap_or("no output")
131        .to_owned()
132}
133
134#[cfg(test)]
135mod tests {
136    use super::row_owes;
137
138    /// The `(status, detail)` matrix behind the closing line: every row
139    /// that still names a move owes, and only finished rows do not.
140    #[test]
141    fn a_row_owes_until_nothing_is_left_to_ask() {
142        for status in [
143            "candidate",
144            "kept",
145            "stale",
146            "confirmed",
147            "unconfirmed",
148            "unknown",
149            "worktree-bound",
150            "delete-failed",
151            "remove-failed",
152            "branch-delete-failed",
153        ] {
154            assert!(row_owes(status, None), "{status} names a move");
155            assert!(row_owes(status, Some("detail")), "{status} names a move");
156        }
157        for finished in ["deleted", "pruned"] {
158            assert!(
159                row_owes(finished, Some("the branch configuration survives")),
160                "surviving residue is still owed"
161            );
162            assert!(!row_owes(finished, None), "done is done");
163        }
164    }
165}