use anyhow::{Context, Result};
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};
const MARKER_BEGIN: &str = "# >>> trusty-search >>>";
const MARKER_END: &str = "# <<< trusty-search <<<";
#[derive(Debug)]
pub struct HookArgs {
pub repo: Option<PathBuf>,
pub action: HookAction,
}
#[derive(Debug, Clone, clap::Subcommand)]
pub enum HookAction {
Install,
Uninstall,
}
pub async fn handle_hook(args: HookArgs) -> Result<()> {
let repo_root = resolve_repo_root(args.repo.as_deref())?;
let hooks_dir = repo_root.join(".git").join("hooks");
anyhow::ensure!(
hooks_dir.exists(),
"no .git/hooks directory found at '{}' — is this a git repository?",
repo_root.display()
);
match args.action {
HookAction::Install => {
for (name, content) in hook_scripts() {
let path = hooks_dir.join(name);
install_block(&path, content)?;
println!("{} installed hook {}", "✓".green(), path.display());
}
println!(
" Run {} inside this repo to register it first.",
"trusty-search index add .".cyan()
);
}
HookAction::Uninstall => {
for (name, _) in hook_scripts() {
let path = hooks_dir.join(name);
uninstall_block(&path)?;
println!("{} removed trusty-search block from {}", "−".red(), name);
}
}
}
Ok(())
}
fn hook_scripts() -> [(&'static str, &'static str); 3] {
[
("post-commit", POST_COMMIT_SCRIPT),
("post-merge", POST_MERGE_SCRIPT),
("post-checkout", POST_CHECKOUT_SCRIPT),
]
}
const POST_COMMIT_SCRIPT: &str = r#"#!/bin/sh
# trusty-search: incremental reindex on commit
# CWD is always the repo root when git invokes this hook.
# `trusty-search add/remove` auto-detects the index from CWD — no --index needed.
BINARY="trusty-search"
# Bail out silently when the binary is not on PATH.
command -v "$BINARY" >/dev/null 2>&1 || exit 0
# No-op when the daemon is down (best-effort health probe).
"$BINARY" health >/dev/null 2>&1 || exit 0
# Determine changed/added vs deleted files in the last commit.
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
CHANGED="$(git diff-tree --no-commit-id -r --name-only --diff-filter=ACRMT HEAD 2>/dev/null)" || true
DELETED="$(git diff-tree --no-commit-id -r --name-only --diff-filter=D HEAD 2>/dev/null)" || true
# Index changed/added files.
if [ -n "$CHANGED" ]; then
echo "$CHANGED" | while IFS= read -r f; do
[ -f "$REPO_ROOT/$f" ] || continue
"$BINARY" add "$REPO_ROOT/$f" >/dev/null 2>&1 || true
done
fi
# Remove deleted files.
if [ -n "$DELETED" ]; then
echo "$DELETED" | while IFS= read -r f; do
"$BINARY" remove "$REPO_ROOT/$f" >/dev/null 2>&1 || true
done
fi
exit 0
"#;
const POST_MERGE_SCRIPT: &str = r#"#!/bin/sh
# trusty-search: incremental reindex on merge
# CWD is always the repo root when git invokes this hook.
BINARY="trusty-search"
command -v "$BINARY" >/dev/null 2>&1 || exit 0
"$BINARY" health >/dev/null 2>&1 || exit 0
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
# Resolve the pre-merge reference: ORIG_HEAD is reliable for true merges, but
# fast-forward pulls may not leave it. Fall back to HEAD@{1} (reflog), and if
# that also fails (initial clone) skip cleanly.
if git rev-parse --verify -q ORIG_HEAD >/dev/null 2>&1; then
BASE="ORIG_HEAD"
elif git rev-parse --verify -q "HEAD@{1}" >/dev/null 2>&1; then
BASE="HEAD@{1}"
else
exit 0
fi
CHANGED="$(git diff --name-only --diff-filter=ACRMT "$BASE" HEAD 2>/dev/null)" || true
DELETED="$(git diff --name-only --diff-filter=D "$BASE" HEAD 2>/dev/null)" || true
if [ -n "$CHANGED" ]; then
echo "$CHANGED" | while IFS= read -r f; do
[ -f "$REPO_ROOT/$f" ] || continue
"$BINARY" add "$REPO_ROOT/$f" >/dev/null 2>&1 || true
done
fi
if [ -n "$DELETED" ]; then
echo "$DELETED" | while IFS= read -r f; do
"$BINARY" remove "$REPO_ROOT/$f" >/dev/null 2>&1 || true
done
fi
exit 0
"#;
const POST_CHECKOUT_SCRIPT: &str = r#"#!/bin/sh
# trusty-search: background reindex on branch switch
# CWD is always the repo root when git invokes this hook.
# Only act on branch-switch checkouts ($3 == 1).
[ "$3" = "1" ] || exit 0
# Skip when the HEAD didn't actually change.
[ "$1" != "$2" ] || exit 0
BINARY="trusty-search"
command -v "$BINARY" >/dev/null 2>&1 || exit 0
"$BINARY" health >/dev/null 2>&1 || exit 0
# Fire-and-forget diff-only reindex in the background so the checkout
# returns immediately. The daemon's SHA-256 hash skip makes this cheap.
"$BINARY" reindex >/dev/null 2>&1 &
exit 0
"#;
pub fn install_block(hook_path: &Path, script_body: &str) -> Result<()> {
let existing = if hook_path.exists() {
fs::read_to_string(hook_path)
.with_context(|| format!("could not read {}", hook_path.display()))?
} else {
String::new()
};
let new_content = splice_block(&existing, script_body);
atomic_write(hook_path, &new_content)?;
make_executable(hook_path)?;
Ok(())
}
pub fn uninstall_block(hook_path: &Path) -> Result<()> {
if !hook_path.exists() {
return Ok(());
}
let content = fs::read_to_string(hook_path)
.with_context(|| format!("could not read {}", hook_path.display()))?;
let stripped = remove_block(&content);
if stripped == content {
return Ok(());
}
if stripped.trim().is_empty() {
fs::remove_file(hook_path)
.with_context(|| format!("could not remove {}", hook_path.display()))?;
} else {
atomic_write(hook_path, &stripped)?;
make_executable(hook_path)?;
}
Ok(())
}
pub fn splice_block(existing: &str, script_body: &str) -> String {
let block = format!("{}\n{}{}\n", MARKER_BEGIN, script_body, MARKER_END);
if let Some(begin) = existing.find(MARKER_BEGIN) {
if let Some(end_pos) = existing[begin..].find(MARKER_END) {
let abs_end = begin + end_pos + MARKER_END.len();
let abs_end = if existing.as_bytes().get(abs_end) == Some(&b'\n') {
abs_end + 1
} else {
abs_end
};
let mut result = existing[..begin].to_owned();
result.push_str(&block);
result.push_str(&existing[abs_end..]);
return result;
}
}
if existing.is_empty() {
block
} else {
let sep = if existing.ends_with('\n') { "" } else { "\n" };
format!("{}{}{}", existing, sep, block)
}
}
pub fn remove_block(content: &str) -> String {
let Some(begin) = content.find(MARKER_BEGIN) else {
return content.to_owned();
};
let Some(end_pos) = content[begin..].find(MARKER_END) else {
return content.to_owned();
};
let abs_end = begin + end_pos + MARKER_END.len();
let abs_end = if content.as_bytes().get(abs_end) == Some(&b'\n') {
abs_end + 1
} else {
abs_end
};
let mut result = content[..begin].to_owned();
result.push_str(&content[abs_end..]);
result
}
fn atomic_write(path: &Path, content: &str) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("hook path '{}' has no parent directory", path.display()))?;
fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?;
let uid = {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("{}-{}", std::process::id(), nanos)
};
let file_name = path
.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("hook"));
let tmp_name = format!(".{}-{}.ts-tmp", file_name.to_string_lossy(), uid);
let tmp = parent.join(tmp_name);
fs::write(&tmp, content).with_context(|| format!("could not write {}", tmp.display()))?;
fs::rename(&tmp, path)
.with_context(|| format!("could not rename {} to {}", tmp.display(), path.display()))?;
Ok(())
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(path).with_context(|| format!("could not stat {}", path.display()))?;
let mut perms = meta.permissions();
let mode = perms.mode() | 0o111;
perms.set_mode(mode);
fs::set_permissions(path, perms)
.with_context(|| format!("could not chmod +x {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
fn resolve_repo_root(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
let canon =
fs::canonicalize(p).with_context(|| format!("could not resolve '{}'", p.display()))?;
return Ok(canon);
}
let out = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("could not run git")?;
if !out.status.success() {
anyhow::bail!("not inside a git repository; pass --repo <path>");
}
let raw = std::str::from_utf8(&out.stdout)
.context("git output was not UTF-8")?
.trim()
.to_owned();
Ok(PathBuf::from(raw))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn splice_inserts_into_empty() {
let result = splice_block("", "echo hello\n");
assert!(result.contains(MARKER_BEGIN), "missing begin marker");
assert!(result.contains(MARKER_END), "missing end marker");
assert!(result.contains("echo hello"), "missing body");
}
#[test]
fn splice_appends_to_existing_content() {
let existing = "#!/bin/sh\necho other\n";
let result = splice_block(existing, "echo ts\n");
assert!(
result.starts_with("#!/bin/sh\necho other\n"),
"existing content moved"
);
assert!(result.contains(MARKER_BEGIN), "missing begin marker");
assert!(result.contains("echo ts"), "missing body");
}
#[test]
fn splice_replaces_existing_block() {
let first = splice_block("", "echo v1\n");
let second = splice_block(&first, "echo v2\n");
assert_eq!(second.matches(MARKER_BEGIN).count(), 1, "duplicate begin");
assert_eq!(second.matches(MARKER_END).count(), 1, "duplicate end");
assert!(second.contains("echo v2"), "new body missing");
assert!(!second.contains("echo v1"), "old body still present");
}
#[test]
fn remove_block_is_symmetric() {
let original = "#!/bin/sh\necho other\n";
let installed = splice_block(original, "echo ts\n");
let restored = remove_block(&installed);
assert_eq!(restored.trim(), original.trim());
}
#[test]
fn remove_block_noop_when_absent() {
let content = "#!/bin/sh\necho other\n";
let result = remove_block(content);
assert_eq!(result, content);
}
#[test]
fn post_commit_script_does_not_use_empty_index_flag() {
assert!(
!POST_COMMIT_SCRIPT.contains("--index \"\""),
"post-commit must not use --index \"\""
);
assert!(
!POST_COMMIT_SCRIPT.contains("--index ''"),
"post-commit must not use --index ''"
);
}
#[test]
fn post_merge_script_does_not_use_empty_index_flag() {
assert!(
!POST_MERGE_SCRIPT.contains("--index \"\""),
"post-merge must not use --index \"\""
);
assert!(
!POST_MERGE_SCRIPT.contains("--index ''"),
"post-merge must not use --index ''"
);
}
#[test]
fn post_merge_script_guards_orig_head_existence() {
assert!(
POST_MERGE_SCRIPT.contains("rev-parse --verify"),
"post-merge must verify ORIG_HEAD before using it"
);
assert!(
POST_MERGE_SCRIPT.contains("HEAD@{1}"),
"post-merge must have a HEAD@{{1}} fallback"
);
}
fn fake_hooks_dir() -> (TempDir, PathBuf) {
let tmp = tempfile::tempdir().unwrap();
let hooks = tmp.path().join(".git").join("hooks");
fs::create_dir_all(&hooks).unwrap();
(tmp, hooks)
}
#[test]
fn install_creates_hook_file() {
let (_tmp, hooks) = fake_hooks_dir();
let path = hooks.join("post-commit");
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
assert!(path.exists(), "hook file was not created");
}
#[cfg(unix)]
#[test]
fn install_creates_hook_file_and_is_executable() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, hooks) = fake_hooks_dir();
let path = hooks.join("post-commit");
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode();
assert_ne!(mode & 0o111, 0, "hook is not executable");
}
#[test]
fn install_is_idempotent() {
let (_tmp, hooks) = fake_hooks_dir();
let path = hooks.join("post-commit");
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert_eq!(content.matches(MARKER_BEGIN).count(), 1, "block duplicated");
}
#[test]
fn install_preserves_existing_shebang() {
let (_tmp, hooks) = fake_hooks_dir();
let path = hooks.join("post-commit");
fs::write(&path, "#!/bin/sh\necho existing\n").unwrap();
#[cfg(unix)]
make_executable(&path).unwrap();
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.starts_with("#!/bin/sh"), "shebang moved");
assert!(content.contains("echo existing"), "prior content gone");
assert!(
content.contains(MARKER_BEGIN),
"trusty-search block missing"
);
}
#[test]
fn uninstall_removes_block() {
let (_tmp, hooks) = fake_hooks_dir();
let path = hooks.join("post-commit");
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
uninstall_block(&path).unwrap();
if path.exists() {
let content = fs::read_to_string(&path).unwrap();
assert!(
!content.contains(MARKER_BEGIN),
"marker still present after uninstall"
);
}
}
#[test]
fn uninstall_preserves_other_content() {
let (_tmp, hooks) = fake_hooks_dir();
let path = hooks.join("post-commit");
fs::write(&path, "#!/bin/sh\necho other-tool\n").unwrap();
#[cfg(unix)]
make_executable(&path).unwrap();
install_block(&path, POST_COMMIT_SCRIPT).unwrap();
uninstall_block(&path).unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(
content.contains("echo other-tool"),
"other tool's content gone"
);
assert!(
!content.contains(MARKER_BEGIN),
"trusty-search block still present"
);
}
#[test]
fn uninstall_noop_when_file_absent() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nonexistent-hook");
uninstall_block(&path).unwrap(); }
fn fake_git_repo() -> TempDir {
let tmp = tempfile::tempdir().unwrap();
let hooks_dir = tmp.path().join(".git").join("hooks");
fs::create_dir_all(&hooks_dir).unwrap();
tmp
}
#[tokio::test]
async fn handle_hook_install_writes_all_three_hooks() {
let repo = fake_git_repo();
let args = HookArgs {
repo: Some(repo.path().to_path_buf()),
action: HookAction::Install,
};
handle_hook(args).await.unwrap();
for name in ["post-commit", "post-merge", "post-checkout"] {
let p = repo.path().join(".git").join("hooks").join(name);
assert!(p.exists(), "{name} was not created");
let content = fs::read_to_string(&p).unwrap();
assert!(content.contains(MARKER_BEGIN), "{name} missing marker");
}
}
#[tokio::test]
async fn handle_hook_uninstall_removes_all_three_hooks() {
let repo = fake_git_repo();
let install_args = HookArgs {
repo: Some(repo.path().to_path_buf()),
action: HookAction::Install,
};
handle_hook(install_args).await.unwrap();
let uninstall_args = HookArgs {
repo: Some(repo.path().to_path_buf()),
action: HookAction::Uninstall,
};
handle_hook(uninstall_args).await.unwrap();
for name in ["post-commit", "post-merge", "post-checkout"] {
let p = repo.path().join(".git").join("hooks").join(name);
if p.exists() {
let content = fs::read_to_string(&p).unwrap();
assert!(
!content.contains(MARKER_BEGIN),
"{name} still has trusty-search block"
);
}
}
}
#[tokio::test]
async fn handle_hook_install_errors_outside_git_repo() {
let tmp = tempfile::tempdir().unwrap();
let args = HookArgs {
repo: Some(tmp.path().to_path_buf()),
action: HookAction::Install,
};
let result = handle_hook(args).await;
assert!(result.is_err(), "expected error outside a git repo");
}
}