release_kit/setup/
branch_reminder.rs1use std::path::PathBuf;
19
20use camino::Utf8Path;
21
22pub const MARKER: &str = "# release-kit branch reminder";
25
26pub const HOOK_BODY: &str = "#!/bin/sh
34# release-kit branch reminder
35# Installed by rk setup step branch-reminder; rerunning that step rewrites it.
36# After a merge arrives, report the branches and worktrees the forge already
37# merged and retired. Prints nothing when there are none, never blocks a pull.
38if rk branches prune --help >/dev/null 2>&1; then
39 rk branches prune --quiet || :
40fi
41if rk worktree prune --help >/dev/null 2>&1; then
42 rk worktree prune --quiet || :
43fi
44exit 0
45";
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum HookState {
50 Installed,
52 Drifted,
54 Foreign,
56 Absent,
58 Unreadable(String),
60}
61
62pub fn hook_path(target: &Utf8Path) -> Result<PathBuf, String> {
70 let answered = std::process::Command::new("git")
71 .arg("-C")
72 .arg(target.as_std_path())
73 .args(["rev-parse", "--git-path", "hooks"])
74 .output()
75 .map_err(|source| format!("git did not run: {source}"))?;
76 if !answered.status.success() {
77 return Err(format!("{target} is not a git repository"));
78 }
79 let hooks = String::from_utf8_lossy(&answered.stdout).trim().to_owned();
80 if hooks.is_empty() {
81 return Err("git named no hooks directory".to_owned());
82 }
83 let hooks = PathBuf::from(hooks);
84 let hooks = if hooks.is_absolute() {
85 hooks
86 } else {
87 target.as_std_path().join(hooks)
88 };
89 Ok(hooks.join("post-merge"))
90}
91
92#[must_use]
94pub fn observe_hook(target: &Utf8Path) -> HookState {
95 let path = match hook_path(target) {
96 Ok(path) => path,
97 Err(detail) => return HookState::Unreadable(detail),
98 };
99 match std::fs::symlink_metadata(&path) {
104 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return HookState::Absent,
105 Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
106 Ok(meta) if !meta.is_file() => return HookState::Foreign,
107 Ok(_) => {}
108 }
109 let bytes = match std::fs::read(&path) {
110 Ok(bytes) => bytes,
111 Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
112 };
113 if !String::from_utf8_lossy(&bytes).contains(MARKER) {
114 return HookState::Foreign;
115 }
116 let executable = {
117 #[cfg(unix)]
118 {
119 use std::os::unix::fs::PermissionsExt as _;
120 std::fs::metadata(&path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
121 }
122 #[cfg(not(unix))]
123 {
124 true
125 }
126 };
127 if bytes == HOOK_BODY.as_bytes() && executable {
128 HookState::Installed
129 } else {
130 HookState::Drifted
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 #[test]
140 fn the_hook_body_carries_the_marker_and_never_fails() {
141 assert!(super::HOOK_BODY.starts_with("#!/bin/sh\n"));
142 assert!(super::HOOK_BODY.contains(super::MARKER));
143 for verb in ["branches", "worktree"] {
144 assert!(
145 super::HOOK_BODY
146 .contains(&format!("if rk {verb} prune --help >/dev/null 2>&1; then")),
147 "the {verb} call is guarded by its own capability probe"
148 );
149 assert!(
150 super::HOOK_BODY.contains(&format!("rk {verb} prune --quiet || :")),
151 "the {verb} prune runs quiet and never fails the pull"
152 );
153 }
154 assert!(
155 !super::HOOK_BODY.contains("command -v"),
156 "a presence check answers the wrong question; the probe is per verb"
157 );
158 assert!(super::HOOK_BODY.ends_with("exit 0\n"));
159 }
160}