1use std::path::Path;
11
12use crate::walk::py_join;
13
14pub struct HookSpec {
16 pub style: &'static str,
17 pub description: &'static str,
18}
19
20pub const BUNDLED_HOOKS: [HookSpec; 2] = [
22 HookSpec {
23 style: "post-commit",
24 description: "Advisory write-cadence nudge after each commit (never blocks).",
25 },
26 HookSpec {
27 style: "pre-commit",
28 description: "Validate staged Markdown artifacts before each commit (blocks on errors).",
29 },
30];
31
32pub const DEFAULT_STYLE: &str = "post-commit";
34
35pub(crate) const HOOK_BYTES: [&[u8]; 2] = [
37 include_bytes!("../assets/hooks/post-commit.sh"),
38 include_bytes!("../assets/hooks/pre-commit.sh"),
39];
40
41pub fn available_hooks() -> Vec<&'static str> {
43 BUNDLED_HOOKS.iter().map(|h| h.style).collect()
44}
45
46fn hook_bytes(style: &str) -> Option<&'static [u8]> {
47 BUNDLED_HOOKS
48 .iter()
49 .position(|h| h.style == style)
50 .map(|i| HOOK_BYTES[i])
51}
52
53pub struct InstalledHook {
56 pub style: String,
57 pub path: String,
58}
59
60pub enum HookInstallError {
64 NotAGitWorkTree(String),
67 FileExists(String),
69 Io(String),
71}
72
73pub fn install_hook(target_dir: &str, style: &str) -> Result<InstalledHook, HookInstallError> {
78 let content = hook_bytes(style).expect("argparse-validated style");
79
80 let git_dir = Path::new(target_dir).join(".git");
81 if !git_dir.is_dir() {
82 return Err(HookInstallError::NotAGitWorkTree(format!(
83 "no .git directory in {target_dir}; run `decided hook install` from a git repository root"
84 )));
85 }
86
87 let dest_display = py_join(target_dir, &[".git", "hooks", style]);
88 let dest = Path::new(&dest_display);
89 if dest.exists() {
90 return Err(HookInstallError::FileExists(format!(
91 "{dest_display} already exists; decided hook install never overwrites"
92 )));
93 }
94
95 let hooks_dir = git_dir.join("hooks");
96 std::fs::create_dir_all(&hooks_dir)
97 .map_err(|e| HookInstallError::Io(format!("{e}: {}", hooks_dir.display())))?;
98 std::fs::write(dest, content)
99 .map_err(|e| HookInstallError::Io(format!("{e}: {dest_display}")))?;
100 #[cfg(unix)]
102 {
103 use std::os::unix::fs::PermissionsExt;
104 let mode = std::fs::metadata(dest)
105 .map_err(|e| HookInstallError::Io(format!("{e}: {dest_display}")))?
106 .permissions();
107 let new_mode = mode.mode() | 0o111;
108 std::fs::set_permissions(dest, std::fs::Permissions::from_mode(new_mode))
109 .map_err(|e| HookInstallError::Io(format!("{e}: {dest_display}")))?;
110 }
111 Ok(InstalledHook {
112 style: style.to_string(),
113 path: dest_display,
114 })
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn registry_order_and_default() {
123 assert_eq!(available_hooks(), vec!["post-commit", "pre-commit"]);
124 assert_eq!(DEFAULT_STYLE, "post-commit");
125 }
126}