use anyhow::{Context, Result, anyhow, bail};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::{HooksArgs, HooksCommands, HooksRepoArgs};
use crate::path_utils::detect_repo_root;
use crate::sync::resolved_sync_config;
use crate::sync_schedule::{ScheduleBackend, current_backend, install_or_update_schedule};
const PACEFLOW_HOOK_MARKER: &str = "PACEFLOW_MANAGED_PRE_COMMIT_HOOK";
pub fn run(args: HooksArgs) -> Result<()> {
match args.command {
HooksCommands::Install(args) => install_hook(args.repo.as_deref()),
HooksCommands::Uninstall(args) => uninstall_hook(args.repo.as_deref()),
HooksCommands::Status(args) => status_hook(args.repo.as_deref()),
HooksCommands::PreCommit(args) => run_pre_commit(args),
}
}
fn run_pre_commit(args: HooksRepoArgs) -> Result<()> {
let mut backend = current_backend();
run_pre_commit_with_backend(args, backend.as_mut())
}
fn run_pre_commit_with_backend(
args: HooksRepoArgs,
backend: &mut dyn ScheduleBackend,
) -> Result<()> {
let _repo_root = resolve_repo_root(args.repo.as_deref())?;
check_sync_configured()?;
ensure_schedule_installed(backend)
}
fn install_hook(repo: Option<&str>) -> Result<()> {
let repo_root = resolve_repo_root(repo)?;
let hook_path = pre_commit_hook_path(&repo_root)?;
if let Some(existing) = read_existing_hook(&hook_path)?
&& !is_paceflow_managed(&existing)
{
bail!(
"refusing to overwrite existing non-Paceflow pre-commit hook at {}",
hook_path.display()
);
}
if let Some(parent) = hook_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create hook directory {}", parent.display()))?;
}
fs::write(&hook_path, hook_contents())
.with_context(|| format!("failed to write {}", hook_path.display()))?;
set_executable(&hook_path)?;
println!(
"Installed Paceflow pre-commit setup gate at {}",
hook_path.display()
);
Ok(())
}
fn uninstall_hook(repo: Option<&str>) -> Result<()> {
let repo_root = resolve_repo_root(repo)?;
let hook_path = pre_commit_hook_path(&repo_root)?;
let Some(existing) = read_existing_hook(&hook_path)? else {
println!("No pre-commit hook found at {}", hook_path.display());
return Ok(());
};
if !is_paceflow_managed(&existing) {
bail!(
"refusing to remove existing non-Paceflow pre-commit hook at {}",
hook_path.display()
);
}
fs::remove_file(&hook_path)
.with_context(|| format!("failed to remove {}", hook_path.display()))?;
println!(
"Removed Paceflow pre-commit setup gate from {}",
hook_path.display()
);
Ok(())
}
fn status_hook(repo: Option<&str>) -> Result<()> {
let repo_root = resolve_repo_root(repo)?;
let hook_path = pre_commit_hook_path(&repo_root)?;
match read_existing_hook(&hook_path)? {
Some(contents) if is_paceflow_managed(&contents) => {
println!(
"Paceflow pre-commit setup gate is installed at {}",
hook_path.display()
);
}
Some(_) => {
println!(
"A non-Paceflow pre-commit hook exists at {}",
hook_path.display()
);
}
None => {
println!(
"Paceflow pre-commit setup gate is not installed at {}",
hook_path.display()
);
}
}
Ok(())
}
fn check_sync_configured() -> Result<()> {
match resolved_sync_config() {
Ok(Some(_)) => Ok(()),
Ok(None) => {
bail!("paceflow sync is not configured for this machine.\nRun: paceflow sync config")
}
Err(err) => bail!("invalid paceflow sync configuration: {err}"),
}
}
fn ensure_schedule_installed(backend: &mut dyn ScheduleBackend) -> Result<()> {
install_or_update_schedule(backend).map(|_| ()).map_err(|err| {
anyhow!(
"paceflow periodic sync schedule is not installed and could not be configured: {err}\nRun: paceflow sync schedule install"
)
})
}
fn resolve_repo_root(repo: Option<&str>) -> Result<PathBuf> {
let candidate = match repo {
Some(repo) => PathBuf::from(repo),
None => std::env::current_dir()?,
};
let absolute = if candidate.is_absolute() {
candidate
} else {
std::env::current_dir()?.join(candidate)
};
detect_repo_root(&absolute).ok_or_else(|| {
anyhow!(
"could not determine a git repository root from {}",
absolute.display()
)
})
}
fn pre_commit_hook_path(repo_root: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.current_dir(repo_root)
.args(["rev-parse", "--git-path", "hooks/pre-commit"])
.output()
.context("failed to locate git hooks directory")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("failed to locate git hooks directory: {}", stderr.trim());
}
let raw = String::from_utf8(output.stdout)?.trim().to_string();
if raw.is_empty() {
bail!("git returned an empty pre-commit hook path");
}
let path = PathBuf::from(raw);
if path.is_absolute() {
Ok(path)
} else {
Ok(repo_root.join(path))
}
}
fn read_existing_hook(path: &Path) -> Result<Option<String>> {
match fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())),
}
}
fn is_paceflow_managed(contents: &str) -> bool {
contents.contains(PACEFLOW_HOOK_MARKER)
}
fn hook_contents() -> &'static str {
"#!/bin/sh\n\
# PACEFLOW_MANAGED_PRE_COMMIT_HOOK\n\
root=\"$(git rev-parse --show-toplevel 2>/dev/null)\" || exit 1\n\
\n\
if ! command -v paceflow >/dev/null 2>&1; then\n\
echo \"paceflow is required for this repo. Install it, then run: paceflow sync config\" >&2\n\
exit 1\n\
fi\n\
\n\
exec paceflow hooks pre-commit --repo \"$root\"\n"
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
.with_context(|| format!("failed to make {} executable", path.display()))
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sync::{
SYNC_BASE_URL_ENV_VAR, SYNC_ORGANIZATION_ID_ENV_VAR, SYNC_TOKEN_ENV_VAR, SavedSyncConfig,
save_sync_config,
};
use crate::sync_schedule::{
ScheduleBackend, ScheduleBackendKind, ScheduleDefinition, ScheduleState,
expected_definition_with_exe,
};
use crate::test_support::{ScopedEnvVar, lock_env};
use anyhow::Result;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
fn init_repo(path: &std::path::Path) -> Result<()> {
fs::create_dir_all(path)?;
let status = Command::new("git").current_dir(path).arg("init").status()?;
assert!(status.success());
Ok(())
}
fn unset_sync_env() -> [ScopedEnvVar; 3] {
[
ScopedEnvVar::unset(SYNC_BASE_URL_ENV_VAR),
ScopedEnvVar::unset(SYNC_ORGANIZATION_ID_ENV_VAR),
ScopedEnvVar::unset(SYNC_TOKEN_ENV_VAR),
]
}
#[derive(Debug)]
struct FakeScheduleBackend {
state: ScheduleState,
installs: usize,
}
impl FakeScheduleBackend {
fn new(state: ScheduleState) -> Self {
Self { state, installs: 0 }
}
}
impl ScheduleBackend for FakeScheduleBackend {
fn kind(&self) -> ScheduleBackendKind {
ScheduleBackendKind::LinuxSystemd
}
fn state(&self) -> Result<ScheduleState> {
Ok(self.state.clone())
}
fn install(&mut self, expected: &ScheduleDefinition) -> Result<()> {
self.state = ScheduleState::Installed(expected.clone());
self.installs += 1;
Ok(())
}
fn uninstall(&mut self) -> Result<()> {
self.state = ScheduleState::Missing;
Ok(())
}
}
fn save_valid_sync_config() -> Result<()> {
save_sync_config(&SavedSyncConfig {
base_url: "https://api.example.com".to_string(),
organization_id: "org-1".to_string(),
organization_name: Some("Example".to_string()),
member_email: Some("dev@example.com".to_string()),
token: "token-1".to_string(),
})?;
Ok(())
}
#[test]
fn install_creates_managed_pre_commit_hook() -> Result<()> {
let tempdir = tempdir()?;
let repo = tempdir.path().join("repo");
init_repo(&repo)?;
install_hook(Some(repo.to_string_lossy().as_ref()))?;
let hook = repo.join(".git/hooks/pre-commit");
let contents = fs::read_to_string(&hook)?;
assert!(contents.contains(PACEFLOW_HOOK_MARKER));
assert!(contents.contains("exec paceflow hooks pre-commit --repo \"$root\""));
assert!(!contents.contains("\r\n"));
Ok(())
}
#[test]
fn install_refuses_existing_non_paceflow_hook() -> Result<()> {
let tempdir = tempdir()?;
let repo = tempdir.path().join("repo");
init_repo(&repo)?;
let hook = repo.join(".git/hooks/pre-commit");
fs::write(&hook, "#!/bin/sh\necho existing\n")?;
let err = install_hook(Some(repo.to_string_lossy().as_ref())).expect_err("should refuse");
assert!(
err.to_string()
.contains("existing non-Paceflow pre-commit hook")
);
assert_eq!(fs::read_to_string(&hook)?, "#!/bin/sh\necho existing\n");
Ok(())
}
#[test]
fn install_updates_existing_paceflow_hook() -> Result<()> {
let tempdir = tempdir()?;
let repo = tempdir.path().join("repo");
init_repo(&repo)?;
let hook = repo.join(".git/hooks/pre-commit");
fs::write(&hook, format!("#!/bin/sh\n# {PACEFLOW_HOOK_MARKER}\nold\n"))?;
install_hook(Some(repo.to_string_lossy().as_ref()))?;
let contents = fs::read_to_string(&hook)?;
assert!(contents.contains("exec paceflow hooks pre-commit --repo \"$root\""));
assert!(!contents.contains("old"));
Ok(())
}
#[test]
fn pre_commit_check_fails_without_sync_config() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _sync_env = unset_sync_env();
let err = check_sync_configured().expect_err("should require sync config");
assert!(err.to_string().contains("paceflow sync is not configured"));
Ok(())
}
#[test]
fn pre_commit_check_accepts_saved_sync_config() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _sync_env = unset_sync_env();
save_sync_config(&SavedSyncConfig {
base_url: "https://api.example.com".to_string(),
organization_id: "org-1".to_string(),
organization_name: Some("Example".to_string()),
member_email: Some("dev@example.com".to_string()),
token: "token-1".to_string(),
})?;
check_sync_configured()?;
Ok(())
}
#[test]
fn pre_commit_check_accepts_env_only_sync_config() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _base = ScopedEnvVar::set(SYNC_BASE_URL_ENV_VAR, "https://api.example.com");
let _org = ScopedEnvVar::set(SYNC_ORGANIZATION_ID_ENV_VAR, "org-1");
let _token = ScopedEnvVar::set(SYNC_TOKEN_ENV_VAR, "token-1");
check_sync_configured()?;
Ok(())
}
#[test]
fn pre_commit_check_uses_saved_over_env_when_both_present() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _base = ScopedEnvVar::set(SYNC_BASE_URL_ENV_VAR, "https://env.example.com");
let _org = ScopedEnvVar::set(SYNC_ORGANIZATION_ID_ENV_VAR, "env-org");
let _token = ScopedEnvVar::set(SYNC_TOKEN_ENV_VAR, "env-token");
save_sync_config(&SavedSyncConfig {
base_url: "https://saved.example.com".to_string(),
organization_id: "saved-org".to_string(),
organization_name: Some("Saved".to_string()),
member_email: Some("saved@example.com".to_string()),
token: "saved-token".to_string(),
})?;
check_sync_configured()?;
Ok(())
}
#[test]
fn pre_commit_check_surfaces_malformed_saved_base_url_even_when_env_is_valid() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _base = ScopedEnvVar::set(SYNC_BASE_URL_ENV_VAR, "https://env.example.com");
let _org = ScopedEnvVar::set(SYNC_ORGANIZATION_ID_ENV_VAR, "env-org");
let _token = ScopedEnvVar::set(SYNC_TOKEN_ENV_VAR, "env-token");
save_sync_config(&SavedSyncConfig {
base_url: "not-a-url".to_string(),
organization_id: "saved-org".to_string(),
organization_name: Some("Saved".to_string()),
member_email: Some("saved@example.com".to_string()),
token: "saved-token".to_string(),
})?;
let err = check_sync_configured().expect_err("malformed saved base URL should win");
assert!(err.to_string().contains("sync base URL must start"));
Ok(())
}
#[test]
fn pre_commit_check_reports_malformed_base_url() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _base = ScopedEnvVar::set(SYNC_BASE_URL_ENV_VAR, "api.example.com");
let _org = ScopedEnvVar::set(SYNC_ORGANIZATION_ID_ENV_VAR, "org-1");
let _token = ScopedEnvVar::set(SYNC_TOKEN_ENV_VAR, "token-1");
let err = check_sync_configured().expect_err("should reject malformed base URL");
assert!(err.to_string().contains("sync base URL must start"));
Ok(())
}
#[test]
fn pre_commit_installs_missing_schedule_after_sync_config_check() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let repo = tempdir.path().join("repo");
init_repo(&repo)?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _sync_env = unset_sync_env();
save_valid_sync_config()?;
let mut backend = FakeScheduleBackend::new(ScheduleState::Missing);
run_pre_commit_with_backend(
HooksRepoArgs {
repo: Some(repo.to_string_lossy().to_string()),
},
&mut backend,
)?;
assert_eq!(backend.installs, 1);
assert_eq!(
backend.state,
ScheduleState::Installed(expected_definition_with_exe(
ScheduleBackendKind::LinuxSystemd,
std::env::current_exe()?
))
);
Ok(())
}
#[test]
fn pre_commit_passes_when_schedule_is_already_installed() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let repo = tempdir.path().join("repo");
init_repo(&repo)?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _sync_env = unset_sync_env();
save_valid_sync_config()?;
let mut backend =
FakeScheduleBackend::new(ScheduleState::Installed(expected_definition_with_exe(
ScheduleBackendKind::LinuxSystemd,
std::env::current_exe()?,
)));
run_pre_commit_with_backend(
HooksRepoArgs {
repo: Some(repo.to_string_lossy().to_string()),
},
&mut backend,
)?;
assert_eq!(backend.installs, 0);
Ok(())
}
#[test]
fn pre_commit_missing_sync_blocks_before_schedule_install() -> Result<()> {
let _guard = lock_env();
let tempdir = tempdir()?;
let repo = tempdir.path().join("repo");
init_repo(&repo)?;
let _home = ScopedEnvVar::set("PACEFLOW_HOME", tempdir.path());
let _sync_env = unset_sync_env();
let mut backend = FakeScheduleBackend::new(ScheduleState::Missing);
let err = run_pre_commit_with_backend(
HooksRepoArgs {
repo: Some(repo.to_string_lossy().to_string()),
},
&mut backend,
)
.expect_err("missing sync should block");
assert!(err.to_string().contains("paceflow sync is not configured"));
assert_eq!(backend.installs, 0);
Ok(())
}
}