use crate::error::{Error, Result};
use std::path::{Path, PathBuf};
const MARKER_BEGIN: &str = "# BEGIN timebomb";
const MARKER_END: &str = "# END timebomb";
const HOOK_BLOCK: &str = "# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\n";
const NEW_HOOK_CONTENT: &str =
"#!/bin/sh\nset -e\n# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\n";
fn find_git_dir(path: &Path) -> Result<PathBuf> {
let mut current = path.to_path_buf();
loop {
let candidate = current.join(".git");
if candidate.exists() {
return Ok(candidate);
}
match current.parent() {
Some(parent) => current = parent.to_path_buf(),
None => {
return Err(Error::InvalidArgument(
"no .git directory found; is this a git repository?".to_string(),
))
}
}
}
}
fn hook_has_timebomb_block(content: &str) -> bool {
content.contains(MARKER_BEGIN)
}
fn remove_timebomb_block(content: &str) -> String {
let had_trailing_newline = content.ends_with('\n');
let mut out = String::with_capacity(content.len());
let mut inside = false;
let mut first = true;
for line in content.lines() {
if line.trim() == MARKER_BEGIN {
inside = true;
continue;
}
if line.trim() == MARKER_END {
inside = false;
continue;
}
if !inside {
if !first {
out.push('\n');
}
out.push_str(line);
first = false;
}
}
if !first && had_trailing_newline {
out.push('\n');
}
out
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(path).map_err(|e| Error::Io {
source: e,
path: Some(path.to_path_buf()),
})?;
let mut perms = meta.permissions();
let mode = perms.mode() | 0o111;
perms.set_mode(mode);
std::fs::set_permissions(path, perms).map_err(|e| Error::Io {
source: e,
path: Some(path.to_path_buf()),
})
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
pub fn run_hook_install(path: &Path, yes: bool) -> Result<i32> {
let git_dir = find_git_dir(path)?;
let hooks_dir = git_dir.join("hooks");
if !hooks_dir.exists() {
std::fs::create_dir_all(&hooks_dir).map_err(|e| Error::Io {
source: e,
path: Some(hooks_dir.clone()),
})?;
}
let hook_path = hooks_dir.join("pre-commit");
if hook_path.exists() {
let existing = std::fs::read_to_string(&hook_path).map_err(|e| Error::Io {
source: e,
path: Some(hook_path.clone()),
})?;
if hook_has_timebomb_block(&existing) {
println!(
"timebomb hook is already installed at {}",
hook_path.display()
);
return Ok(0);
}
if !yes {
println!(
"Will append timebomb block to existing hook at {}",
hook_path.display()
);
println!("Proceed? [y/N] ");
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| Error::Io {
source: e,
path: None,
})?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(0);
}
}
let new_content = format!("{}\n{}", existing.trim_end(), HOOK_BLOCK);
std::fs::write(&hook_path, &new_content).map_err(|e| Error::Io {
source: e,
path: Some(hook_path.clone()),
})?;
make_executable(&hook_path)?;
println!("timebomb hook appended to {}", hook_path.display());
} else {
if !yes {
println!("Will create new hook file at {}", hook_path.display());
println!("Proceed? [y/N] ");
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| Error::Io {
source: e,
path: None,
})?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(0);
}
}
std::fs::write(&hook_path, NEW_HOOK_CONTENT).map_err(|e| Error::Io {
source: e,
path: Some(hook_path.clone()),
})?;
make_executable(&hook_path)?;
println!("timebomb hook installed at {}", hook_path.display());
}
Ok(0)
}
pub fn run_hook_uninstall(path: &Path, yes: bool) -> Result<i32> {
let git_dir = find_git_dir(path)?;
let hook_path = git_dir.join("hooks").join("pre-commit");
if !hook_path.exists() {
println!("No pre-commit hook found — nothing to uninstall.");
return Ok(0);
}
let content = std::fs::read_to_string(&hook_path).map_err(|e| Error::Io {
source: e,
path: Some(hook_path.clone()),
})?;
if !hook_has_timebomb_block(&content) {
println!("timebomb hook is not installed — nothing to uninstall.");
return Ok(0);
}
if !yes {
println!("Will remove timebomb block from {}", hook_path.display());
println!("Proceed? [y/N] ");
let mut input = String::new();
std::io::stdin()
.read_line(&mut input)
.map_err(|e| Error::Io {
source: e,
path: None,
})?;
if !input.trim().eq_ignore_ascii_case("y") {
println!("Aborted.");
return Ok(0);
}
}
let cleaned = remove_timebomb_block(&content);
let has_real_content = cleaned
.lines()
.any(|l| !l.trim().is_empty() && l.trim() != "#!/bin/sh" && l.trim() != "set -e");
if !has_real_content {
std::fs::remove_file(&hook_path).map_err(|e| Error::Io {
source: e,
path: Some(hook_path.clone()),
})?;
println!("timebomb hook removed (file deleted — it only contained the timebomb block).");
} else {
std::fs::write(&hook_path, &cleaned).map_err(|e| Error::Io {
source: e,
path: Some(hook_path.clone()),
})?;
println!("timebomb block removed from {}", hook_path.display());
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn create_fake_git(tmp: &std::path::Path) {
std::fs::create_dir_all(tmp.join(".git").join("hooks")).unwrap();
}
#[test]
fn test_hook_install_creates_new_file() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let result = run_hook_install(tmp.path(), true).unwrap();
assert_eq!(result, 0);
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
assert!(hook_path.exists(), "pre-commit hook file should be created");
let content = std::fs::read_to_string(&hook_path).unwrap();
assert!(content.contains(MARKER_BEGIN));
assert!(content.contains(MARKER_END));
assert!(content.contains("timebomb sweep --since HEAD ."));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let meta = std::fs::metadata(&hook_path).unwrap();
assert_ne!(
meta.permissions().mode() & 0o111,
0,
"hook should be executable"
);
}
}
#[test]
fn test_hook_install_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
run_hook_install(tmp.path(), true).unwrap();
run_hook_install(tmp.path(), true).unwrap();
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
let content = std::fs::read_to_string(&hook_path).unwrap();
let count = content.matches(MARKER_BEGIN).count();
assert_eq!(count, 1, "marker block should appear exactly once");
}
#[test]
fn test_hook_install_appends_to_existing_hook() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
{
let mut f = std::fs::File::create(&hook_path).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "echo 'existing hook'").unwrap();
}
run_hook_install(tmp.path(), true).unwrap();
let content = std::fs::read_to_string(&hook_path).unwrap();
assert!(
content.contains("echo 'existing hook'"),
"original content preserved"
);
assert!(content.contains(MARKER_BEGIN), "timebomb block appended");
assert!(content.contains("timebomb sweep --since HEAD ."));
}
#[test]
fn test_hook_uninstall_removes_block() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
run_hook_install(tmp.path(), true).unwrap();
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
assert!(hook_path.exists());
run_hook_uninstall(tmp.path(), true).unwrap();
assert!(
!hook_path.exists(),
"hook file should be deleted when it only had the block"
);
}
#[test]
fn test_hook_uninstall_preserves_other_content() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
{
let mut f = std::fs::File::create(&hook_path).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
writeln!(f, "echo 'my other check'").unwrap();
}
run_hook_install(tmp.path(), true).unwrap();
run_hook_uninstall(tmp.path(), true).unwrap();
assert!(
hook_path.exists(),
"hook file should remain (has other content)"
);
let content = std::fs::read_to_string(&hook_path).unwrap();
assert!(
!content.contains(MARKER_BEGIN),
"timebomb marker should be gone"
);
assert!(
content.contains("my other check"),
"other content preserved"
);
}
#[test]
fn test_hook_uninstall_on_missing_hook() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let result = run_hook_uninstall(tmp.path(), true).unwrap();
assert_eq!(result, 0);
}
#[test]
fn test_remove_timebomb_block_basic() {
let input = "line before\n# BEGIN timebomb\ntimebomb sweep --since HEAD .\n# END timebomb\nline after\n";
let output = remove_timebomb_block(input);
assert!(!output.contains(MARKER_BEGIN));
assert!(!output.contains(MARKER_END));
assert!(output.contains("line before"));
assert!(output.contains("line after"));
}
#[test]
fn test_hook_has_timebomb_block() {
assert!(hook_has_timebomb_block(
"some content\n# BEGIN timebomb\nstuff\n# END timebomb\n"
));
assert!(!hook_has_timebomb_block("just a regular hook\n"));
}
#[test]
fn test_find_git_dir_not_found() {
let tmp = tempfile::tempdir().unwrap();
let result = find_git_dir(tmp.path());
assert!(result.is_err());
}
#[test]
fn test_find_git_dir_found() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let result = find_git_dir(tmp.path());
assert!(result.is_ok());
assert!(result.unwrap().ends_with(".git"));
}
#[test]
fn test_find_git_dir_found_from_subdirectory() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let subdir = tmp.path().join("a").join("b").join("c");
std::fs::create_dir_all(&subdir).unwrap();
let result = find_git_dir(&subdir);
assert!(result.is_ok());
}
#[test]
fn test_remove_timebomb_block_no_block_is_noop() {
let input = "#!/bin/sh\necho 'no timebomb here'\n";
let output = remove_timebomb_block(input);
assert!(output.contains("echo 'no timebomb here'"));
assert!(!output.contains(MARKER_BEGIN));
}
#[test]
fn test_remove_timebomb_block_preserves_surrounding_lines() {
let input = "\
#!/bin/sh\n\
echo before\n\
# BEGIN timebomb\n\
timebomb sweep --since HEAD .\n\
# END timebomb\n\
echo after\n\
";
let output = remove_timebomb_block(input);
assert!(!output.contains(MARKER_BEGIN));
assert!(!output.contains(MARKER_END));
assert!(output.contains("echo before"));
assert!(output.contains("echo after"));
assert!(!output.contains("timebomb sweep"));
}
#[test]
fn test_hook_install_creates_hooks_dir_if_missing() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".git")).unwrap();
let result = run_hook_install(tmp.path(), true);
assert!(result.is_ok());
let hooks_dir = tmp.path().join(".git").join("hooks");
assert!(hooks_dir.exists());
assert!(hooks_dir.join("pre-commit").exists());
}
#[test]
fn test_hook_uninstall_no_timebomb_in_existing_hook() {
let tmp = tempfile::tempdir().unwrap();
create_fake_git(tmp.path());
let hook_path = tmp.path().join(".git").join("hooks").join("pre-commit");
std::fs::write(&hook_path, "#!/bin/sh\necho 'unrelated'\n").unwrap();
let result = run_hook_uninstall(tmp.path(), true).unwrap();
assert_eq!(result, 0);
let content = std::fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("unrelated"));
}
#[test]
fn test_new_hook_content_is_executable_script() {
assert!(NEW_HOOK_CONTENT.starts_with("#!/bin/sh"));
assert!(NEW_HOOK_CONTENT.contains("set -e"));
assert!(NEW_HOOK_CONTENT.contains(MARKER_BEGIN));
assert!(NEW_HOOK_CONTENT.contains(MARKER_END));
}
#[test]
fn test_hook_block_constant_is_valid() {
assert!(HOOK_BLOCK.contains(MARKER_BEGIN));
assert!(HOOK_BLOCK.contains(MARKER_END));
assert!(HOOK_BLOCK.contains("timebomb sweep"));
}
}