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