1use crate::error::{Error, Result};
2use std::path::{Path, PathBuf};
3
4const MARKER_BEGIN: &str = "# BEGIN timebomb";
5const MARKER_END: &str = "# END timebomb";
6
7const HOOK_BLOCK: &str = "# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\n";
9
10const NEW_HOOK_CONTENT: &str =
12 "#!/bin/sh\nset -e\n# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\n";
13
14fn find_git_dir(path: &Path) -> Result<PathBuf> {
16 let mut current = path.to_path_buf();
17 loop {
18 let candidate = current.join(".git");
19 if candidate.exists() {
20 return Ok(candidate);
22 }
23 match current.parent() {
24 Some(parent) => current = parent.to_path_buf(),
25 None => {
26 return Err(Error::InvalidArgument(
27 "no .git directory found; is this a git repository?".to_string(),
28 ))
29 }
30 }
31 }
32}
33
34fn hook_has_timebomb_block(content: &str) -> bool {
36 content.contains(MARKER_BEGIN)
37}
38
39fn remove_timebomb_block(content: &str) -> String {
44 let had_trailing_newline = content.ends_with('\n');
45 let mut out = String::with_capacity(content.len());
46 let mut inside = false;
47 let mut first = true;
48 for line in content.lines() {
49 if line.trim() == MARKER_BEGIN {
50 inside = true;
51 continue;
52 }
53 if line.trim() == MARKER_END {
54 inside = false;
55 continue;
56 }
57 if !inside {
58 if !first {
59 out.push('\n');
60 }
61 out.push_str(line);
62 first = false;
63 }
64 }
65 if !first && had_trailing_newline {
66 out.push('\n');
67 }
68 out
69}
70
71#[cfg(unix)]
73fn make_executable(path: &Path) -> Result<()> {
74 use std::os::unix::fs::PermissionsExt;
75 let meta = std::fs::metadata(path).map_err(|e| Error::Io {
76 source: e,
77 path: Some(path.to_path_buf()),
78 })?;
79 let mut perms = meta.permissions();
80 let mode = perms.mode() | 0o111;
82 perms.set_mode(mode);
83 std::fs::set_permissions(path, perms).map_err(|e| Error::Io {
84 source: e,
85 path: Some(path.to_path_buf()),
86 })
87}
88
89#[cfg(not(unix))]
90fn make_executable(_path: &Path) -> Result<()> {
91 Ok(())
92}
93
94pub fn run_hook_install(path: &Path, yes: bool) -> Result<i32> {
101 let git_dir = find_git_dir(path)?;
102 let hooks_dir = git_dir.join("hooks");
103
104 if !hooks_dir.exists() {
106 std::fs::create_dir_all(&hooks_dir).map_err(|e| Error::Io {
107 source: e,
108 path: Some(hooks_dir.clone()),
109 })?;
110 }
111
112 let hook_path = hooks_dir.join("pre-commit");
113
114 if hook_path.exists() {
116 let existing = std::fs::read_to_string(&hook_path).map_err(|e| Error::Io {
117 source: e,
118 path: Some(hook_path.clone()),
119 })?;
120 if hook_has_timebomb_block(&existing) {
121 println!(
122 "timebomb hook is already installed at {}",
123 hook_path.display()
124 );
125 return Ok(0);
126 }
127
128 if !yes {
130 println!(
131 "Will append timebomb block to existing hook at {}",
132 hook_path.display()
133 );
134 println!("Proceed? [y/N] ");
135 let mut input = String::new();
136 std::io::stdin()
137 .read_line(&mut input)
138 .map_err(|e| Error::Io {
139 source: e,
140 path: None,
141 })?;
142 if !input.trim().eq_ignore_ascii_case("y") {
143 println!("Aborted.");
144 return Ok(0);
145 }
146 }
147
148 let new_content = format!("{}\n{}", existing.trim_end(), HOOK_BLOCK);
149 std::fs::write(&hook_path, &new_content).map_err(|e| Error::Io {
150 source: e,
151 path: Some(hook_path.clone()),
152 })?;
153 make_executable(&hook_path)?;
154 println!("timebomb hook appended to {}", hook_path.display());
155 } else {
156 if !yes {
158 println!("Will create new hook file at {}", hook_path.display());
159 println!("Proceed? [y/N] ");
160 let mut input = String::new();
161 std::io::stdin()
162 .read_line(&mut input)
163 .map_err(|e| Error::Io {
164 source: e,
165 path: None,
166 })?;
167 if !input.trim().eq_ignore_ascii_case("y") {
168 println!("Aborted.");
169 return Ok(0);
170 }
171 }
172
173 std::fs::write(&hook_path, NEW_HOOK_CONTENT).map_err(|e| Error::Io {
174 source: e,
175 path: Some(hook_path.clone()),
176 })?;
177 make_executable(&hook_path)?;
178 println!("timebomb hook installed at {}", hook_path.display());
179 }
180
181 Ok(0)
182}
183
184pub fn run_hook_uninstall(path: &Path, yes: bool) -> Result<i32> {
190 let git_dir = find_git_dir(path)?;
191 let hook_path = git_dir.join("hooks").join("pre-commit");
192
193 if !hook_path.exists() {
194 println!("No pre-commit hook found — nothing to uninstall.");
195 return Ok(0);
196 }
197
198 let content = std::fs::read_to_string(&hook_path).map_err(|e| Error::Io {
199 source: e,
200 path: Some(hook_path.clone()),
201 })?;
202
203 if !hook_has_timebomb_block(&content) {
204 println!("timebomb hook is not installed — nothing to uninstall.");
205 return Ok(0);
206 }
207
208 if !yes {
209 println!("Will remove timebomb block from {}", hook_path.display());
210 println!("Proceed? [y/N] ");
211 let mut input = String::new();
212 std::io::stdin()
213 .read_line(&mut input)
214 .map_err(|e| Error::Io {
215 source: e,
216 path: None,
217 })?;
218 if !input.trim().eq_ignore_ascii_case("y") {
219 println!("Aborted.");
220 return Ok(0);
221 }
222 }
223
224 let cleaned = remove_timebomb_block(&content);
225
226 let has_real_content = cleaned
230 .lines()
231 .any(|l| !l.trim().is_empty() && l.trim() != "#!/bin/sh" && l.trim() != "set -e");
232
233 if !has_real_content {
234 std::fs::remove_file(&hook_path).map_err(|e| Error::Io {
235 source: e,
236 path: Some(hook_path.clone()),
237 })?;
238 println!("timebomb hook removed (file deleted — it only contained the timebomb block).");
239 } else {
240 std::fs::write(&hook_path, &cleaned).map_err(|e| Error::Io {
241 source: e,
242 path: Some(hook_path.clone()),
243 })?;
244 println!("timebomb block removed from {}", hook_path.display());
245 }
246
247 Ok(0)
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use std::io::Write;
254
255 fn create_fake_git(tmp: &std::path::Path) {
257 std::fs::create_dir_all(tmp.join(".git").join("hooks")).unwrap();
258 }
259
260 #[test]
261 fn test_hook_install_creates_new_file() {
262 let tmp = tempfile::tempdir().unwrap();
263 create_fake_git(tmp.path());
264
265 let result = run_hook_install(tmp.path(), true).unwrap();
266 assert_eq!(result, 0);
267
268 let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
269 assert!(hook_path.exists(), "pre-commit hook file should be created");
270
271 let content = std::fs::read_to_string(&hook_path).unwrap();
272 assert!(content.contains(MARKER_BEGIN));
273 assert!(content.contains(MARKER_END));
274 assert!(content.contains("timebomb sweep --since HEAD ."));
275
276 #[cfg(unix)]
278 {
279 use std::os::unix::fs::PermissionsExt;
280 let meta = std::fs::metadata(&hook_path).unwrap();
281 assert_ne!(
282 meta.permissions().mode() & 0o111,
283 0,
284 "hook should be executable"
285 );
286 }
287 }
288
289 #[test]
290 fn test_hook_install_is_idempotent() {
291 let tmp = tempfile::tempdir().unwrap();
292 create_fake_git(tmp.path());
293
294 run_hook_install(tmp.path(), true).unwrap();
296 run_hook_install(tmp.path(), true).unwrap();
297
298 let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
299 let content = std::fs::read_to_string(&hook_path).unwrap();
300
301 let count = content.matches(MARKER_BEGIN).count();
303 assert_eq!(count, 1, "marker block should appear exactly once");
304 }
305
306 #[test]
307 fn test_hook_install_appends_to_existing_hook() {
308 let tmp = tempfile::tempdir().unwrap();
309 create_fake_git(tmp.path());
310
311 let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
312 {
313 let mut f = std::fs::File::create(&hook_path).unwrap();
314 writeln!(f, "#!/bin/sh").unwrap();
315 writeln!(f, "echo 'existing hook'").unwrap();
316 }
317
318 run_hook_install(tmp.path(), true).unwrap();
319
320 let content = std::fs::read_to_string(&hook_path).unwrap();
321 assert!(
322 content.contains("echo 'existing hook'"),
323 "original content preserved"
324 );
325 assert!(content.contains(MARKER_BEGIN), "timebomb block appended");
326 assert!(content.contains("timebomb sweep --since HEAD ."));
327 }
328
329 #[test]
330 fn test_hook_uninstall_removes_block() {
331 let tmp = tempfile::tempdir().unwrap();
332 create_fake_git(tmp.path());
333
334 run_hook_install(tmp.path(), true).unwrap();
335
336 let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
337 assert!(hook_path.exists());
338
339 run_hook_uninstall(tmp.path(), true).unwrap();
340
341 assert!(
343 !hook_path.exists(),
344 "hook file should be deleted when it only had the block"
345 );
346 }
347
348 #[test]
349 fn test_hook_uninstall_preserves_other_content() {
350 let tmp = tempfile::tempdir().unwrap();
351 create_fake_git(tmp.path());
352
353 let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
354 {
355 let mut f = std::fs::File::create(&hook_path).unwrap();
356 writeln!(f, "#!/bin/sh").unwrap();
357 writeln!(f, "echo 'my other check'").unwrap();
358 }
359
360 run_hook_install(tmp.path(), true).unwrap();
361 run_hook_uninstall(tmp.path(), true).unwrap();
362
363 assert!(
365 hook_path.exists(),
366 "hook file should remain (has other content)"
367 );
368 let content = std::fs::read_to_string(&hook_path).unwrap();
369 assert!(
370 !content.contains(MARKER_BEGIN),
371 "timebomb marker should be gone"
372 );
373 assert!(
374 content.contains("my other check"),
375 "other content preserved"
376 );
377 }
378
379 #[test]
380 fn test_hook_uninstall_on_missing_hook() {
381 let tmp = tempfile::tempdir().unwrap();
382 create_fake_git(tmp.path());
383
384 let result = run_hook_uninstall(tmp.path(), true).unwrap();
386 assert_eq!(result, 0);
387 }
388
389 #[test]
390 fn test_remove_timebomb_block_basic() {
391 let input = "line before\n# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\nline after\n";
392 let output = remove_timebomb_block(input);
393 assert!(!output.contains(MARKER_BEGIN));
394 assert!(!output.contains(MARKER_END));
395 assert!(output.contains("line before"));
396 assert!(output.contains("line after"));
397 }
398
399 #[test]
400 fn test_hook_has_timebomb_block() {
401 assert!(hook_has_timebomb_block(
402 "some content\n# BEGIN timebomb\nstuff\n# END timebomb\n"
403 ));
404 assert!(!hook_has_timebomb_block("just a regular hook\n"));
405 }
406
407 #[test]
408 fn test_find_git_dir_not_found() {
409 let tmp = tempfile::tempdir().unwrap();
413 let result = find_git_dir(tmp.path());
414 assert!(result.is_err());
416 }
417
418 #[test]
419 fn test_find_git_dir_found() {
420 let tmp = tempfile::tempdir().unwrap();
421 create_fake_git(tmp.path());
422 let result = find_git_dir(tmp.path());
423 assert!(result.is_ok());
424 assert!(result.unwrap().ends_with(".git"));
425 }
426
427 #[test]
428 fn test_find_git_dir_found_from_subdirectory() {
429 let tmp = tempfile::tempdir().unwrap();
431 create_fake_git(tmp.path());
432 let subdir = tmp.path().join("a").join("b").join("c");
433 std::fs::create_dir_all(&subdir).unwrap();
434 let result = find_git_dir(&subdir);
435 assert!(result.is_ok());
436 }
437
438 #[test]
439 fn test_remove_timebomb_block_no_block_is_noop() {
440 let input = "#!/bin/sh\necho 'no timebomb here'\n";
441 let output = remove_timebomb_block(input);
442 assert!(output.contains("echo 'no timebomb here'"));
444 assert!(!output.contains(MARKER_BEGIN));
445 }
446
447 #[test]
448 fn test_remove_timebomb_block_preserves_surrounding_lines() {
449 let input = "\
450#!/bin/sh\n\
451echo before\n\
452# BEGIN timebomb\n\
453timebomb sweep --since HEAD .\n\
454# END timebomb\n\
455echo after\n\
456";
457 let output = remove_timebomb_block(input);
458 assert!(!output.contains(MARKER_BEGIN));
459 assert!(!output.contains(MARKER_END));
460 assert!(output.contains("echo before"));
461 assert!(output.contains("echo after"));
462 assert!(!output.contains("timebomb sweep"));
463 }
464
465 #[test]
466 fn test_hook_install_creates_hooks_dir_if_missing() {
467 let tmp = tempfile::tempdir().unwrap();
469 std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
471
472 let result = run_hook_install(tmp.path(), true);
473 assert!(result.is_ok());
474
475 let hooks_dir = tmp.path().join(".git").join("hooks");
476 assert!(hooks_dir.exists());
477 assert!(hooks_dir.join("pre-commit").exists());
478 }
479
480 #[test]
481 fn test_hook_uninstall_no_timebomb_in_existing_hook() {
482 let tmp = tempfile::tempdir().unwrap();
484 create_fake_git(tmp.path());
485
486 let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
487 std::fs::write(&hook_path, "#!/bin/sh\necho 'unrelated'\n").unwrap();
488
489 let result = run_hook_uninstall(tmp.path(), true).unwrap();
490 assert_eq!(result, 0);
491 let content = std::fs::read_to_string(&hook_path).unwrap();
493 assert!(content.contains("unrelated"));
494 }
495
496 #[test]
497 fn test_new_hook_content_is_executable_script() {
498 assert!(NEW_HOOK_CONTENT.starts_with("#!/bin/sh"));
500 assert!(NEW_HOOK_CONTENT.contains("set -e"));
501 assert!(NEW_HOOK_CONTENT.contains(MARKER_BEGIN));
502 assert!(NEW_HOOK_CONTENT.contains(MARKER_END));
503 }
504
505 #[test]
506 fn test_hook_block_constant_is_valid() {
507 assert!(HOOK_BLOCK.contains(MARKER_BEGIN));
509 assert!(HOOK_BLOCK.contains(MARKER_END));
510 assert!(HOOK_BLOCK.contains("timebomb sweep"));
511 }
512}