use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use super::location::Repo;
use super::refs::repo_config_path;
pub const HOOK_FILE: &str = ".tsk/hooks/pre-docs-commit";
pub const SKIP_VAR: &str = "TSK_SKIP_DOC_CHECK";
pub const WORKTREE_VAR: &str = "TSK_DOCS_WORKTREE";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookRun {
NotRun,
Skipped,
Passed,
}
pub const HOOK_VAR: &str = "TSK_DOCS_HOOK";
pub const HOOK_NAME: &str = "pre-docs-commit";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookSource {
Env,
Clone,
User,
Repo,
}
impl HookSource {
pub fn label(self) -> &'static str {
match self {
HookSource::Env => "environment (TSK_DOCS_HOOK)",
HookSource::Clone => "clone (<state>/repos/<clone-id>/hooks/pre-docs-commit)",
HookSource::User => "user (<config>/tsk/hooks/pre-docs-commit)",
HookSource::Repo => "repo (.tsk/hooks/pre-docs-commit)",
}
}
}
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path).is_ok_and(|m| m.permissions().mode() & 0o111 != 0)
}
fn check_executable(path: &Path) -> Result<(), String> {
if is_executable(path) {
Ok(())
} else {
Err(format!(
"error: the documents hook {} is not executable. Run chmod +x on it",
path.display()
))
}
}
pub fn resolve(
repo: &Repo,
clone_dir: Option<&Path>,
) -> Result<Option<(PathBuf, HookSource)>, String> {
if let Some(value) = std::env::var_os(HOOK_VAR).filter(|v| !v.is_empty()) {
let path = PathBuf::from(value);
if !path.is_file() {
return Err(format!(
"error: {} names {}, which is not a file",
HOOK_VAR,
path.display()
));
}
check_executable(&path)?;
return Ok(Some((path, HookSource::Env)));
}
let mut candidates: Vec<(PathBuf, HookSource)> = Vec::new();
if let Some(dir) = clone_dir {
candidates.push((dir.join("hooks").join(HOOK_NAME), HookSource::Clone));
}
if let Some(user) = crate::config::config_file_from_env()
.and_then(|f| f.parent().map(|dir| dir.join("hooks").join(HOOK_NAME)))
{
candidates.push((user, HookSource::User));
}
if let Some(path) = repo_hook_path(repo) {
candidates.push((path, HookSource::Repo));
}
for (path, source) in candidates {
if path.is_file() {
check_executable(&path)?;
return Ok(Some((path, source)));
}
}
Ok(None)
}
pub fn repo_hook_path(repo: &Repo) -> Option<PathBuf> {
let config = repo_config_path(repo)?;
let top = config.parent()?.parent()?;
Some(top.join(HOOK_FILE))
}
pub fn show() -> Result<(), String> {
let cwd = std::env::current_dir()
.map_err(|e| format!("error: could not read the current directory: {}", e))?;
let repo = Repo::discover(&cwd)?;
let clone_dir = match (super::location::state_root_from_env(), repo.read_clone_id()) {
(Ok(root), Ok(Some(id))) => super::location::dolt_db_path(&root, &id)
.parent()
.map(Path::to_path_buf),
_ => None,
};
match resolve(&repo, clone_dir.as_deref())? {
Some((path, source)) => {
println!("docs hook: {}", path.display());
println!("source: {}", source.label());
}
None => println!("docs hook: none"),
}
Ok(())
}
fn skip_requested() -> bool {
std::env::var(SKIP_VAR).is_ok_and(|v| {
let v = v.trim();
!v.is_empty() && v != "0"
})
}
pub fn run(repo: &Repo, docs_root: &Path, paths: &[String]) -> Result<HookRun, String> {
let Some((hook, _)) = resolve(repo, docs_root.parent())? else {
return Ok(HookRun::NotRun);
};
run_hook(&hook, docs_root, paths)
}
pub fn run_hook(hook: &Path, docs_root: &Path, paths: &[String]) -> Result<HookRun, String> {
if !hook.exists() || paths.is_empty() {
return Ok(HookRun::NotRun);
}
if skip_requested() {
eprintln!(
"note: {} is set; skipped the documents hook {}",
SKIP_VAR,
hook.display()
);
return Ok(HookRun::Skipped);
}
let output = Command::new(hook)
.args(paths)
.current_dir(docs_root)
.env(WORKTREE_VAR, docs_root)
.stdin(Stdio::null())
.output()
.map_err(|e| {
format!(
"error: could not run the documents hook {}: {}.\n \
It must be an executable file with a working interpreter line",
hook.display(),
e
)
})?;
if output.status.success() {
let _ = std::io::stdout().write_all(&output.stdout);
let _ = std::io::stderr().write_all(&output.stderr);
return Ok(HookRun::Passed);
}
let status = match output.status.code() {
Some(code) => format!("exit status {}", code),
None => "terminated by a signal".to_string(),
};
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(&output.stdout));
text.push_str(&String::from_utf8_lossy(&output.stderr));
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&format!(
"error: the documents hook {} failed ({}). Fix the documents it names, or set {}=1 to skip it",
hook.display(),
status,
SKIP_VAR
));
Err(text)
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
fn script(dir: &Path, body: &str, mode: u32) -> PathBuf {
let file = dir.join("hook");
std::fs::write(&file, body).unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(mode)).unwrap();
file
}
#[test]
fn a_missing_hook_runs_nothing() {
let dir = tempfile::tempdir().unwrap();
let run = run_hook(&dir.path().join("none"), dir.path(), &["a.md".into()]).unwrap();
assert_eq!(run, HookRun::NotRun);
}
#[test]
fn a_failing_hook_returns_its_output_and_a_non_executable_one_names_its_path() {
let dir = tempfile::tempdir().unwrap();
let hook = script(
dir.path(),
"#!/bin/sh\necho found a problem\nexit 3\n",
0o755,
);
let err = run_hook(&hook, dir.path(), &["a.md".into()]).unwrap_err();
assert!(err.starts_with("found a problem\n"), "{}", err);
assert!(err.contains("exit status 3"), "{}", err);
let hook = script(dir.path(), "#!/bin/sh\nexit 0\n", 0o644);
let err = run_hook(&hook, dir.path(), &["a.md".into()]).unwrap_err();
assert!(err.contains(&hook.display().to_string()), "{}", err);
}
}