Skip to main content

timebomb/
hook.rs

1use crate::error::{Error, Result};
2use std::path::{Path, PathBuf};
3
4const MARKER_BEGIN: &str = "# BEGIN timebomb";
5const MARKER_END: &str = "# END timebomb";
6
7/// The block inserted into (or appended to) the pre-commit hook.
8const HOOK_BLOCK: &str = "# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\n";
9
10/// Content of a freshly-created pre-commit hook file.
11const NEW_HOOK_CONTENT: &str =
12    "#!/bin/sh\nset -e\n# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\n";
13
14/// Walk up from `path` looking for a `.git` directory or file.
15fn 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            // `.git` may be a file (git worktrees) or a directory — both are valid.
21            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
34/// Return true if the hook file already contains the timebomb marker block.
35fn hook_has_timebomb_block(content: &str) -> bool {
36    content.contains(MARKER_BEGIN)
37}
38
39/// Remove the timebomb marker block from `content`, returning the cleaned string.
40///
41/// Preserves the original file's trailing-newline behaviour: if `content` did
42/// not end with `\n`, the returned string won't either.
43fn 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/// Set the executable bit on a file (Unix only; no-op on other platforms).
72#[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    // Add owner + group + other execute bits.
81    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
94/// Install the timebomb pre-commit hook.
95///
96/// - If the hook already contains the timebomb block, prints a message and exits 0.
97/// - If the hook file does not exist, creates it with a shebang + hook block.
98/// - If the hook file exists but has no timebomb block, appends the block.
99/// - When `yes` is false the user is prompted before any write.
100pub 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    // Ensure the hooks directory exists.
105    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    // Check if already installed.
115    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        // Append to existing hook.
129        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        // Create a new hook file.
157        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
184/// Uninstall the timebomb pre-commit hook.
185///
186/// - If the hook file does not exist or has no timebomb block, prints a message and exits 0.
187/// - If the resulting cleaned file is empty (or only whitespace), deletes it.
188/// - Otherwise writes the cleaned content back.
189pub 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    // Lines that count as "real content" (non-boilerplate after trim).
227    // The shebang and `set -e` are standard shell boilerplate; if only those
228    // remain after removing the timebomb block, the hook file is safe to delete.
229    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    /// Create a minimal fake git repo structure in `tmp` (just a `.git/hooks/` dir).
256    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        // Check executable bit on Unix.
277        #[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        // Install twice.
295        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        // The marker should appear exactly once.
302        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        // File should be gone (it only had the timebomb block).
342        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        // File should still exist with the other content.
364        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        // Uninstall without ever installing — should succeed with exit code 0.
385        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        // A directory with no .git anywhere up the tree will fail.
410        // Use /tmp directly — it should have no .git unless someone put one there.
411        // This test is best-effort; skip if /tmp itself somehow has .git.
412        let tmp = tempfile::tempdir().unwrap();
413        let result = find_git_dir(tmp.path());
414        // Should fail — no .git in the temp dir.
415        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        // find_git_dir should walk up and find .git even from a nested subdirectory.
430        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        // Content is unchanged except possibly trailing newline normalisation.
443        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        // The fake git dir has no hooks/ subdirectory — install should create it.
468        let tmp = tempfile::tempdir().unwrap();
469        // Create .git directly (no hooks/ subdir).
470        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        // File exists but has no timebomb block — uninstall should succeed silently.
483        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        // File still exists, content unchanged.
492        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        // The content written for a fresh hook must have a shebang and set -e.
499        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        // HOOK_BLOCK itself must contain both markers and the check command.
508        assert!(HOOK_BLOCK.contains(MARKER_BEGIN));
509        assert!(HOOK_BLOCK.contains(MARKER_END));
510        assert!(HOOK_BLOCK.contains("timebomb sweep"));
511    }
512}