use crate::agents::expand_tilde;
use crate::agents::home_dir;
use clap::ValueEnum;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum GitHookMode {
Default,
Yes,
No,
}
const HOOK_MARKER: &str = "# tokensave: auto-sync";
const HOOK_MARKER_CHECKOUT: &str = "# tokensave: auto-init";
const HOOK_MARKER_CHAIN: &str = "# tokensave: chain-repo-hook";
fn chain_repo_hook_snippet(hook_name: &str) -> String {
format!(
"{HOOK_MARKER_CHAIN}\n\
repo_hook=\"$(git rev-parse --git-dir 2>/dev/null)/hooks/{hook_name}\"\n\
if [ -x \"$repo_hook\" ] && [ \"$repo_hook\" != \"$0\" ]; then\n\
\t\"$repo_hook\" \"$@\"\n\
fi\n"
)
}
const FORWARDED_REPO_HOOKS: &[&str] = &[
"applypatch-msg",
"pre-applypatch",
"post-applypatch",
"pre-commit",
"pre-merge-commit",
"prepare-commit-msg",
"commit-msg",
"pre-rebase",
"post-merge",
"pre-push",
"post-rewrite",
"pre-auto-gc",
"post-index-change",
"push-to-checkout",
"sendemail-validate",
"reference-transaction",
];
fn install_repo_hook_forwarders(
hooks_dir: &Path,
claiming_hookspath: bool,
hooks_dir_is_default: bool,
) {
if !(claiming_hookspath || hooks_dir_is_default) {
return;
}
for name in FORWARDED_REPO_HOOKS {
let path = hooks_dir.join(name);
if path.exists() {
continue;
}
write_global_hook(&path, &chain_repo_hook_snippet(name));
}
}
fn should_chain_repo_hooks(
claiming_hookspath: bool,
hooks_dir_is_default: bool,
existing_contents: Option<&str>,
) -> bool {
if existing_contents.is_some_and(|c| c.contains(HOOK_MARKER_CHAIN)) {
return false;
}
claiming_hookspath
|| (hooks_dir_is_default
&& existing_contents
.is_none_or(|c| c.contains(HOOK_MARKER) || c.contains(HOOK_MARKER_CHECKOUT)))
}
fn post_commit_snippet(tokensave_bin: &str) -> String {
let bin = tokensave_bin.replace('\\', "/");
format!(
"{HOOK_MARKER}\n\
{bin} sync >/dev/null 2>&1 &\n"
)
}
fn post_checkout_snippet(tokensave_bin: &str) -> String {
let bin = tokensave_bin.replace('\\', "/");
format!(
"{HOOK_MARKER_CHECKOUT}\n\
if [ \"$1\" = \"0000000000000000000000000000000000000000\" ]; then\n\
\t{bin} init >/dev/null 2>&1 &\n\
elif [ \"$3\" = \"1\" ]; then\n\
\t{bin} branch add >/dev/null 2>&1 &\n\
fi\n"
)
}
fn write_global_hook(hook_path: &Path, snippet: &str) -> bool {
if hook_path.exists() {
use std::io::Write;
let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(hook_path) else {
eprintln!(
" \x1b[31m✘\x1b[0m Failed to open {} for writing",
hook_path.display()
);
return false;
};
if write!(f, "\n{snippet}").is_err() {
eprintln!(
" \x1b[31m✘\x1b[0m Failed to write to {}",
hook_path.display()
);
return false;
}
} else {
let contents = format!("#!/bin/sh\n{snippet}");
if std::fs::write(hook_path, contents).is_err() {
eprintln!(
" \x1b[31m✘\x1b[0m Failed to create {}",
hook_path.display()
);
return false;
}
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(hook_path, std::fs::Permissions::from_mode(0o755));
}
true
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HookAction {
AlreadyInstalled,
Skip,
Prompt,
Install,
}
pub(crate) fn decide_hook_action(mode: GitHookMode, hook_contents: Option<&str>) -> HookAction {
if hook_contents.is_some_and(|c| c.contains(HOOK_MARKER)) {
return HookAction::AlreadyInstalled;
}
match mode {
GitHookMode::Default if atty_stdin() => HookAction::Prompt,
GitHookMode::Default | GitHookMode::No => HookAction::Skip,
GitHookMode::Yes => HookAction::Install,
}
}
pub fn offer_git_post_commit_hook(tokensave_bin: &str, mode: GitHookMode) {
let Some(home) = home_dir() else { return };
let hooks_dir = read_global_hooks_path(&home);
let default_hooks_dir = home.join(".config").join("git").join("hooks");
let (hooks_dir, need_set_hookspath) = match hooks_dir {
Some(dir) => (dir, false),
None => (default_hooks_dir.clone(), true),
};
let hooks_dir_is_default = hooks_dir == default_hooks_dir;
if need_set_hookspath {
let template_dir = [
home.join(".gitconfig"),
home.join(".config").join("git").join("config"),
]
.iter()
.find_map(|p| parse_gitconfig_value(p, "init", "templatedir"));
if let Some(dir) = template_dir {
eprintln!(
" \x1b[33m⚠\x1b[0m git init.templateDir is set ({dir}). Installing sets a global \
core.hooksPath, which makes git skip each repository's .git/hooks/. tokensave's \
global hooks forward to the repository's own hooks so they keep running."
);
}
}
let hook_path = hooks_dir.join("post-commit");
let existing_contents: Option<String> = if hook_path.exists() {
std::fs::read_to_string(&hook_path).ok()
} else {
None
};
let install_post_commit = match decide_hook_action(mode, existing_contents.as_deref()) {
HookAction::AlreadyInstalled => {
eprintln!(" Global git post-commit hook already contains tokensave, skipping");
false
}
HookAction::Skip => {
return;
}
HookAction::Prompt => {
eprintln!();
eprint!(
"Install global git \x1b[1mpost-commit\x1b[0m + \x1b[1mpost-checkout\x1b[0m hooks to auto-run \x1b[1mtokensave sync\x1b[0m after each commit and \x1b[1mtokensave init\x1b[0m after a fresh clone? [y/N] "
);
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
return;
}
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
eprintln!(" Skipped git hooks");
return;
}
true
}
HookAction::Install => true,
};
if let Err(e) = std::fs::create_dir_all(&hooks_dir) {
eprintln!(
" \x1b[31m✘\x1b[0m Failed to create {}: {e}",
hooks_dir.display()
);
return;
}
if need_set_hookspath {
let gitconfig_path = home.join(".gitconfig");
if let Err(msg) = set_global_hooks_path(&gitconfig_path, &hooks_dir) {
eprintln!(" \x1b[31m✘\x1b[0m {msg} — hook not installed");
return;
}
eprintln!(
"\x1b[32m✔\x1b[0m Set git core.hooksPath to {}",
hooks_dir.display()
);
}
if should_chain_repo_hooks(
need_set_hookspath,
hooks_dir_is_default,
existing_contents.as_deref(),
) {
write_global_hook(&hook_path, &chain_repo_hook_snippet("post-commit"));
}
if install_post_commit && write_global_hook(&hook_path, &post_commit_snippet(tokensave_bin)) {
eprintln!(
"\x1b[32m✔\x1b[0m Installed global git post-commit hook at {}",
hook_path.display()
);
}
let checkout_path = hooks_dir.join("post-checkout");
let checkout_contents = std::fs::read_to_string(&checkout_path).ok();
if should_chain_repo_hooks(
need_set_hookspath,
hooks_dir_is_default,
checkout_contents.as_deref(),
) {
write_global_hook(&checkout_path, &chain_repo_hook_snippet("post-checkout"));
}
let checkout_present = checkout_contents.is_some_and(|c| c.contains(HOOK_MARKER_CHECKOUT));
if !checkout_present && write_global_hook(&checkout_path, &post_checkout_snippet(tokensave_bin))
{
eprintln!(
"\x1b[32m✔\x1b[0m Installed global git post-checkout hook at {}",
checkout_path.display()
);
}
install_repo_hook_forwarders(&hooks_dir, need_set_hookspath, hooks_dir_is_default);
}
fn read_global_hooks_path(home: &Path) -> Option<PathBuf> {
let candidates = [
home.join(".gitconfig"),
home.join(".config").join("git").join("config"),
];
for path in &candidates {
if let Some(value) = parse_gitconfig_value(path, "core", "hookspath") {
let expanded = expand_tilde(&value, home);
let p = PathBuf::from(&expanded);
if p.is_absolute() {
return Some(p);
}
return Some(home.join(p));
}
}
None
}
fn parse_gitconfig_value(path: &Path, section: &str, key: &str) -> Option<String> {
let contents = std::fs::read_to_string(path).ok()?;
let section_lower = section.to_ascii_lowercase();
let key_lower = key.to_ascii_lowercase();
let mut in_section = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
let header = trimmed
.trim_start_matches('[')
.split(']')
.next()
.unwrap_or("")
.trim();
let section_name = header.split_whitespace().next().unwrap_or("");
in_section = section_name.eq_ignore_ascii_case(§ion_lower);
continue;
}
if !in_section {
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if let Some((k, v)) = trimmed.split_once('=') {
if k.trim().to_ascii_lowercase() == key_lower {
let v = v.trim();
let v = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
return Some(v.to_string());
}
}
}
None
}
fn set_global_hooks_path(
gitconfig_path: &Path,
hooks_dir: &Path,
) -> std::result::Result<(), String> {
let hooks_str = hooks_dir.to_string_lossy().replace('\\', "/");
let contents = if gitconfig_path.exists() {
std::fs::read_to_string(gitconfig_path)
.map_err(|e| format!("Failed to read {}: {e}", gitconfig_path.display()))?
} else {
String::new()
};
let new_contents = insert_gitconfig_value(&contents, "core", "hooksPath", &hooks_str);
if let Some(parent) = gitconfig_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create {}: {e}", parent.display()))?;
}
std::fs::write(gitconfig_path, new_contents)
.map_err(|e| format!("Failed to write {}: {e}", gitconfig_path.display()))?;
Ok(())
}
fn insert_gitconfig_value(contents: &str, section: &str, key: &str, value: &str) -> String {
let section_lower = section.to_ascii_lowercase();
let lines: Vec<&str> = contents.lines().collect();
let mut result = Vec::with_capacity(lines.len() + 3);
let entry = format!("\t{key} = {value}");
let mut section_end: Option<usize> = None;
let mut in_section = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
if in_section {
section_end = Some(i);
break;
}
let header = trimmed
.trim_start_matches('[')
.split(']')
.next()
.unwrap_or("")
.trim();
let name = header.split_whitespace().next().unwrap_or("");
if name.eq_ignore_ascii_case(§ion_lower) {
in_section = true;
}
}
}
if in_section && section_end.is_none() {
section_end = Some(lines.len());
}
if let Some(insert_at) = section_end {
for (i, line) in lines.iter().enumerate() {
if i == insert_at {
result.push(entry.as_str());
}
result.push(line);
}
if insert_at == lines.len() {
result.push(&entry);
}
} else {
for line in &lines {
result.push(line);
}
if !contents.is_empty() && !contents.ends_with('\n') {
result.push("");
}
let section_header = format!("[{section}]");
let mut out = result.join("\n");
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(§ion_header);
out.push('\n');
out.push_str(&entry);
out.push('\n');
return out;
}
let mut out = result.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn atty_stdin() -> bool {
use std::io::IsTerminal;
std::io::stdin().is_terminal()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod git_hook_tests {
use super::*;
use crate::agents::*;
use std::path::Path;
#[test]
fn parse_hookspath_basic() {
let config = "[core]\n\thooksPath = /home/user/.git-hooks\n";
assert_eq!(
parse_gitconfig_value_from_str(config, "core", "hookspath"),
Some("/home/user/.git-hooks".to_string())
);
}
#[test]
fn parse_hookspath_quoted() {
let config = "[core]\n\thooksPath = \"/home/user/my hooks\"\n";
assert_eq!(
parse_gitconfig_value_from_str(config, "core", "hookspath"),
Some("/home/user/my hooks".to_string())
);
}
#[test]
fn parse_hookspath_case_insensitive() {
let config = "[Core]\n\tHooksPath = /tmp/hooks\n";
assert_eq!(
parse_gitconfig_value_from_str(config, "core", "hookspath"),
Some("/tmp/hooks".to_string())
);
}
#[test]
fn parse_hookspath_missing() {
let config = "[core]\n\tautocrlf = true\n";
assert_eq!(
parse_gitconfig_value_from_str(config, "core", "hookspath"),
None
);
}
#[test]
fn parse_hookspath_wrong_section() {
let config = "[user]\n\thooksPath = /nope\n[core]\n\tautocrlf = true\n";
assert_eq!(
parse_gitconfig_value_from_str(config, "core", "hookspath"),
None
);
}
#[test]
fn insert_into_existing_section() {
let config = "[user]\n\tname = Test\n[core]\n\tautocrlf = true\n";
let result = insert_gitconfig_value(config, "core", "hooksPath", "/tmp/hooks");
assert!(result.contains("\thooksPath = /tmp/hooks"));
assert!(result.contains("[core]"));
assert!(result.contains("autocrlf = true"));
}
#[test]
fn insert_new_section() {
let config = "[user]\n\tname = Test\n";
let result = insert_gitconfig_value(config, "core", "hooksPath", "/tmp/hooks");
assert!(result.contains("[core]\n\thooksPath = /tmp/hooks"));
}
#[test]
fn insert_into_empty_file() {
let result = insert_gitconfig_value("", "core", "hooksPath", "/tmp/hooks");
assert!(result.contains("[core]\n\thooksPath = /tmp/hooks"));
}
#[test]
fn insert_before_next_section() {
let config = "[core]\n\tautocrlf = true\n[user]\n\tname = Test\n";
let result = insert_gitconfig_value(config, "core", "hooksPath", "/tmp/hooks");
let hooks_pos = result.find("hooksPath").unwrap();
let user_pos = result.find("[user]").unwrap();
let autocrlf_pos = result.find("autocrlf").unwrap();
assert!(hooks_pos > autocrlf_pos);
assert!(hooks_pos < user_pos);
}
#[test]
fn expand_tilde_with_slash() {
let home = Path::new("/home/test");
assert_eq!(expand_tilde("~/hooks", home), "/home/test/hooks");
}
#[test]
fn expand_tilde_bare() {
let home = Path::new("/home/test");
assert_eq!(expand_tilde("~", home), "/home/test");
}
#[test]
fn expand_tilde_no_tilde() {
let home = Path::new("/home/test");
assert_eq!(expand_tilde("/abs/path", home), "/abs/path");
}
#[test]
fn decide_hook_action_yes_installs_when_file_missing() {
assert_eq!(
decide_hook_action(GitHookMode::Yes, None),
HookAction::Install
);
}
#[test]
fn decide_hook_action_yes_installs_when_file_exists_without_marker() {
let contents = "#!/bin/sh\necho hello\n";
assert_eq!(
decide_hook_action(GitHookMode::Yes, Some(contents)),
HookAction::Install
);
}
#[test]
fn decide_hook_action_yes_reports_already_installed_when_marker_present() {
let contents = "#!/bin/sh\n# tokensave: auto-sync\n/usr/bin/tokensave sync\n";
assert_eq!(
decide_hook_action(GitHookMode::Yes, Some(contents)),
HookAction::AlreadyInstalled
);
}
#[test]
fn decide_hook_action_no_skips_even_when_file_missing() {
assert_eq!(decide_hook_action(GitHookMode::No, None), HookAction::Skip);
}
#[test]
fn post_checkout_snippet_inits_only_on_fresh_clone() {
let s = post_checkout_snippet("/usr/local/bin/tokensave");
assert!(
s.contains(HOOK_MARKER_CHECKOUT),
"must carry its idempotency marker, got: {s}"
);
assert!(
s.contains("/usr/local/bin/tokensave init"),
"must run `init` with the resolved binary, got: {s}"
);
assert!(
s.contains("0000000000000000000000000000000000000000"),
"must guard on the fresh-clone sentinel so branch switches re-route to branch add, got: {s}"
);
assert!(
s.contains("elif [ \"$3\" = \"1\" ]")
&& s.contains("/usr/local/bin/tokensave branch add"),
"must transparently track the branch on a branch checkout (flag $3==1), got: {s}"
);
}
#[test]
fn write_global_hook_creates_with_shebang_then_appends() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("post-checkout");
assert!(write_global_hook(&path, "FIRST\n"));
let after_create = std::fs::read_to_string(&path).unwrap();
assert!(
after_create.starts_with("#!/bin/sh\n"),
"new hook file must get a shebang, got: {after_create}"
);
assert!(after_create.contains("FIRST"));
assert!(write_global_hook(&path, "SECOND\n"));
let after_append = std::fs::read_to_string(&path).unwrap();
assert!(
after_append.contains("FIRST") && after_append.contains("SECOND"),
"second write must append, not clobber, got: {after_append}"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o111, 0o111, "hook must be executable");
}
}
#[test]
fn bare_name_resolves_through_injected_path() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::write(dir.path().join("tokensave"), "").unwrap();
let path_var = dir.path().to_string_lossy().to_string();
assert!(command_resolves_to_tokensave_in(
"tokensave",
Some(&path_var)
));
assert!(!command_resolves_to_tokensave_in(
"tokensave",
Some("/nonexistent")
));
assert!(!command_resolves_to_tokensave_in("tokensave", None));
assert!(!command_resolves_to_tokensave_in(
"othertool",
Some(&path_var)
));
}
#[test]
fn preserve_mcp_command_replaces_stale_or_foreign_commands() {
assert_eq!(
preserve_mcp_command_str(Some("/nonexistent/dir/tokensave"), "/new/tokensave"),
"/new/tokensave"
);
assert_eq!(
preserve_mcp_command_str(Some("/bin/sh"), "/new/tokensave"),
"/new/tokensave"
);
assert_eq!(
preserve_mcp_command_str(None, "/new/tokensave"),
"/new/tokensave"
);
}
#[test]
fn preserve_mcp_command_reads_string_and_array_shapes() {
let dir = tempfile::TempDir::new().unwrap();
let abs = dir.path().join("tokensave");
std::fs::write(&abs, "").unwrap();
let abs = abs.to_string_lossy().to_string();
let string_shape = serde_json::json!(abs);
assert_eq!(preserve_mcp_command(Some(&string_shape), "/new/bin"), abs);
let array_shape = serde_json::json!([abs, "serve"]);
assert_eq!(preserve_mcp_command(Some(&array_shape), "/new/bin"), abs);
}
#[test]
fn forwarded_hooks_cover_common_types_but_not_tokensave_owned() {
assert!(!FORWARDED_REPO_HOOKS.contains(&"post-commit"));
assert!(!FORWARDED_REPO_HOOKS.contains(&"post-checkout"));
for h in ["pre-commit", "pre-push", "commit-msg", "prepare-commit-msg"] {
assert!(
FORWARDED_REPO_HOOKS.contains(&h),
"{h} must be forwarded or a global hooksPath silently disables it"
);
}
for h in ["pre-receive", "update", "post-receive", "proc-receive"] {
assert!(!FORWARDED_REPO_HOOKS.contains(&h));
}
}
#[test]
fn install_repo_hook_forwarders_writes_when_claiming_and_skips_existing() {
let dir = tempfile::tempdir().unwrap();
let user_pre_commit = dir.path().join("pre-commit");
std::fs::write(&user_pre_commit, "#!/bin/sh\n# user's own\n").unwrap();
install_repo_hook_forwarders(dir.path(), true, true);
assert_eq!(
std::fs::read_to_string(&user_pre_commit).unwrap(),
"#!/bin/sh\n# user's own\n",
"an existing hook must never be clobbered"
);
let created = std::fs::read_to_string(dir.path().join("pre-push")).unwrap();
assert!(created.starts_with("#!/bin/sh\n"));
assert!(created.contains(HOOK_MARKER_CHAIN));
assert!(created.contains("/hooks/pre-push"));
assert!(created.contains("git rev-parse --git-dir"));
}
#[test]
fn install_repo_hook_forwarders_noop_for_user_managed_hookspath() {
let dir = tempfile::tempdir().unwrap();
install_repo_hook_forwarders(dir.path(), false, false);
assert!(
!dir.path().join("pre-commit").exists(),
"must leave a user-managed hooksPath directory untouched"
);
}
#[test]
fn chain_snippet_forwards_to_repo_hook_via_git_dir() {
let s = chain_repo_hook_snippet("post-checkout");
assert!(s.contains(HOOK_MARKER_CHAIN));
assert!(s.contains("git rev-parse --git-dir"));
assert!(!s.contains("--git-path"));
assert!(s.contains("/hooks/post-checkout"));
assert!(s.contains("\"$@\""));
}
#[test]
fn should_chain_when_claiming_hookspath() {
assert!(should_chain_repo_hooks(true, true, None));
assert!(should_chain_repo_hooks(true, false, None));
}
#[test]
fn should_chain_retrofits_tokensave_owned_default_dir() {
assert!(should_chain_repo_hooks(
false,
true,
Some("#!/bin/sh\n# tokensave: auto-sync\ntokensave sync &\n")
));
assert!(should_chain_repo_hooks(false, true, None));
}
#[test]
fn should_not_chain_user_managed_hookspath_or_twice() {
assert!(!should_chain_repo_hooks(
false,
false,
Some("#!/bin/sh\nmy-own-hook\n")
));
assert!(!should_chain_repo_hooks(
false,
true,
Some("#!/bin/sh\nmy-own-hook\n")
));
assert!(!should_chain_repo_hooks(
true,
true,
Some("#!/bin/sh\n# tokensave: chain-repo-hook\n")
));
}
#[test]
fn decide_hook_action_no_still_reports_already_installed() {
let contents = "# tokensave: auto-sync\nfoo\n";
assert_eq!(
decide_hook_action(GitHookMode::No, Some(contents)),
HookAction::AlreadyInstalled
);
}
#[test]
fn decide_hook_action_default_skips_when_file_missing() {
let action = decide_hook_action(GitHookMode::Default, None);
assert!(matches!(action, HookAction::Skip | HookAction::Prompt));
}
#[test]
fn decide_hook_action_default_already_installed_wins_over_tty() {
let contents = "# tokensave: auto-sync\nfoo\n";
assert_eq!(
decide_hook_action(GitHookMode::Default, Some(contents)),
HookAction::AlreadyInstalled
);
}
fn parse_gitconfig_value_from_str(contents: &str, section: &str, key: &str) -> Option<String> {
let section_lower = section.to_ascii_lowercase();
let key_lower = key.to_ascii_lowercase();
let mut in_section = false;
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
let header = trimmed
.trim_start_matches('[')
.split(']')
.next()
.unwrap_or("")
.trim();
let section_name = header.split_whitespace().next().unwrap_or("");
in_section = section_name.eq_ignore_ascii_case(§ion_lower);
continue;
}
if !in_section {
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if let Some((k, v)) = trimmed.split_once('=') {
if k.trim().to_ascii_lowercase() == key_lower {
let v = v.trim();
let v = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.unwrap_or(v);
return Some(v.to_string());
}
}
}
None
}
}