1use std::collections::BTreeSet;
7use std::hash::{Hash, Hasher};
8use std::path::Path;
9
10use crate::config::{DEFAULT_CONFIG_FILE, HookEntry, HooksConfig, ReleaseConfig};
11use crate::error::ReleaseError;
12
13const GENERATED_MARKER: &str = "# Generated by sr";
15
16const HASH_FILE: &str = ".sr-hooks-hash";
18
19pub fn sync_hooks(
27 repo_root: &Path,
28 config: &HooksConfig,
29) -> Result<bool, crate::error::ReleaseError> {
30 let hooks_dir = repo_root.join(".githooks");
31 let hash_path = hooks_dir.join(HASH_FILE);
32 let current_hash = config_hash(config);
33
34 if let Ok(stored) = std::fs::read_to_string(&hash_path)
36 && stored.trim() == current_hash
37 {
38 return Ok(false);
39 }
40
41 let configured: BTreeSet<&str> = config
42 .hooks
43 .iter()
44 .filter(|(_, entries)| !entries.is_empty())
45 .map(|(name, _)| name.as_str())
46 .collect();
47
48 if configured.is_empty() {
49 let removed = remove_stale_hooks(&hooks_dir, &configured)?;
50 let _ = std::fs::remove_file(&hash_path);
52 return Ok(removed);
53 }
54
55 std::fs::create_dir_all(&hooks_dir).map_err(|e| {
56 crate::error::ReleaseError::Config(format!("failed to create .githooks: {e}"))
57 })?;
58
59 let mut changed = false;
60
61 for &hook_name in &configured {
62 let hook_path = hooks_dir.join(hook_name);
63 let expected = shim_script(hook_name);
64
65 match std::fs::read_to_string(&hook_path) {
66 Ok(existing) if existing == expected => {
67 }
69 Ok(existing) if existing.contains(GENERATED_MARKER) => {
70 write_shim(&hook_path, &expected)?;
72 changed = true;
73 }
74 Ok(_) => {
75 let backup = hooks_dir.join(format!("{hook_name}.bak"));
77 std::fs::rename(&hook_path, &backup).map_err(|e| {
78 crate::error::ReleaseError::Config(format!(
79 "failed to backup .githooks/{hook_name}: {e}"
80 ))
81 })?;
82 eprintln!("backed up .githooks/{hook_name} → .githooks/{hook_name}.bak");
83 write_shim(&hook_path, &expected)?;
84 changed = true;
85 }
86 Err(_) => {
87 write_shim(&hook_path, &expected)?;
89 changed = true;
90 }
91 }
92 }
93
94 if remove_stale_hooks(&hooks_dir, &configured)? {
95 changed = true;
96 }
97
98 std::fs::write(&hash_path, ¤t_hash).map_err(|e| {
100 crate::error::ReleaseError::Config(format!("failed to write hooks hash: {e}"))
101 })?;
102
103 if changed {
104 set_hooks_path(repo_root);
105 }
106
107 Ok(changed)
108}
109
110pub fn needs_sync(repo_root: &Path, config: &HooksConfig) -> bool {
112 let hash_path = repo_root.join(".githooks").join(HASH_FILE);
113 match std::fs::read_to_string(&hash_path) {
114 Ok(stored) => stored.trim() != config_hash(config),
115 Err(_) => {
116 !config.hooks.is_empty()
118 }
119 }
120}
121
122fn config_hash(config: &HooksConfig) -> String {
124 let json = serde_json::to_string(&config.hooks).unwrap_or_default();
125 let mut hasher = std::collections::hash_map::DefaultHasher::new();
126 json.hash(&mut hasher);
127 format!("{:016x}", hasher.finish())
128}
129
130fn shim_script(hook_name: &str) -> String {
132 format!(
133 "#!/usr/bin/env sh\n\
134 {GENERATED_MARKER} — edit the hooks section in {config} to modify.\n\
135 exec sr hook run {hook_name} -- \"$@\"\n",
136 config = DEFAULT_CONFIG_FILE,
137 )
138}
139
140fn write_shim(path: &Path, content: &str) -> Result<(), crate::error::ReleaseError> {
142 std::fs::write(path, content)
143 .map_err(|e| crate::error::ReleaseError::Config(format!("failed to write hook: {e}")))?;
144
145 #[cfg(unix)]
146 {
147 use std::os::unix::fs::PermissionsExt;
148 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
149 crate::error::ReleaseError::Config(format!("failed to chmod hook: {e}"))
150 })?;
151 }
152
153 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
154 eprintln!("synced .githooks/{name}");
155 }
156
157 Ok(())
158}
159
160fn remove_stale_hooks(
162 hooks_dir: &Path,
163 configured: &BTreeSet<&str>,
164) -> Result<bool, crate::error::ReleaseError> {
165 if !hooks_dir.is_dir() {
166 return Ok(false);
167 }
168
169 let mut removed = false;
170 let entries = std::fs::read_dir(hooks_dir).map_err(|e| {
171 crate::error::ReleaseError::Config(format!("failed to read .githooks: {e}"))
172 })?;
173
174 for entry in entries {
175 let entry = entry.map_err(|e| crate::error::ReleaseError::Config(e.to_string()))?;
176 let path = entry.path();
177
178 if !path.is_file() {
179 continue;
180 }
181
182 let name = match path.file_name().and_then(|n| n.to_str()) {
183 Some(n) => n.to_string(),
184 None => continue,
185 };
186
187 if name == HASH_FILE || name.ends_with(".bak") {
189 continue;
190 }
191
192 if !is_sr_managed(&path) {
194 continue;
195 }
196
197 if !configured.contains(name.as_str()) {
198 std::fs::remove_file(&path).map_err(|e| {
199 crate::error::ReleaseError::Config(format!(
200 "failed to remove .githooks/{name}: {e}"
201 ))
202 })?;
203 eprintln!("removed stale .githooks/{name}");
204 removed = true;
205 }
206 }
207
208 Ok(removed)
209}
210
211fn is_sr_managed(path: &Path) -> bool {
213 std::fs::read_to_string(path)
214 .map(|content| content.contains(GENERATED_MARKER))
215 .unwrap_or(false)
216}
217
218fn set_hooks_path(repo_root: &Path) {
220 let _ = std::process::Command::new("git")
221 .args(["config", "core.hooksPath", ".githooks/"])
222 .current_dir(repo_root)
223 .status();
224}
225
226pub fn run_shell(
234 cmd: &str,
235 stdin_data: Option<&str>,
236 env: &[(&str, &str)],
237) -> Result<(), ReleaseError> {
238 let mut child = {
239 let mut builder = std::process::Command::new("sh");
240 builder.args(["-c", cmd]);
241 for &(k, v) in env {
242 builder.env(k, v);
243 }
244 if stdin_data.is_some() {
245 builder.stdin(std::process::Stdio::piped());
246 } else {
247 builder.stdin(std::process::Stdio::inherit());
248 }
249 builder
250 .spawn()
251 .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
252 };
253
254 if let Some(data) = stdin_data
255 && let Some(ref mut stdin) = child.stdin
256 {
257 use std::io::Write;
258 let _ = stdin.write_all(data.as_bytes());
259 }
260
261 let status = child
262 .wait()
263 .map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
264
265 if !status.success() {
266 let code = status.code().unwrap_or(1);
267 return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
268 }
269
270 Ok(())
271}
272
273pub fn build_hook_json(hook_name: &str, args: &[String]) -> serde_json::Value {
279 let mut obj = serde_json::Map::new();
280 obj.insert("hook".into(), serde_json::Value::String(hook_name.into()));
281 obj.insert(
282 "args".into(),
283 serde_json::Value::Array(
284 args.iter()
285 .map(|a| serde_json::Value::String(a.clone()))
286 .collect(),
287 ),
288 );
289
290 match hook_name {
292 "commit-msg" => {
293 if let Some(f) = args.first() {
294 obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
295 }
296 }
297 "prepare-commit-msg" => {
298 if let Some(f) = args.first() {
299 obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
300 }
301 if let Some(s) = args.get(1) {
302 obj.insert("source".into(), serde_json::Value::String(s.clone()));
303 }
304 if let Some(s) = args.get(2) {
305 obj.insert("sha".into(), serde_json::Value::String(s.clone()));
306 }
307 }
308 "pre-push" => {
309 if let Some(r) = args.first() {
310 obj.insert("remote_name".into(), serde_json::Value::String(r.clone()));
311 }
312 if let Some(u) = args.get(1) {
313 obj.insert("remote_url".into(), serde_json::Value::String(u.clone()));
314 }
315 }
316 "pre-rebase" => {
317 if let Some(u) = args.first() {
318 obj.insert("upstream".into(), serde_json::Value::String(u.clone()));
319 }
320 if let Some(b) = args.get(1) {
321 obj.insert("branch".into(), serde_json::Value::String(b.clone()));
322 }
323 }
324 "post-checkout" => {
325 if let Some(r) = args.first() {
326 obj.insert("prev_ref".into(), serde_json::Value::String(r.clone()));
327 }
328 if let Some(r) = args.get(1) {
329 obj.insert("new_ref".into(), serde_json::Value::String(r.clone()));
330 }
331 if let Some(f) = args.get(2) {
332 obj.insert(
333 "branch_checkout".into(),
334 serde_json::Value::String(f.clone()),
335 );
336 }
337 }
338 "post-merge" => {
339 if let Some(s) = args.first() {
340 obj.insert("squash".into(), serde_json::Value::String(s.clone()));
341 }
342 }
343 _ => {}
344 }
345
346 serde_json::Value::Object(obj)
347}
348
349fn staged_files() -> Result<Vec<String>, ReleaseError> {
351 let output = std::process::Command::new("git")
352 .args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
353 .output()
354 .map_err(|e| ReleaseError::Hook(format!("git diff --cached: {e}")))?;
355 let stdout = String::from_utf8_lossy(&output.stdout);
356 Ok(stdout
357 .lines()
358 .filter(|l| !l.is_empty())
359 .map(|l| l.to_string())
360 .collect())
361}
362
363fn match_files(files: &[String], patterns: &[String]) -> Vec<String> {
365 let compiled: Vec<glob::Pattern> = patterns
366 .iter()
367 .filter_map(|p| glob::Pattern::new(p).ok())
368 .collect();
369
370 files
371 .iter()
372 .filter(|f| {
373 let basename = Path::new(f)
374 .file_name()
375 .and_then(|n| n.to_str())
376 .unwrap_or(f);
377 compiled
378 .iter()
379 .any(|pat| pat.matches(f) || pat.matches(basename))
380 })
381 .cloned()
382 .collect()
383}
384
385pub fn run_hook(
391 config: &ReleaseConfig,
392 hook_name: &str,
393 args: &[String],
394) -> Result<(), ReleaseError> {
395 let entries = config
396 .hooks
397 .hooks
398 .get(hook_name)
399 .ok_or_else(|| ReleaseError::Hook(format!("no hook configured for '{hook_name}'")))?;
400
401 if entries.is_empty() {
402 return Ok(());
403 }
404
405 let json = build_hook_json(hook_name, args);
406 let json_str = serde_json::to_string(&json)
407 .map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;
408
409 let mut cached_staged: Option<Vec<String>> = None;
411
412 for entry in entries {
413 match entry {
414 HookEntry::Simple(cmd) => {
415 run_shell(cmd, Some(&json_str), &[])?;
416 }
417 HookEntry::Step {
418 step,
419 patterns,
420 rules,
421 } => {
422 let all_staged = match &mut cached_staged {
423 Some(files) => files,
424 None => {
425 cached_staged = Some(staged_files().unwrap_or_default());
426 cached_staged.as_mut().unwrap()
427 }
428 };
429
430 if all_staged.is_empty() {
431 eprintln!("{hook_name}: no staged files, skipping steps.");
432 break;
433 }
434
435 let matched = match_files(all_staged, patterns);
436 if matched.is_empty() {
437 eprintln!("{hook_name} [{step}]: no files match {patterns:?}, skipping.");
438 continue;
439 }
440
441 let files_str = matched.join(" ");
442
443 for rule in rules {
444 let cmd = if rule.contains("{files}") {
445 rule.replace("{files}", &files_str)
446 } else {
447 rule.clone()
448 };
449
450 eprintln!("{hook_name} [{step}]: {cmd}");
451 run_shell(&cmd, None, &[])?;
452 }
453 }
454 }
455 }
456
457 Ok(())
458}
459
460pub fn validate_commit_msg(config: &ReleaseConfig) -> Result<(), ReleaseError> {
463 use std::io::Read;
464 let mut input = String::new();
465 std::io::stdin()
466 .read_to_string(&mut input)
467 .map_err(|e| ReleaseError::Hook(format!("failed to read stdin: {e}")))?;
468
469 let json: serde_json::Value = serde_json::from_str(&input)
470 .map_err(|e| ReleaseError::Hook(format!("invalid JSON on stdin: {e}")))?;
471
472 let file = json["message_file"]
473 .as_str()
474 .ok_or_else(|| ReleaseError::Hook("missing 'message_file' in hook JSON".into()))?;
475
476 let content = std::fs::read_to_string(file)
477 .map_err(|e| ReleaseError::Hook(format!("cannot read commit message file: {e}")))?;
478
479 let first_line = content.lines().next().unwrap_or("").trim();
480
481 if first_line.starts_with("Merge ") {
483 return Ok(());
484 }
485
486 if first_line.starts_with("fixup! ")
488 || first_line.starts_with("squash! ")
489 || first_line.starts_with("amend! ")
490 {
491 return Ok(());
492 }
493
494 let re = regex::Regex::new(&config.commit_pattern)
495 .map_err(|e| ReleaseError::Hook(format!("invalid commit_pattern: {e}")))?;
496
497 if !re.is_match(first_line) {
498 let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
499 return Err(ReleaseError::Hook(format!(
500 "commit message does not follow Conventional Commits.\n\n\
501 \x20 Expected: <type>(<scope>): <description>\n\
502 \x20 Got: {first_line}\n\n\
503 \x20 Valid types: {}\n\
504 \x20 Breaking: append '!' before the colon, e.g. feat!: ...\n\n\
505 \x20 Examples:\n\
506 \x20 feat: add release dry-run flag\n\
507 \x20 fix(core): handle empty tag list\n\
508 \x20 feat!: redesign config format",
509 type_names.join(", "),
510 )));
511 }
512
513 if let Some(caps) = re.captures(first_line) {
515 let msg_type = caps.name("type").map(|m| m.as_str()).unwrap_or_default();
516
517 if !config.types.iter().any(|t| t.name == msg_type) {
518 let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
519 return Err(ReleaseError::Hook(format!(
520 "commit type '{msg_type}' is not allowed.\n\n\
521 \x20 Valid types: {}",
522 type_names.join(", "),
523 )));
524 }
525 }
526
527 Ok(())
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use crate::config::HookEntry;
534 use std::collections::BTreeMap;
535
536 fn make_config(hooks: &[(&str, Vec<HookEntry>)]) -> HooksConfig {
537 let mut map = BTreeMap::new();
538 for (name, entries) in hooks {
539 map.insert(name.to_string(), entries.clone());
540 }
541 HooksConfig { hooks: map }
542 }
543
544 #[test]
545 fn creates_hook_scripts() {
546 let dir = tempfile::tempdir().unwrap();
547 let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
548
549 let changed = sync_hooks(dir.path(), &config).unwrap();
550 assert!(changed);
551
552 let hook = dir.path().join(".githooks/pre-commit");
553 assert!(hook.exists());
554 let content = std::fs::read_to_string(&hook).unwrap();
555 assert!(content.contains("sr hook run pre-commit"));
556 assert!(content.contains(GENERATED_MARKER));
557 }
558
559 #[test]
560 fn idempotent_returns_false() {
561 let dir = tempfile::tempdir().unwrap();
562 let config = make_config(&[(
563 "commit-msg",
564 vec![HookEntry::Simple("sr hook commit-msg".into())],
565 )]);
566
567 assert!(sync_hooks(dir.path(), &config).unwrap());
568 assert!(!sync_hooks(dir.path(), &config).unwrap());
570 }
571
572 #[test]
573 fn removes_stale_hooks() {
574 let dir = tempfile::tempdir().unwrap();
575 let hooks_dir = dir.path().join(".githooks");
576 std::fs::create_dir_all(&hooks_dir).unwrap();
577
578 std::fs::write(
580 hooks_dir.join("pre-push"),
581 format!("{GENERATED_MARKER}\nold script"),
582 )
583 .unwrap();
584
585 std::fs::write(hooks_dir.join("post-checkout"), "#!/bin/sh\necho custom").unwrap();
587
588 let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
589
590 sync_hooks(dir.path(), &config).unwrap();
591
592 assert!(
593 !hooks_dir.join("pre-push").exists(),
594 "stale sr-managed hook should be removed"
595 );
596 assert!(
597 hooks_dir.join("post-checkout").exists(),
598 "non-sr-managed hook should be preserved"
599 );
600 assert!(hooks_dir.join("pre-commit").exists());
601 }
602
603 #[test]
604 fn backs_up_conflicting_hooks() {
605 let dir = tempfile::tempdir().unwrap();
606 let hooks_dir = dir.path().join(".githooks");
607 std::fs::create_dir_all(&hooks_dir).unwrap();
608
609 let custom_content = "#!/bin/sh\necho custom commit-msg hook";
611 std::fs::write(hooks_dir.join("commit-msg"), custom_content).unwrap();
612
613 let config = make_config(&[(
614 "commit-msg",
615 vec![HookEntry::Simple("sr hook commit-msg".into())],
616 )]);
617
618 sync_hooks(dir.path(), &config).unwrap();
619
620 let backup = hooks_dir.join("commit-msg.bak");
622 assert!(backup.exists());
623 assert_eq!(std::fs::read_to_string(&backup).unwrap(), custom_content);
624
625 let content = std::fs::read_to_string(hooks_dir.join("commit-msg")).unwrap();
627 assert!(content.contains("sr hook run commit-msg"));
628 }
629
630 #[test]
631 fn empty_config_cleans_up() {
632 let dir = tempfile::tempdir().unwrap();
633 let hooks_dir = dir.path().join(".githooks");
634 std::fs::create_dir_all(&hooks_dir).unwrap();
635
636 std::fs::write(
637 hooks_dir.join("pre-commit"),
638 format!("{GENERATED_MARKER}\nscript"),
639 )
640 .unwrap();
641 std::fs::write(hooks_dir.join(".sr-hooks-hash"), "oldhash").unwrap();
642
643 let config = make_config(&[]);
644 sync_hooks(dir.path(), &config).unwrap();
645
646 assert!(!hooks_dir.join("pre-commit").exists());
647 assert!(!hooks_dir.join(".sr-hooks-hash").exists());
648 }
649
650 #[test]
651 fn needs_sync_detects_changes() {
652 let dir = tempfile::tempdir().unwrap();
653 let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
654
655 assert!(needs_sync(dir.path(), &config));
656
657 sync_hooks(dir.path(), &config).unwrap();
658 assert!(!needs_sync(dir.path(), &config));
659
660 let config2 =
662 make_config(&[("pre-commit", vec![HookEntry::Simple("echo changed".into())])]);
663 assert!(needs_sync(dir.path(), &config2));
664 }
665}