1use std::collections::BTreeSet;
7use std::hash::{Hash, Hasher};
8use std::path::Path;
9
10use crate::config::{DEFAULT_CONFIG_FILE, HooksConfig};
11
12const GENERATED_MARKER: &str = "# Generated by sr";
14
15const HASH_FILE: &str = ".sr-hooks-hash";
17
18pub fn sync_hooks(
26 repo_root: &Path,
27 config: &HooksConfig,
28) -> Result<bool, crate::error::ReleaseError> {
29 let hooks_dir = repo_root.join(".githooks");
30 let hash_path = hooks_dir.join(HASH_FILE);
31 let current_hash = config_hash(config);
32
33 if let Ok(stored) = std::fs::read_to_string(&hash_path)
35 && stored.trim() == current_hash
36 {
37 return Ok(false);
38 }
39
40 let configured: BTreeSet<&str> = config
41 .hooks
42 .iter()
43 .filter(|(_, entries)| !entries.is_empty())
44 .map(|(name, _)| name.as_str())
45 .collect();
46
47 if configured.is_empty() {
48 let removed = remove_stale_hooks(&hooks_dir, &configured)?;
49 let _ = std::fs::remove_file(&hash_path);
51 return Ok(removed);
52 }
53
54 std::fs::create_dir_all(&hooks_dir).map_err(|e| {
55 crate::error::ReleaseError::Config(format!("failed to create .githooks: {e}"))
56 })?;
57
58 let mut changed = false;
59
60 for &hook_name in &configured {
61 let hook_path = hooks_dir.join(hook_name);
62 let expected = shim_script(hook_name);
63
64 match std::fs::read_to_string(&hook_path) {
65 Ok(existing) if existing == expected => {
66 }
68 Ok(existing) if existing.contains(GENERATED_MARKER) => {
69 write_shim(&hook_path, &expected)?;
71 changed = true;
72 }
73 Ok(_) => {
74 let backup = hooks_dir.join(format!("{hook_name}.bak"));
76 std::fs::rename(&hook_path, &backup).map_err(|e| {
77 crate::error::ReleaseError::Config(format!(
78 "failed to backup .githooks/{hook_name}: {e}"
79 ))
80 })?;
81 eprintln!("backed up .githooks/{hook_name} → .githooks/{hook_name}.bak");
82 write_shim(&hook_path, &expected)?;
83 changed = true;
84 }
85 Err(_) => {
86 write_shim(&hook_path, &expected)?;
88 changed = true;
89 }
90 }
91 }
92
93 if remove_stale_hooks(&hooks_dir, &configured)? {
94 changed = true;
95 }
96
97 std::fs::write(&hash_path, ¤t_hash).map_err(|e| {
99 crate::error::ReleaseError::Config(format!("failed to write hooks hash: {e}"))
100 })?;
101
102 if changed {
103 set_hooks_path(repo_root);
104 }
105
106 Ok(changed)
107}
108
109pub fn needs_sync(repo_root: &Path, config: &HooksConfig) -> bool {
111 let hash_path = repo_root.join(".githooks").join(HASH_FILE);
112 match std::fs::read_to_string(&hash_path) {
113 Ok(stored) => stored.trim() != config_hash(config),
114 Err(_) => {
115 !config.hooks.is_empty()
117 }
118 }
119}
120
121fn config_hash(config: &HooksConfig) -> String {
123 let json = serde_json::to_string(&config.hooks).unwrap_or_default();
124 let mut hasher = std::collections::hash_map::DefaultHasher::new();
125 json.hash(&mut hasher);
126 format!("{:016x}", hasher.finish())
127}
128
129fn shim_script(hook_name: &str) -> String {
131 format!(
132 "#!/usr/bin/env sh\n\
133 {GENERATED_MARKER} — edit the hooks section in {config} to modify.\n\
134 exec sr hook run {hook_name} -- \"$@\"\n",
135 config = DEFAULT_CONFIG_FILE,
136 )
137}
138
139fn write_shim(path: &Path, content: &str) -> Result<(), crate::error::ReleaseError> {
141 std::fs::write(path, content)
142 .map_err(|e| crate::error::ReleaseError::Config(format!("failed to write hook: {e}")))?;
143
144 #[cfg(unix)]
145 {
146 use std::os::unix::fs::PermissionsExt;
147 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
148 crate::error::ReleaseError::Config(format!("failed to chmod hook: {e}"))
149 })?;
150 }
151
152 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
153 eprintln!("synced .githooks/{name}");
154 }
155
156 Ok(())
157}
158
159fn remove_stale_hooks(
161 hooks_dir: &Path,
162 configured: &BTreeSet<&str>,
163) -> Result<bool, crate::error::ReleaseError> {
164 if !hooks_dir.is_dir() {
165 return Ok(false);
166 }
167
168 let mut removed = false;
169 let entries = std::fs::read_dir(hooks_dir).map_err(|e| {
170 crate::error::ReleaseError::Config(format!("failed to read .githooks: {e}"))
171 })?;
172
173 for entry in entries {
174 let entry = entry.map_err(|e| crate::error::ReleaseError::Config(e.to_string()))?;
175 let path = entry.path();
176
177 if !path.is_file() {
178 continue;
179 }
180
181 let name = match path.file_name().and_then(|n| n.to_str()) {
182 Some(n) => n.to_string(),
183 None => continue,
184 };
185
186 if name == HASH_FILE || name.ends_with(".bak") {
188 continue;
189 }
190
191 if !is_sr_managed(&path) {
193 continue;
194 }
195
196 if !configured.contains(name.as_str()) {
197 std::fs::remove_file(&path).map_err(|e| {
198 crate::error::ReleaseError::Config(format!(
199 "failed to remove .githooks/{name}: {e}"
200 ))
201 })?;
202 eprintln!("removed stale .githooks/{name}");
203 removed = true;
204 }
205 }
206
207 Ok(removed)
208}
209
210fn is_sr_managed(path: &Path) -> bool {
212 std::fs::read_to_string(path)
213 .map(|content| content.contains(GENERATED_MARKER))
214 .unwrap_or(false)
215}
216
217fn set_hooks_path(repo_root: &Path) {
219 let _ = std::process::Command::new("git")
220 .args(["config", "core.hooksPath", ".githooks/"])
221 .current_dir(repo_root)
222 .status();
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::config::HookEntry;
229 use std::collections::BTreeMap;
230
231 fn make_config(hooks: &[(&str, Vec<HookEntry>)]) -> HooksConfig {
232 let mut map = BTreeMap::new();
233 for (name, entries) in hooks {
234 map.insert(name.to_string(), entries.clone());
235 }
236 HooksConfig { hooks: map }
237 }
238
239 #[test]
240 fn creates_hook_scripts() {
241 let dir = tempfile::tempdir().unwrap();
242 let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
243
244 let changed = sync_hooks(dir.path(), &config).unwrap();
245 assert!(changed);
246
247 let hook = dir.path().join(".githooks/pre-commit");
248 assert!(hook.exists());
249 let content = std::fs::read_to_string(&hook).unwrap();
250 assert!(content.contains("sr hook run pre-commit"));
251 assert!(content.contains(GENERATED_MARKER));
252 }
253
254 #[test]
255 fn idempotent_returns_false() {
256 let dir = tempfile::tempdir().unwrap();
257 let config = make_config(&[(
258 "commit-msg",
259 vec![HookEntry::Simple("sr hook commit-msg".into())],
260 )]);
261
262 assert!(sync_hooks(dir.path(), &config).unwrap());
263 assert!(!sync_hooks(dir.path(), &config).unwrap());
265 }
266
267 #[test]
268 fn removes_stale_hooks() {
269 let dir = tempfile::tempdir().unwrap();
270 let hooks_dir = dir.path().join(".githooks");
271 std::fs::create_dir_all(&hooks_dir).unwrap();
272
273 std::fs::write(
275 hooks_dir.join("pre-push"),
276 format!("{GENERATED_MARKER}\nold script"),
277 )
278 .unwrap();
279
280 std::fs::write(hooks_dir.join("post-checkout"), "#!/bin/sh\necho custom").unwrap();
282
283 let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
284
285 sync_hooks(dir.path(), &config).unwrap();
286
287 assert!(
288 !hooks_dir.join("pre-push").exists(),
289 "stale sr-managed hook should be removed"
290 );
291 assert!(
292 hooks_dir.join("post-checkout").exists(),
293 "non-sr-managed hook should be preserved"
294 );
295 assert!(hooks_dir.join("pre-commit").exists());
296 }
297
298 #[test]
299 fn backs_up_conflicting_hooks() {
300 let dir = tempfile::tempdir().unwrap();
301 let hooks_dir = dir.path().join(".githooks");
302 std::fs::create_dir_all(&hooks_dir).unwrap();
303
304 let custom_content = "#!/bin/sh\necho custom commit-msg hook";
306 std::fs::write(hooks_dir.join("commit-msg"), custom_content).unwrap();
307
308 let config = make_config(&[(
309 "commit-msg",
310 vec![HookEntry::Simple("sr hook commit-msg".into())],
311 )]);
312
313 sync_hooks(dir.path(), &config).unwrap();
314
315 let backup = hooks_dir.join("commit-msg.bak");
317 assert!(backup.exists());
318 assert_eq!(std::fs::read_to_string(&backup).unwrap(), custom_content);
319
320 let content = std::fs::read_to_string(hooks_dir.join("commit-msg")).unwrap();
322 assert!(content.contains("sr hook run commit-msg"));
323 }
324
325 #[test]
326 fn empty_config_cleans_up() {
327 let dir = tempfile::tempdir().unwrap();
328 let hooks_dir = dir.path().join(".githooks");
329 std::fs::create_dir_all(&hooks_dir).unwrap();
330
331 std::fs::write(
332 hooks_dir.join("pre-commit"),
333 format!("{GENERATED_MARKER}\nscript"),
334 )
335 .unwrap();
336 std::fs::write(hooks_dir.join(".sr-hooks-hash"), "oldhash").unwrap();
337
338 let config = make_config(&[]);
339 sync_hooks(dir.path(), &config).unwrap();
340
341 assert!(!hooks_dir.join("pre-commit").exists());
342 assert!(!hooks_dir.join(".sr-hooks-hash").exists());
343 }
344
345 #[test]
346 fn needs_sync_detects_changes() {
347 let dir = tempfile::tempdir().unwrap();
348 let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
349
350 assert!(needs_sync(dir.path(), &config));
351
352 sync_hooks(dir.path(), &config).unwrap();
353 assert!(!needs_sync(dir.path(), &config));
354
355 let config2 =
357 make_config(&[("pre-commit", vec![HookEntry::Simple("echo changed".into())])]);
358 assert!(needs_sync(dir.path(), &config2));
359 }
360}