use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::hook::core::{files, shell};
use super::Outcome;
pub(crate) const HOOK_NAMES: [&str; 4] =
["post-commit", "post-checkout", "post-merge", "post-rewrite"];
const MARKER_ID: &str = "githooks";
pub(crate) fn install(st_program: &str) -> Result<Outcome, String> {
install_at(&hooks_dir()?, st_program)
}
pub(crate) fn uninstall() -> Result<Outcome, String> {
uninstall_at(&hooks_dir()?)
}
pub(crate) fn show() -> Result<Outcome, String> {
show_at(&hooks_dir()?)
}
pub(crate) fn install_at(dir: &Path, st_program: &str) -> Result<Outcome, String> {
let block = fire_and_forget_block(st_program);
let mut outcome = Outcome::default();
for name in HOOK_NAMES {
let path = dir.join(name);
if ensure_shell_block(&path, &block)? {
outcome.changed.push(path);
}
}
outcome.installed = true;
Ok(outcome)
}
pub(crate) fn uninstall_at(dir: &Path) -> Result<Outcome, String> {
let mut outcome = Outcome::default();
for name in HOOK_NAMES {
let path = dir.join(name);
if remove_shell_block(&path)? {
outcome.changed.push(path);
}
}
Ok(outcome)
}
pub(crate) fn show_at(dir: &Path) -> Result<Outcome, String> {
let mut installed = true;
for name in HOOK_NAMES {
installed &= contains_marker(&dir.join(name))?;
}
Ok(Outcome {
installed,
..Outcome::default()
})
}
fn hooks_dir() -> Result<PathBuf, String> {
let cwd = std::env::current_dir()
.map_err(|err| format!("st: failed to read current directory: {err}"))?;
Ok(resolve_hooks_dir(&cwd))
}
fn resolve_hooks_dir(cwd: &Path) -> PathBuf {
if let Some(dir) = git_path_hooks(cwd) {
return dir;
}
files::project_root(cwd).join(".git").join("hooks")
}
fn git_path_hooks(cwd: &Path) -> Option<PathBuf> {
let output = Command::new(crate::git_util::resolve_git_binary())
.arg("-C")
.arg(cwd)
.args(["rev-parse", "--git-path", "hooks"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8(output.stdout).ok()?;
let raw = raw.trim();
if raw.is_empty() {
return None;
}
let path = PathBuf::from(raw);
let absolute = if path.is_absolute() {
path
} else {
cwd.join(path)
};
Some(absolute.canonicalize().unwrap_or(absolute))
}
fn marker_start() -> String {
format!("# syntext-agent:{MARKER_ID}:start")
}
fn marker_end() -> String {
format!("# syntext-agent:{MARKER_ID}:end")
}
fn fire_and_forget_block(st_program: &str) -> String {
let st_quoted = shell::shell_quote(st_program);
format!(
"{start}\nif command -v {st_quoted} >/dev/null 2>&1; then\n {st_quoted} update --quiet >/dev/null 2>&1 &\nelif command -v st >/dev/null 2>&1; then\n st update --quiet >/dev/null 2>&1 &\nfi\n{end}\n",
start = marker_start(),
end = marker_end(),
)
}
fn ensure_shell_block(path: &Path, block: &str) -> Result<bool, String> {
let existing = read_optional(path)?;
if existing.contains(&marker_start()) {
return Ok(false);
}
let mut next = existing;
if next.is_empty() {
next.push_str("#!/bin/sh\n");
} else if !next.ends_with('\n') {
next.push('\n');
}
next.push_str(block);
let changed = files::write_text_if_changed(path, &next)?;
if changed {
files::set_executable(path)?;
}
Ok(changed)
}
fn remove_shell_block(path: &Path) -> Result<bool, String> {
if !path.exists() {
return Ok(false);
}
let existing = fs::read_to_string(path)
.map_err(|err| format!("st: failed to read {}: {err}", path.display()))?;
let start_marker = marker_start();
let end_marker = marker_end();
let Some(start) = existing.find(&start_marker) else {
return Ok(false);
};
let Some(end_rel) = existing[start..].find(&end_marker) else {
return Ok(false);
};
let end = start + end_rel + end_marker.len();
let mut next = String::new();
next.push_str(existing[..start].trim_end());
if !next.is_empty() {
next.push('\n');
}
next.push_str(existing[end..].trim_start_matches(['\r', '\n']));
files::write_text_if_changed(path, &next)
}
fn contains_marker(path: &Path) -> Result<bool, String> {
if !path.exists() {
return Ok(false);
}
let text = fs::read_to_string(path)
.map_err(|err| format!("st: failed to read {}: {err}", path.display()))?;
Ok(text.contains(&marker_start()))
}
fn read_optional(path: &Path) -> Result<String, String> {
if !path.exists() {
return Ok(String::new());
}
fs::read_to_string(path).map_err(|err| format!("st: failed to read {}: {err}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
fn init_git_repo() -> tempfile::TempDir {
let repo = tempfile::TempDir::new().unwrap();
std::process::Command::new(crate::git_util::resolve_git_binary())
.arg("-C")
.arg(repo.path())
.args(["init"])
.output()
.unwrap();
repo
}
#[test]
fn resolve_hooks_dir_matches_dot_git_hooks_in_a_normal_repo() {
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return; }
let repo = init_git_repo();
let expected = repo
.path()
.canonicalize()
.unwrap()
.join(".git")
.join("hooks");
let resolved = resolve_hooks_dir(repo.path());
assert_eq!(resolved, expected);
}
#[test]
fn resolve_hooks_dir_respects_core_hooks_path() {
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return; }
let repo = init_git_repo();
let custom_hooks = tempfile::TempDir::new().unwrap();
let status = std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["config", "core.hooksPath"])
.arg(custom_hooks.path())
.status()
.unwrap();
assert!(status.success());
let expected = custom_hooks.path().canonicalize().unwrap();
let resolved = resolve_hooks_dir(repo.path());
assert_eq!(resolved, expected);
}
#[test]
fn install_at_targets_custom_hooks_path_not_dot_git_hooks() {
let git = crate::git_util::resolve_git_binary();
if !git.is_file() {
return; }
let repo = init_git_repo();
let custom_hooks = tempfile::TempDir::new().unwrap();
let status = std::process::Command::new(&git)
.arg("-C")
.arg(repo.path())
.args(["config", "core.hooksPath"])
.arg(custom_hooks.path())
.status()
.unwrap();
assert!(status.success());
let resolved = resolve_hooks_dir(repo.path());
install_at(&resolved, "st").expect("install_at should succeed");
for name in HOOK_NAMES {
let installed_path = custom_hooks.path().join(name);
assert!(
installed_path.exists(),
"expected hook {name} to be installed under core.hooksPath, found nothing at {}",
installed_path.display()
);
let dot_git_hooks_path = repo.path().join(".git").join("hooks").join(name);
assert!(
!dot_git_hooks_path.exists()
|| !fs::read_to_string(&dot_git_hooks_path)
.unwrap_or_default()
.contains(&marker_start()),
"hook {name} must not be installed into the literal .git/hooks \
when core.hooksPath is set, found marker at {}",
dot_git_hooks_path.display()
);
}
}
#[test]
fn resolve_hooks_dir_falls_back_when_not_a_git_repo() {
let temp = tempfile::TempDir::new().unwrap();
let resolved = resolve_hooks_dir(temp.path());
assert!(resolved.ends_with(Path::new(".git").join("hooks")));
}
}