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 mut command = std::process::Command::new("git");
71 for var in crate::maintenance::GIT_HOOK_VARS {
72 command.env_remove(var);
73 }
74 let answered = command
75 .arg("-C")
76 .arg(target.as_std_path())
77 .args(["rev-parse", "--git-path", "hooks"])
78 .output()
79 .map_err(|source| format!("git did not run: {source}"))?;
80 if !answered.status.success() {
81 return Err(format!("{target} is not a git repository"));
82 }
83 let hooks = String::from_utf8_lossy(&answered.stdout).trim().to_owned();
84 if hooks.is_empty() {
85 return Err("git named no hooks directory".to_owned());
86 }
87 let hooks = PathBuf::from(hooks);
88 let hooks = if hooks.is_absolute() {
89 hooks
90 } else {
91 target.as_std_path().join(hooks)
92 };
93 Ok(hooks.join("post-merge"))
94}
95
96#[must_use]
98pub fn observe_hook(target: &Utf8Path) -> HookState {
99 let path = match hook_path(target) {
100 Ok(path) => path,
101 Err(detail) => return HookState::Unreadable(detail),
102 };
103 match std::fs::symlink_metadata(&path) {
108 Err(source) if source.kind() == std::io::ErrorKind::NotFound => return HookState::Absent,
109 Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
110 Ok(meta) if !meta.is_file() => return HookState::Foreign,
111 Ok(_) => {}
112 }
113 let bytes = match std::fs::read(&path) {
114 Ok(bytes) => bytes,
115 Err(source) => return HookState::Unreadable(format!("{}: {source}", path.display())),
116 };
117 if !String::from_utf8_lossy(&bytes).contains(MARKER) {
118 return HookState::Foreign;
119 }
120 let executable = {
121 #[cfg(unix)]
122 {
123 use std::os::unix::fs::PermissionsExt as _;
124 std::fs::metadata(&path).is_ok_and(|meta| meta.permissions().mode() & 0o111 != 0)
125 }
126 #[cfg(not(unix))]
127 {
128 true
129 }
130 };
131 if bytes == HOOK_BODY.as_bytes() && executable {
132 HookState::Installed
133 } else {
134 HookState::Drifted
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 #[test]
144 fn the_hook_body_carries_the_marker_and_never_fails() {
145 assert!(super::HOOK_BODY.starts_with("#!/bin/sh\n"));
146 assert!(super::HOOK_BODY.contains(super::MARKER));
147 for verb in ["branches", "worktree"] {
148 assert!(
149 super::HOOK_BODY
150 .contains(&format!("if rk {verb} prune --help >/dev/null 2>&1; then")),
151 "the {verb} call is guarded by its own capability probe"
152 );
153 assert!(
154 super::HOOK_BODY.contains(&format!("rk {verb} prune --quiet || :")),
155 "the {verb} prune runs quiet and never fails the pull"
156 );
157 }
158 assert!(
159 !super::HOOK_BODY.contains("command -v"),
160 "a presence check answers the wrong question; the probe is per verb"
161 );
162 assert!(super::HOOK_BODY.ends_with("exit 0\n"));
163 }
164}