1use anyhow::Result;
2use colored::Colorize;
3use std::path::Path;
4
5use crate::config::Config;
6use crate::hooks::ALL_HOOKS;
7
8pub const GITHOPS_MARKER: &str = "# GITHOPS_MANAGED";
10
11pub fn sync_to_hooks(config: &Config, hooks_dir: &Path, force: bool) -> Result<(usize, usize)> {
18 std::fs::create_dir_all(hooks_dir)?;
19
20 let mut installed = 0usize;
21 let mut skipped = 0usize;
22
23 for hook_info in ALL_HOOKS {
24 let hook_cfg = match config.hooks.get(hook_info.name) {
25 Some(cfg) => cfg,
26 None => continue,
27 };
28
29 let resolved = hook_cfg.resolved_commands(&config.definitions);
30 let active_count = resolved.iter().filter(|c| !c.test).count();
31
32 if !hook_cfg.enabled || active_count == 0 {
33 continue;
34 }
35
36 let hook_path = hooks_dir.join(hook_info.name);
37 let script = build_hook_script(hook_info.name, active_count);
38
39 if hook_path.exists() {
40 let existing = std::fs::read_to_string(&hook_path).unwrap_or_default();
41 if !existing.contains(GITHOPS_MARKER) {
42 if !force {
43 println!(
44 "{} {} — not managed by githops, skipping (use {} to overwrite)",
45 "skip:".yellow().bold(),
46 hook_info.name,
47 "githops sync --force".cyan()
48 );
49 skipped += 1;
50 continue;
51 }
52 println!(
53 "{} {} — overwriting unmanaged hook",
54 "force:".yellow().bold(),
55 hook_info.name
56 );
57 }
58 }
59
60 std::fs::write(&hook_path, &script)?;
61 make_executable(&hook_path)?;
62 println!("{} {}", "synced:".green().bold(), hook_info.name);
63 installed += 1;
64 }
65
66 for hook_info in ALL_HOOKS {
68 let hook_path = hooks_dir.join(hook_info.name);
69 if !hook_path.exists() {
70 continue;
71 }
72 let existing = std::fs::read_to_string(&hook_path).unwrap_or_default();
73 if !existing.contains(GITHOPS_MARKER) {
74 continue;
75 }
76 let configured = config
77 .hooks
78 .get(hook_info.name)
79 .map(|c| {
80 let resolved = c.resolved_commands(&config.definitions);
81 c.enabled && resolved.iter().any(|cmd| !cmd.test)
82 })
83 .unwrap_or(false);
84 if !configured {
85 std::fs::remove_file(&hook_path)?;
86 println!("{} {}", "removed:".dimmed(), hook_info.name);
87 }
88 }
89
90 Ok((installed, skipped))
91}
92
93fn build_hook_script(hook_name: &str, command_count: usize) -> String {
94 format!(
95 r#"#!/bin/sh
96{marker}
97# Hook: {name}
98# Managed by githops — do not edit manually.
99# Run `githops sync` to regenerate.
100# Commands configured: {count}
101
102exec githops check {name} "$@"
103"#,
104 marker = GITHOPS_MARKER,
105 name = hook_name,
106 count = command_count
107 )
108}
109
110#[cfg(unix)]
111fn make_executable(path: &Path) -> Result<()> {
112 use std::os::unix::fs::PermissionsExt;
113 let mut perms = std::fs::metadata(path)?.permissions();
114 perms.set_mode(perms.mode() | 0o111);
115 std::fs::set_permissions(path, perms)?;
116 Ok(())
117}
118
119#[cfg(not(unix))]
120fn make_executable(_path: &Path) -> Result<()> {
121 Ok(())
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::config::{Command, CommandEntry, Config, HookConfig};
128 use std::collections::BTreeMap;
129 use tempfile::TempDir;
130
131 #[test]
132 fn test_build_hook_script_contains_marker() {
133 let script = build_hook_script("pre-commit", 3);
134 assert!(script.contains("GITHOPS_MANAGED"));
135 }
136
137 #[test]
138 fn test_build_hook_script_contains_hook_name() {
139 let script = build_hook_script("commit-msg", 1);
140 assert!(script.contains("commit-msg"));
141 }
142
143 #[test]
144 fn test_build_hook_script_has_shebang() {
145 let script = build_hook_script("pre-push", 2);
146 assert!(script.starts_with("#!/"));
147 }
148
149 #[test]
150 fn test_build_hook_script_calls_githops_check() {
151 let script = build_hook_script("pre-commit", 1);
152 assert!(script.contains("githops check"));
153 }
154
155 #[test]
156 fn test_sync_creates_hook_files() {
157 let dir = TempDir::new().unwrap();
158 let mut config = Config::default();
159 config.hooks.pre_commit = Some(HookConfig {
160 enabled: true,
161 parallel: false,
162 commands: vec![CommandEntry::Inline(Command {
163 name: "lint".into(),
164 run: "echo lint".into(),
165 depends: vec![],
166 env: BTreeMap::new(),
167 test: false,
168 cache: None,
169 })],
170 });
171
172 let (installed, skipped) = sync_to_hooks(&config, dir.path(), false).unwrap();
173 assert_eq!(installed, 1);
174 assert_eq!(skipped, 0);
175 assert!(dir.path().join("pre-commit").exists());
176 }
177
178 #[test]
179 fn test_sync_hook_script_content_is_correct() {
180 let dir = TempDir::new().unwrap();
181 let mut config = Config::default();
182 config.hooks.pre_commit = Some(HookConfig {
183 enabled: true,
184 parallel: false,
185 commands: vec![CommandEntry::Inline(Command {
186 name: "lint".into(),
187 run: "echo lint".into(),
188 depends: vec![],
189 env: BTreeMap::new(),
190 test: false,
191 cache: None,
192 })],
193 });
194
195 sync_to_hooks(&config, dir.path(), false).unwrap();
196 let content = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
197 assert!(content.contains("GITHOPS_MANAGED"));
198 assert!(content.contains("pre-commit"));
199 }
200
201 #[test]
202 fn test_sync_skips_disabled_hook() {
203 let dir = TempDir::new().unwrap();
204 let mut config = Config::default();
205 config.hooks.pre_commit = Some(HookConfig {
206 enabled: false, parallel: false,
208 commands: vec![CommandEntry::Inline(Command {
209 name: "lint".into(),
210 run: "echo lint".into(),
211 depends: vec![],
212 env: BTreeMap::new(),
213 test: false,
214 cache: None,
215 })],
216 });
217
218 let (installed, _skipped) = sync_to_hooks(&config, dir.path(), false).unwrap();
219 assert_eq!(installed, 0);
220 assert!(!dir.path().join("pre-commit").exists());
221 }
222
223 #[test]
224 fn test_sync_skips_test_only_commands() {
225 let dir = TempDir::new().unwrap();
226 let mut config = Config::default();
227 config.hooks.pre_commit = Some(HookConfig {
228 enabled: true,
229 parallel: false,
230 commands: vec![CommandEntry::Inline(Command {
231 name: "lint".into(),
232 run: "echo lint".into(),
233 depends: vec![],
234 env: BTreeMap::new(),
235 test: true, cache: None,
237 })],
238 });
239
240 let (installed, _) = sync_to_hooks(&config, dir.path(), false).unwrap();
241 assert_eq!(installed, 0);
242 }
243
244 #[test]
245 fn test_sync_does_not_overwrite_unmanaged_hook() {
246 let dir = TempDir::new().unwrap();
247 std::fs::write(dir.path().join("pre-commit"), "#!/bin/sh\necho manual").unwrap();
249
250 let mut config = Config::default();
251 config.hooks.pre_commit = Some(HookConfig {
252 enabled: true,
253 parallel: false,
254 commands: vec![CommandEntry::Inline(Command {
255 name: "lint".into(),
256 run: "echo lint".into(),
257 depends: vec![],
258 env: BTreeMap::new(),
259 test: false,
260 cache: None,
261 })],
262 });
263
264 let (_installed, skipped) = sync_to_hooks(&config, dir.path(), false).unwrap();
265 assert_eq!(skipped, 1);
266 let content = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
268 assert!(content.contains("manual"));
269 }
270
271 #[test]
272 fn test_sync_force_overwrites_unmanaged_hook() {
273 let dir = TempDir::new().unwrap();
274 std::fs::write(dir.path().join("pre-commit"), "#!/bin/sh\necho manual").unwrap();
275
276 let mut config = Config::default();
277 config.hooks.pre_commit = Some(HookConfig {
278 enabled: true,
279 parallel: false,
280 commands: vec![CommandEntry::Inline(Command {
281 name: "lint".into(),
282 run: "echo lint".into(),
283 depends: vec![],
284 env: BTreeMap::new(),
285 test: false,
286 cache: None,
287 })],
288 });
289
290 let (installed, _) = sync_to_hooks(&config, dir.path(), true).unwrap();
291 assert_eq!(installed, 1);
292 let content = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
293 assert!(content.contains("GITHOPS_MANAGED"));
294 }
295
296 #[test]
297 fn test_sync_removes_obsolete_managed_hook() {
298 let dir = TempDir::new().unwrap();
299 let managed_content =
301 "#!/bin/sh\n# GITHOPS_MANAGED\nexec githops check pre-commit \"$@\"\n";
302 std::fs::write(dir.path().join("pre-commit"), managed_content).unwrap();
303
304 let config = Config::default();
306
307 sync_to_hooks(&config, dir.path(), false).unwrap();
308 assert!(!dir.path().join("pre-commit").exists());
310 }
311
312 #[test]
313 fn test_sync_is_idempotent() {
314 let dir = TempDir::new().unwrap();
315 let mut config = Config::default();
316 config.hooks.pre_commit = Some(HookConfig {
317 enabled: true,
318 parallel: false,
319 commands: vec![CommandEntry::Inline(Command {
320 name: "lint".into(),
321 run: "echo lint".into(),
322 depends: vec![],
323 env: BTreeMap::new(),
324 test: false,
325 cache: None,
326 })],
327 });
328
329 sync_to_hooks(&config, dir.path(), false).unwrap();
330 let content1 = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
331 sync_to_hooks(&config, dir.path(), false).unwrap();
332 let content2 = std::fs::read_to_string(dir.path().join("pre-commit")).unwrap();
333 assert_eq!(content1, content2);
334 }
335}