use crate::config::{self, PlzConfig};
use crate::settings;
use anyhow::{Result, bail};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use toml_edit::DocumentMut;
const MANAGED_MARKER: &str = "# plz:managed - do not edit";
const HOOKS_VERSION: u32 = 2;
pub fn find_git_hooks_dir(base_dir: &Path) -> Result<PathBuf> {
let mut dir = base_dir;
loop {
let git_dir = dir.join(".git");
if git_dir.is_dir() {
return Ok(git_dir.join("hooks"));
}
match dir.parent() {
Some(parent) => dir = parent,
None => bail!("Not a git repository (no .git directory found)"),
}
}
}
pub fn tasks_by_stage(config: &PlzConfig) -> BTreeMap<String, Vec<String>> {
let mut stages: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut task_names: Vec<&String> = config.tasks.keys().collect();
task_names.sort();
for name in task_names {
let task = &config.tasks[name];
if let Some(ref hook) = task.git_hook {
stages.entry(hook.clone()).or_default().push(name.clone());
}
}
if let Some(ref groups) = config.taskgroup {
let mut group_names: Vec<&String> = groups.keys().collect();
group_names.sort();
for gname in group_names {
let group = &groups[gname];
let mut gtask_names: Vec<&String> = group.tasks.keys().collect();
gtask_names.sort();
for tname in gtask_names {
if let Some(ref hook) = group.tasks[tname].git_hook {
stages
.entry(hook.clone())
.or_default()
.push(format!("{gname}:{tname}"));
}
}
}
}
stages
}
fn generate_hook_script(stage: &str) -> String {
format!(
"#!/bin/sh\n\
{MANAGED_MARKER}\n\
# plz:hooks_version={HOOKS_VERSION}\n\
[ \"${{PLZ_SKIP_HOOKS}}\" = \"1\" ] && exit 0\n\
command -v plz >/dev/null 2>&1 || {{ echo \"plz not found in PATH, skipping {stage} hook\" >&2; exit 0; }}\n\
plz --no-interactive hooks run {stage}\n"
)
}
fn installed_hook_version(path: &Path) -> Option<u32> {
let content = fs::read_to_string(path).ok()?;
for line in content.lines() {
if let Some(v) = line.strip_prefix("# plz:hooks_version=") {
return v.trim().parse().ok();
}
}
if content.contains(MANAGED_MARKER) {
return Some(1);
}
None
}
fn is_plz_managed(path: &Path) -> bool {
fs::read_to_string(path)
.map(|content| content.contains(MANAGED_MARKER))
.unwrap_or(false)
}
pub fn install(config: &PlzConfig, base_dir: &Path) -> Result<()> {
let stages = tasks_by_stage(config);
if stages.is_empty() {
eprintln!("No tasks have git_hook configured in plz.toml");
return Ok(());
}
let hooks_dir = find_git_hooks_dir(base_dir)?;
fs::create_dir_all(&hooks_dir)?;
for (stage, task_names) in &stages {
let hook_path = hooks_dir.join(stage);
if hook_path.exists() && !is_plz_managed(&hook_path) {
eprintln!(
"\x1b[33mWarning:\x1b[0m Skipping {stage} — existing hook is not plz-managed"
);
continue;
}
let script = generate_hook_script(stage);
fs::write(&hook_path, &script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))?;
}
let names = task_names.join(", ");
eprintln!("\x1b[32m✓\x1b[0m Installed {stage} hook (tasks: {names})");
}
Ok(())
}
pub fn uninstall(config: &PlzConfig, base_dir: &Path) -> Result<()> {
let stages = tasks_by_stage(config);
if stages.is_empty() {
eprintln!("No tasks have git_hook configured in plz.toml");
return Ok(());
}
let hooks_dir = find_git_hooks_dir(base_dir)?;
for stage in stages.keys() {
let hook_path = hooks_dir.join(stage);
if !hook_path.exists() {
continue;
}
if !is_plz_managed(&hook_path) {
eprintln!("\x1b[33mWarning:\x1b[0m Skipping {stage} — not plz-managed");
continue;
}
fs::remove_file(&hook_path)?;
eprintln!("\x1b[32m✓\x1b[0m Removed {stage} hook");
}
Ok(())
}
pub fn run_stage(
config: &PlzConfig,
stage: &str,
base_dir: &Path,
interactive: bool,
) -> Result<()> {
let stages = tasks_by_stage(config);
let task_names = match stages.get(stage) {
Some(names) => names,
None => return Ok(()),
};
let names = task_names.join(", ");
eprintln!("\x1b[36m🙏 Running {stage} hook ({names})\x1b[0m");
for name in task_names {
if let Some((group, task)) = name.split_once(':') {
crate::runner::run_group_task(config, group, task, base_dir, interactive)?;
} else {
crate::runner::run_task(config, name, base_dir, interactive)?;
}
}
eprintln!("\x1b[32m✓ {stage} hook passed\x1b[0m");
Ok(())
}
pub fn status(config: &PlzConfig, base_dir: &Path) -> Result<()> {
let stages = tasks_by_stage(config);
if stages.is_empty() {
eprintln!("No tasks have git_hook configured in plz.toml");
return Ok(());
}
let hooks_dir = find_git_hooks_dir(base_dir).ok();
for (stage, task_names) in &stages {
let names = task_names.join(", ");
let (status_icon, suffix) = match hooks_dir.as_ref() {
Some(d) => {
let p = d.join(stage);
if !p.exists() || !is_plz_managed(&p) {
("\x1b[2m·\x1b[0m", "")
} else if installed_hook_version(&p).unwrap_or(0) < HOOKS_VERSION {
("\x1b[33m↑\x1b[0m", " \x1b[33m(outdated)\x1b[0m")
} else {
("\x1b[32m✓\x1b[0m", "")
}
}
None => ("\x1b[2m·\x1b[0m", ""),
};
eprintln!("{status_icon} {stage}: {names}{suffix}");
}
Ok(())
}
fn hook_needs_install(path: &Path) -> bool {
if !path.exists() || !is_plz_managed(path) {
return true;
}
installed_hook_version(path).unwrap_or(0) < HOOKS_VERSION
}
fn has_uninstalled_hooks(config: &PlzConfig, base_dir: &Path) -> bool {
let stages = tasks_by_stage(config);
if stages.is_empty() {
return false;
}
let Ok(hooks_dir) = find_git_hooks_dir(base_dir) else {
return false;
};
stages
.keys()
.any(|stage| hook_needs_install(&hooks_dir.join(stage)))
}
pub fn hint_uninstalled_hooks(config: &PlzConfig, base_dir: &Path) {
if std::env::var_os("PLZ_COMMAND").is_some() {
return;
}
if !settings::config_dir_exists() {
eprintln!("\x1b[2mRun `plz plz` to set up custom settings and templates.\x1b[0m");
return;
}
if !settings::load().show_hints {
return;
}
if has_uninstalled_hooks(config, base_dir) {
eprintln!(
"\x1b[2mYour plz.toml has git hooks that need to be installed or updated. Run `plz hooks` to install them.\x1b[0m"
);
}
}
pub fn interactive_install(config: &PlzConfig, base_dir: &Path, interactive: bool) -> Result<()> {
status(config, base_dir)?;
if !has_uninstalled_hooks(config, base_dir) {
return Ok(());
}
if !interactive {
return Ok(());
}
let should_install: bool = cliclack::confirm("Install hooks?")
.initial_value(true)
.interact()?;
if should_install {
install(config, base_dir)?;
}
Ok(())
}
pub fn add_hook(config: &PlzConfig, config_path: &Path) -> Result<()> {
let mut candidates: Vec<String> = Vec::new();
let mut task_names: Vec<&String> = config.tasks.keys().collect();
task_names.sort();
for name in task_names {
if config.tasks[name].git_hook.is_none() {
candidates.push(name.clone());
}
}
if let Some(ref groups) = config.taskgroup {
let mut group_names: Vec<&String> = groups.keys().collect();
group_names.sort();
for gname in group_names {
let group = &groups[gname];
let mut gtask_names: Vec<&String> = group.tasks.keys().collect();
gtask_names.sort();
for tname in gtask_names {
if group.tasks[tname].git_hook.is_none() {
candidates.push(format!("{gname}:{tname}"));
}
}
}
}
if candidates.is_empty() {
eprintln!("All tasks already have a git_hook configured.");
return Ok(());
}
let mut ms_items: Vec<crate::utils::MultiSelectItem> = candidates
.iter()
.map(|name| crate::utils::MultiSelectItem {
label: name.clone(),
hint: String::new(),
selected: false,
})
.collect();
let selected: Vec<&str> = match crate::utils::multiselect(
"Which tasks should run as a git hook?",
&mut ms_items,
true,
)? {
Some(indices) => indices.iter().map(|&i| candidates[i].as_str()).collect(),
None => {
eprintln!("\x1b[2m✕ Cancelled\x1b[0m");
return Ok(());
}
};
if selected.is_empty() {
eprintln!("\x1b[2m✕ Cancelled\x1b[0m");
return Ok(());
}
let common_stages = &[
"pre-commit",
"commit-msg",
"pre-push",
"prepare-commit-msg",
"post-commit",
"post-merge",
"post-checkout",
"pre-rebase",
];
let stage_items: Vec<(&str, &str, &str)> = common_stages.iter().map(|s| (*s, *s, "")).collect();
let stage: &str = cliclack::select("Which git hook stage?")
.items(&stage_items)
.initial_value("pre-commit")
.interact()?;
let content = fs::read_to_string(config_path)?;
let mut doc: DocumentMut = content.parse()?;
for name in &selected {
if let Some((group, task)) = name.split_once(':') {
if let Some(taskgroup) = doc.get_mut("taskgroup")
&& let Some(group_table) = taskgroup.get_mut(group)
&& let Some(task_table) = group_table.get_mut(task)
&& let Some(t) = task_table.as_table_like_mut()
{
t.insert("git_hook", toml_edit::value(stage));
}
} else {
if let Some(tasks) = doc.get_mut("tasks")
&& let Some(task_table) = tasks.get_mut(*name)
&& let Some(t) = task_table.as_table_like_mut()
{
t.insert("git_hook", toml_edit::value(stage));
}
}
}
fs::write(config_path, doc.to_string())?;
for name in &selected {
eprintln!("\x1b[32m✓\x1b[0m Added {stage} hook to \x1b[1m{name}\x1b[0m");
}
let base_dir = config_path.parent().unwrap().to_path_buf();
let updated_config = config::load(config_path)?;
if has_uninstalled_hooks(&updated_config, &base_dir) {
let should_install: bool = cliclack::confirm("Install hooks now?")
.initial_value(true)
.interact()?;
if should_install {
install(&updated_config, &base_dir)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_hook_script() {
let script = generate_hook_script("pre-commit");
assert!(script.starts_with("#!/bin/sh\n"));
assert!(script.contains(MANAGED_MARKER));
assert!(script.contains(&format!("# plz:hooks_version={HOOKS_VERSION}")));
assert!(script.contains("plz --no-interactive hooks run pre-commit"));
assert!(script.contains("PLZ_SKIP_HOOKS"));
assert!(script.contains("command -v plz"));
}
#[test]
fn test_generate_hook_script_commit_msg() {
let script = generate_hook_script("commit-msg");
assert!(script.contains("plz --no-interactive hooks run commit-msg"));
}
#[test]
fn test_installed_hook_version_current() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("pre-commit");
fs::write(&path, generate_hook_script("pre-commit")).unwrap();
assert_eq!(installed_hook_version(&path), Some(HOOKS_VERSION));
}
#[test]
fn test_installed_hook_version_v1() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("pre-commit");
fs::write(
&path,
format!("#!/bin/sh\n{MANAGED_MARKER}\nplz hooks run pre-commit \"$@\"\n"),
)
.unwrap();
assert_eq!(installed_hook_version(&path), Some(1));
}
#[test]
fn test_installed_hook_version_not_managed() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("pre-commit");
fs::write(&path, "#!/bin/sh\necho custom\n").unwrap();
assert_eq!(installed_hook_version(&path), None);
}
#[test]
fn test_installed_hook_version_missing() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nonexistent");
assert_eq!(installed_hook_version(&path), None);
}
#[test]
fn test_is_plz_managed_true() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("pre-commit");
fs::write(
&path,
format!("#!/bin/sh\n{MANAGED_MARKER}\nplz hooks run pre-commit\n"),
)
.unwrap();
assert!(is_plz_managed(&path));
}
#[test]
fn test_is_plz_managed_false() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("pre-commit");
fs::write(&path, "#!/bin/sh\necho custom hook\n").unwrap();
assert!(!is_plz_managed(&path));
}
#[test]
fn test_is_plz_managed_missing() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("nonexistent");
assert!(!is_plz_managed(&path));
}
}