use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::Result;
pub(crate) const HOOK_GUARD_START: &str =
"# >>> dodot transform check --strict (managed by `dodot transform install-hook`) >>>";
pub(crate) const HOOK_GUARD_END: &str = "# <<< dodot transform check --strict <<<";
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum InstallHookOutcome {
Created,
Appended,
AlreadyInstalled,
Updated,
}
#[derive(Debug, Clone, Serialize)]
pub struct InstallHookResult {
pub outcome: InstallHookOutcome,
pub hook_path: String,
pub hook_display_path: String,
pub command_line: String,
}
pub fn install_hook(ctx: &ExecutionContext) -> Result<InstallHookResult> {
let dotfiles_root = ctx.paths.dotfiles_root();
let git_dir = dotfiles_root.join(".git");
if !ctx.fs.is_dir(&git_dir) {
return Err(crate::DodotError::Other(format!(
"no .git directory at {}; pre-commit hooks only apply to git working \
trees. Run `git init` in {} first.",
git_dir.display(),
dotfiles_root.display(),
)));
}
let hooks_dir = git_dir.join("hooks");
let hook_path = hooks_dir.join("pre-commit");
let block = managed_block();
let outcome = if ctx.fs.exists(&hook_path) {
let existing = ctx.fs.read_to_string(&hook_path)?;
if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
let current_block = &existing[start_byte..end_byte];
if current_block == block {
InstallHookOutcome::AlreadyInstalled
} else {
let mut new_content = String::with_capacity(existing.len() + block.len());
new_content.push_str(&existing[..start_byte]);
new_content.push_str(&block);
new_content.push_str(&existing[end_byte..]);
ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
ctx.fs.set_permissions(&hook_path, 0o755)?;
InstallHookOutcome::Updated
}
} else {
let mut new_content = existing.clone();
if !new_content.ends_with('\n') {
new_content.push('\n');
}
if !new_content.ends_with("\n\n") {
new_content.push('\n');
}
new_content.push_str(&block);
ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
ctx.fs.set_permissions(&hook_path, 0o755)?;
InstallHookOutcome::Appended
}
} else {
ctx.fs.mkdir_all(&hooks_dir)?;
let mut new_content = String::from("#!/bin/sh\n\n");
new_content.push_str(&block);
ctx.fs.write_file(&hook_path, new_content.as_bytes())?;
ctx.fs.set_permissions(&hook_path, 0o755)?;
InstallHookOutcome::Created
};
Ok(InstallHookResult {
outcome,
hook_path: hook_path.display().to_string(),
hook_display_path: super::render_path(&hook_path, ctx.paths.home_dir()),
command_line: HOOK_COMMAND.to_string(),
})
}
pub fn hook_is_installed(ctx: &ExecutionContext) -> Result<bool> {
let hook_path = ctx.paths.dotfiles_root().join(".git/hooks/pre-commit");
if !ctx.fs.exists(&hook_path) {
return Ok(false);
}
let existing = ctx.fs.read_to_string(&hook_path)?;
Ok(existing.contains(HOOK_GUARD_START))
}
pub fn managed_block() -> String {
format!(
"{guard_start}\n\
# Aborts the commit if any template-source has drift that needs review —\n\
# divergent deployed file or unresolved dodot-conflict markers. Remove\n\
# this block to opt out.\n\
{refresh}\n\
{check}\n\
{guard_end}\n",
guard_start = HOOK_GUARD_START,
guard_end = HOOK_GUARD_END,
refresh = HOOK_COMMAND_REFRESH,
check = HOOK_COMMAND_CHECK,
)
}
pub(crate) const HOOK_COMMAND_REFRESH: &str = "dodot refresh --quiet || exit 1";
pub(crate) const HOOK_COMMAND_CHECK: &str = "dodot transform check --strict || exit 1";
pub(crate) const HOOK_COMMAND: &str = "dodot refresh --quiet && dodot transform check --strict";
fn find_managed_block(text: &str) -> Option<(usize, usize)> {
let start = text.find(HOOK_GUARD_START)?;
let after_start = start + HOOK_GUARD_START.len();
let end_rel = text[after_start..].find(HOOK_GUARD_END)?;
let end_guard_start = after_start + end_rel;
let end_byte = end_guard_start + HOOK_GUARD_END.len();
let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
end_byte + 1
} else {
end_byte
};
Some((start, end_byte))
}
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use super::super::test_support::make_ctx;
use super::*;
use crate::fs::Fs;
use crate::testing::TempEnvironment;
fn fake_git_dir(env: &TempEnvironment) {
env.fs
.mkdir_all(&env.dotfiles_root.join(".git/hooks"))
.unwrap();
}
#[test]
fn install_hook_creates_new_pre_commit_when_absent() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
assert!(!env.fs.exists(&hook_path));
let ctx = make_ctx(&env);
let result = install_hook(&ctx).unwrap();
assert!(matches!(result.outcome, InstallHookOutcome::Created));
assert!(env.fs.exists(&hook_path));
let body = env.fs.read_to_string(&hook_path).unwrap();
assert!(body.starts_with("#!/bin/sh\n"), "body: {body:?}");
assert!(body.contains(HOOK_GUARD_START), "body: {body:?}");
assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
assert!(body.contains(HOOK_GUARD_END), "body: {body:?}");
}
#[test]
fn install_hook_appends_to_existing_pre_commit() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
let existing = "#!/bin/sh\necho 'my pre-commit'\nexit 0\n";
env.fs.write_file(&hook_path, existing.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let result = install_hook(&ctx).unwrap();
assert!(matches!(result.outcome, InstallHookOutcome::Appended));
let body = env.fs.read_to_string(&hook_path).unwrap();
assert!(body.starts_with(existing), "user content lost: {body:?}");
assert!(body.contains(HOOK_GUARD_START));
assert!(body.contains(HOOK_COMMAND_REFRESH));
assert!(body.contains(HOOK_COMMAND_CHECK));
}
#[test]
fn install_hook_is_idempotent_on_second_call() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
let r1 = install_hook(&ctx).unwrap();
assert!(matches!(r1.outcome, InstallHookOutcome::Created));
let body_after_first = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
let r2 = install_hook(&ctx).unwrap();
assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
let body_after_second = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
assert_eq!(
body_after_first, body_after_second,
"body changed on second call"
);
assert_eq!(body_after_second.matches(HOOK_GUARD_START).count(), 1);
}
#[test]
fn install_hook_errors_if_no_git_dir() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let err = install_hook(&ctx).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("no .git directory"), "msg: {msg}");
assert!(msg.contains("git init"), "msg: {msg}");
}
#[test]
fn hook_is_installed_reports_correctly() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
assert!(!hook_is_installed(&ctx).unwrap());
install_hook(&ctx).unwrap();
assert!(hook_is_installed(&ctx).unwrap());
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
env.fs
.write_file(&hook_path, b"#!/bin/sh\necho hello\n")
.unwrap();
assert!(!hook_is_installed(&ctx).unwrap());
}
#[test]
fn install_hook_sets_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
install_hook(&ctx).unwrap();
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
let mode = std::fs::metadata(&hook_path).unwrap().permissions().mode();
assert!(
mode & 0o100 != 0,
"hook is not executable, mode = {:o}",
mode
);
}
#[test]
fn managed_block_is_self_contained_and_grep_detectable() {
let block = managed_block();
assert!(block.starts_with(HOOK_GUARD_START));
assert!(block.trim_end().ends_with(HOOK_GUARD_END));
assert!(block.contains(HOOK_COMMAND_REFRESH));
assert!(block.contains(HOOK_COMMAND_CHECK));
}
#[test]
fn install_hook_replaces_a_stale_managed_block() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let stale = format!(
"#!/bin/sh\n\
echo 'user-installed pre-commit step'\n\
\n\
{start}\n\
# Old-style block from R4. Still works, but doesn't run\n\
# `dodot refresh` first, so deployed-side edits between\n\
# commits aren't always picked up.\n\
dodot transform check --strict || exit 1\n\
{end}\n\
# User content after the block.\n\
echo 'trailing user step'\n",
start = HOOK_GUARD_START,
end = HOOK_GUARD_END,
);
let hook_path = env.dotfiles_root.join(".git/hooks/pre-commit");
env.fs.write_file(&hook_path, stale.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let result = install_hook(&ctx).unwrap();
assert!(matches!(result.outcome, InstallHookOutcome::Updated));
let body = env.fs.read_to_string(&hook_path).unwrap();
assert!(body.contains(HOOK_COMMAND_REFRESH), "body: {body:?}");
assert!(body.contains(HOOK_COMMAND_CHECK), "body: {body:?}");
assert!(body.contains("user-installed pre-commit step"));
assert!(body.contains("trailing user step"));
assert_eq!(body.matches(HOOK_GUARD_START).count(), 1);
assert_eq!(body.matches(HOOK_GUARD_END).count(), 1);
}
#[test]
fn install_hook_no_op_on_current_block() {
let env = TempEnvironment::builder().build();
fake_git_dir(&env);
let ctx = make_ctx(&env);
let r1 = install_hook(&ctx).unwrap();
assert!(matches!(r1.outcome, InstallHookOutcome::Created));
let body_after_first = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
let r2 = install_hook(&ctx).unwrap();
assert!(matches!(r2.outcome, InstallHookOutcome::AlreadyInstalled));
let body_after_second = env
.fs
.read_to_string(&env.dotfiles_root.join(".git/hooks/pre-commit"))
.unwrap();
assert_eq!(body_after_first, body_after_second);
}
#[test]
fn find_managed_block_locates_byte_range() {
let block = managed_block();
let prefix = "before\n";
let suffix = "after\n";
let text = format!("{prefix}{block}{suffix}");
let (start, end) = find_managed_block(&text).expect("must find block");
assert_eq!(&text[start..end], block);
}
#[test]
fn find_managed_block_returns_none_when_absent() {
assert!(find_managed_block("nothing here").is_none());
let only_start = format!("{HOOK_GUARD_START}\nrandom content\n");
assert!(find_managed_block(&only_start).is_none());
}
}