Skip to main content

release_kit/setup/
branch_reminder.rs

1//! The post-merge reminder hook `rk setup step branch-reminder` writes.
2//!
3//! No git event fires when the forge squash-merges and deletes a branch;
4//! the nearest local event is the pull that fetches the result, which is a
5//! merge, so `post-merge` fires with the `[gone]` marker freshly true.
6//! The hook only reminds — `rk branches prune --quiet` prints nothing
7//! when the clone is clean and never deletes — and it never blocks a
8//! pull. The body is a Rust const rather than a `setup/<forge>/` script:
9//! it belongs to no forge, and the forge trees hold one script per forge
10//! step by the parity rule.
11
12use std::path::PathBuf;
13
14use camino::Utf8Path;
15
16/// The marker line a reminder hook carries; its absence makes a hook
17/// foreign, and a foreign hook is never written over.
18pub const MARKER: &str = "# release-kit branch reminder";
19
20/// The whole hook, byte for byte. No `set -eu` on purpose: the contract
21/// is exit 0 always, and the `|| :` plus the final line hold it.
22pub const HOOK_BODY: &str = "#!/bin/sh
23# release-kit branch reminder
24# Installed by rk setup step branch-reminder; rerunning that step rewrites it.
25# After a merge arrives, report local branches the forge already merged and
26# deleted. Prints nothing when there are none, and never blocks a pull.
27if command -v rk >/dev/null 2>&1; then
28  rk branches prune --quiet || :
29fi
30exit 0
31";
32
33/// What the hook file holds today.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum HookState {
36    /// The file is this binary's body, executable.
37    Installed,
38    /// The marker is present but the body or the mode drifted.
39    Drifted,
40    /// A post-merge hook exists without the marker; it is someone else's.
41    Foreign,
42    /// No post-merge hook exists.
43    Absent,
44    /// The hooks directory or the file could not be read.
45    Unreadable(String),
46}
47
48/// Where git will look for the post-merge hook: `rev-parse --git-path`
49/// answers through gitfiles, linked worktrees, and `core.hooksPath`, and
50/// a relative answer is relative to the target it ran in.
51///
52/// # Errors
53///
54/// The detail of a git that did not run or did not answer.
55pub fn hook_path(target: &Utf8Path) -> Result<PathBuf, String> {
56    let answered = std::process::Command::new("git")
57        .arg("-C")
58        .arg(target.as_std_path())
59        .args(["rev-parse", "--git-path", "hooks"])
60        .output()
61        .map_err(|source| format!("git did not run: {source}"))?;
62    if !answered.status.success() {
63        return Err(format!("{target} is not a git repository"));
64    }
65    let hooks = String::from_utf8_lossy(&answered.stdout).trim().to_owned();
66    if hooks.is_empty() {
67        return Err("git named no hooks directory".to_owned());
68    }
69    let hooks = PathBuf::from(hooks);
70    let hooks = if hooks.is_absolute() {
71        hooks
72    } else {
73        target.as_std_path().join(hooks)
74    };
75    Ok(hooks.join("post-merge"))
76}
77
78/// Read the hook file and judge it against this binary's body.
79#[must_use]
80pub fn observe_hook(target: &Utf8Path) -> HookState {
81    let path = match hook_path(target) {
82        Ok(path) => path,
83        Err(detail) => return HookState::Unreadable(detail),
84    };
85    // Judge the entry itself, not what it points at: a symlink - dangling
86    // or not - is another manager's installation style, and a read
87    // through it would misclassify the dangling case as absent and let
88    // the atomic writer's rename replace the link.
89    match std::fs::symlink_metadata(&path) {
90        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return HookState::Absent,
91        Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
92        Ok(meta) if !meta.is_file() => return HookState::Foreign,
93        Ok(_) => {}
94    }
95    let bytes = match std::fs::read(&path) {
96        Ok(bytes) => bytes,
97        Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
98    };
99    if !String::from_utf8_lossy(&bytes).contains(MARKER) {
100        return HookState::Foreign;
101    }
102    let executable = {
103        #[cfg(unix)]
104        {
105            use std::os::unix::fs::PermissionsExt as _;
106            std::fs::metadata(&path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
107        }
108        #[cfg(not(unix))]
109        {
110            true
111        }
112    };
113    if bytes == HOOK_BODY.as_bytes() && executable {
114        HookState::Installed
115    } else {
116        HookState::Drifted
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    /// The body opens with a shebang, carries the marker, names the quiet
123    /// prune, and ends by succeeding whatever happened above.
124    #[test]
125    fn the_hook_body_carries_the_marker_and_never_fails() {
126        assert!(super::HOOK_BODY.starts_with("#!/bin/sh\n"));
127        assert!(super::HOOK_BODY.contains(super::MARKER));
128        assert!(super::HOOK_BODY.contains("rk branches prune --quiet"));
129        assert!(super::HOOK_BODY.ends_with("exit 0\n"));
130    }
131}