use std::collections::BTreeSet;
use std::hash::{Hash, Hasher};
use std::path::Path;
use crate::config::{DEFAULT_CONFIG_FILE, HookEntry, HooksConfig, ReleaseConfig};
use crate::error::ReleaseError;
use crate::hook_cache;
const GENERATED_MARKER: &str = "# Generated by sr";
const HASH_FILE: &str = ".sr-hooks-hash";
pub fn sync_hooks(
repo_root: &Path,
config: &HooksConfig,
) -> Result<bool, crate::error::ReleaseError> {
let hooks_dir = repo_root.join(".githooks");
let hash_path = hooks_dir.join(HASH_FILE);
let current_hash = config_hash(config);
if let Ok(stored) = std::fs::read_to_string(&hash_path)
&& stored.trim() == current_hash
{
return Ok(false);
}
let configured: BTreeSet<&str> = config
.hooks
.iter()
.filter(|(_, entries)| !entries.is_empty())
.map(|(name, _)| name.as_str())
.collect();
if configured.is_empty() {
let removed = remove_stale_hooks(&hooks_dir, &configured)?;
let _ = std::fs::remove_file(&hash_path);
return Ok(removed);
}
std::fs::create_dir_all(&hooks_dir).map_err(|e| {
crate::error::ReleaseError::Config(format!("failed to create .githooks: {e}"))
})?;
let mut changed = false;
for &hook_name in &configured {
let hook_path = hooks_dir.join(hook_name);
let expected = shim_script(hook_name);
match std::fs::read_to_string(&hook_path) {
Ok(existing) if existing == expected => {
}
Ok(existing) if existing.contains(GENERATED_MARKER) => {
write_shim(&hook_path, &expected)?;
changed = true;
}
Ok(_) => {
let backup = hooks_dir.join(format!("{hook_name}.bak"));
std::fs::rename(&hook_path, &backup).map_err(|e| {
crate::error::ReleaseError::Config(format!(
"failed to backup .githooks/{hook_name}: {e}"
))
})?;
eprintln!("backed up .githooks/{hook_name} → .githooks/{hook_name}.bak");
write_shim(&hook_path, &expected)?;
changed = true;
}
Err(_) => {
write_shim(&hook_path, &expected)?;
changed = true;
}
}
}
if remove_stale_hooks(&hooks_dir, &configured)? {
changed = true;
}
std::fs::write(&hash_path, ¤t_hash).map_err(|e| {
crate::error::ReleaseError::Config(format!("failed to write hooks hash: {e}"))
})?;
if changed {
set_hooks_path(repo_root);
}
Ok(changed)
}
pub fn needs_sync(repo_root: &Path, config: &HooksConfig) -> bool {
let hash_path = repo_root.join(".githooks").join(HASH_FILE);
match std::fs::read_to_string(&hash_path) {
Ok(stored) => stored.trim() != config_hash(config),
Err(_) => {
!config.hooks.is_empty()
}
}
}
fn config_hash(config: &HooksConfig) -> String {
let json = serde_json::to_string(&config.hooks).unwrap_or_default();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
json.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn shim_script(hook_name: &str) -> String {
format!(
"#!/usr/bin/env sh\n\
{GENERATED_MARKER} — edit the hooks section in {config} to modify.\n\
exec sr hook run {hook_name} -- \"$@\"\n",
config = DEFAULT_CONFIG_FILE,
)
}
fn write_shim(path: &Path, content: &str) -> Result<(), crate::error::ReleaseError> {
std::fs::write(path, content)
.map_err(|e| crate::error::ReleaseError::Config(format!("failed to write hook: {e}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).map_err(|e| {
crate::error::ReleaseError::Config(format!("failed to chmod hook: {e}"))
})?;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
eprintln!("synced .githooks/{name}");
}
Ok(())
}
fn remove_stale_hooks(
hooks_dir: &Path,
configured: &BTreeSet<&str>,
) -> Result<bool, crate::error::ReleaseError> {
if !hooks_dir.is_dir() {
return Ok(false);
}
let mut removed = false;
let entries = std::fs::read_dir(hooks_dir).map_err(|e| {
crate::error::ReleaseError::Config(format!("failed to read .githooks: {e}"))
})?;
for entry in entries {
let entry = entry.map_err(|e| crate::error::ReleaseError::Config(e.to_string()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name == HASH_FILE || name.ends_with(".bak") {
continue;
}
if !is_sr_managed(&path) {
continue;
}
if !configured.contains(name.as_str()) {
std::fs::remove_file(&path).map_err(|e| {
crate::error::ReleaseError::Config(format!(
"failed to remove .githooks/{name}: {e}"
))
})?;
eprintln!("removed stale .githooks/{name}");
removed = true;
}
}
Ok(removed)
}
fn is_sr_managed(path: &Path) -> bool {
std::fs::read_to_string(path)
.map(|content| content.contains(GENERATED_MARKER))
.unwrap_or(false)
}
fn set_hooks_path(repo_root: &Path) {
let _ = std::process::Command::new("git")
.args(["config", "core.hooksPath", ".githooks/"])
.current_dir(repo_root)
.status();
}
pub fn run_shell(
cmd: &str,
stdin_data: Option<&str>,
env: &[(&str, &str)],
) -> Result<(), ReleaseError> {
let mut child = {
let mut builder = std::process::Command::new("sh");
builder.args(["-c", cmd]);
for &(k, v) in env {
builder.env(k, v);
}
if stdin_data.is_some() {
builder.stdin(std::process::Stdio::piped());
} else {
builder.stdin(std::process::Stdio::inherit());
}
builder
.spawn()
.map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?
};
if let Some(data) = stdin_data
&& let Some(ref mut stdin) = child.stdin
{
use std::io::Write;
let _ = stdin.write_all(data.as_bytes());
}
let status = child
.wait()
.map_err(|e| ReleaseError::Hook(format!("{cmd}: {e}")))?;
if !status.success() {
let code = status.code().unwrap_or(1);
return Err(ReleaseError::Hook(format!("{cmd} exited with code {code}")));
}
Ok(())
}
pub fn build_hook_json(hook_name: &str, args: &[String]) -> serde_json::Value {
let mut obj = serde_json::Map::new();
obj.insert("hook".into(), serde_json::Value::String(hook_name.into()));
obj.insert(
"args".into(),
serde_json::Value::Array(
args.iter()
.map(|a| serde_json::Value::String(a.clone()))
.collect(),
),
);
match hook_name {
"commit-msg" => {
if let Some(f) = args.first() {
obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
}
}
"prepare-commit-msg" => {
if let Some(f) = args.first() {
obj.insert("message_file".into(), serde_json::Value::String(f.clone()));
}
if let Some(s) = args.get(1) {
obj.insert("source".into(), serde_json::Value::String(s.clone()));
}
if let Some(s) = args.get(2) {
obj.insert("sha".into(), serde_json::Value::String(s.clone()));
}
}
"pre-push" => {
if let Some(r) = args.first() {
obj.insert("remote_name".into(), serde_json::Value::String(r.clone()));
}
if let Some(u) = args.get(1) {
obj.insert("remote_url".into(), serde_json::Value::String(u.clone()));
}
}
"pre-rebase" => {
if let Some(u) = args.first() {
obj.insert("upstream".into(), serde_json::Value::String(u.clone()));
}
if let Some(b) = args.get(1) {
obj.insert("branch".into(), serde_json::Value::String(b.clone()));
}
}
"post-checkout" => {
if let Some(r) = args.first() {
obj.insert("prev_ref".into(), serde_json::Value::String(r.clone()));
}
if let Some(r) = args.get(1) {
obj.insert("new_ref".into(), serde_json::Value::String(r.clone()));
}
if let Some(f) = args.get(2) {
obj.insert(
"branch_checkout".into(),
serde_json::Value::String(f.clone()),
);
}
}
"post-merge" => {
if let Some(s) = args.first() {
obj.insert("squash".into(), serde_json::Value::String(s.clone()));
}
}
_ => {}
}
serde_json::Value::Object(obj)
}
fn staged_files() -> Result<Vec<String>, ReleaseError> {
let output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
.output()
.map_err(|e| ReleaseError::Hook(format!("git diff --cached: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(stdout
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect())
}
fn match_files(files: &[String], patterns: &[String]) -> Vec<String> {
let compiled: Vec<glob::Pattern> = patterns
.iter()
.filter_map(|p| glob::Pattern::new(p).ok())
.collect();
files
.iter()
.filter(|f| {
let basename = Path::new(f)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(f);
compiled
.iter()
.any(|pat| pat.matches(f) || pat.matches(basename))
})
.cloned()
.collect()
}
fn repo_root() -> Option<std::path::PathBuf> {
let output = std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().into())
}
pub fn run_hook(
config: &ReleaseConfig,
hook_name: &str,
args: &[String],
) -> Result<(), ReleaseError> {
let entries = config
.hooks
.hooks
.get(hook_name)
.ok_or_else(|| ReleaseError::Hook(format!("no hook configured for '{hook_name}'")))?;
if entries.is_empty() {
return Ok(());
}
let json = build_hook_json(hook_name, args);
let json_str = serde_json::to_string(&json)
.map_err(|e| ReleaseError::Hook(format!("failed to serialize hook context: {e}")))?;
let no_cache = std::env::var("SR_HOOK_NO_CACHE").is_ok_and(|v| v == "1");
let root = repo_root();
let mut step_cache = match (&root, no_cache) {
(Some(r), false) => Some(hook_cache::load_step_cache(r)),
_ => None,
};
let mut cache_dirty = false;
let mut cached_staged: Option<Vec<String>> = None;
let result = run_hook_inner(
entries,
hook_name,
&json_str,
&mut cached_staged,
root.as_deref(),
&mut step_cache,
&mut cache_dirty,
);
if cache_dirty
&& let (Some(r), Some(cache)) = (&root, &step_cache)
&& let Err(e) = hook_cache::save_step_cache(r, cache)
{
eprintln!("warning: failed to save hook cache: {e}");
}
result
}
fn run_hook_inner(
entries: &[HookEntry],
hook_name: &str,
json_str: &str,
cached_staged: &mut Option<Vec<String>>,
root: Option<&Path>,
step_cache: &mut Option<hook_cache::StepCache>,
cache_dirty: &mut bool,
) -> Result<(), ReleaseError> {
for entry in entries {
match entry {
HookEntry::Simple(cmd) => {
run_shell(cmd, Some(json_str), &[])?;
}
HookEntry::Step {
step,
patterns,
rules,
} => {
let all_staged = match cached_staged {
Some(files) => files,
None => {
*cached_staged = Some(staged_files().unwrap_or_default());
cached_staged.as_mut().unwrap()
}
};
if all_staged.is_empty() {
eprintln!("{hook_name}: no staged files, skipping steps.");
break;
}
let matched = match_files(all_staged, patterns);
if matched.is_empty() {
eprintln!("{hook_name} [{step}]: no files match {patterns:?}, skipping.");
continue;
}
let (changed_files, all_hashes) = match (root, step_cache.as_ref()) {
(Some(r), Some(cache)) => {
let hashes = hook_cache::hash_staged_files(r, &matched);
let diff = hook_cache::changed_files_for_step(cache, step, &hashes);
(Some(diff), Some(hashes))
}
_ => (None, None),
};
if let Some(ref diff) = changed_files {
if diff.changed.is_empty() {
eprintln!(
"{hook_name} [{step}]: all {} files cached, skipping.",
diff.cached.len()
);
continue;
}
if !diff.cached.is_empty() {
eprintln!(
"{hook_name} [{step}]: {} of {} files changed, re-checking.",
diff.changed.len(),
diff.changed.len() + diff.cached.len()
);
}
}
let effective_files = match &changed_files {
Some(diff) => &diff.changed,
None => &matched,
};
for rule in rules {
let cmd = if rule.contains("{files}") {
let files_str = effective_files.join(" ");
rule.replace("{files}", &files_str)
} else {
rule.clone()
};
eprintln!("{hook_name} [{step}]: {cmd}");
run_shell(&cmd, None, &[])?;
}
if let (Some(cache), Some(hashes)) = (step_cache.as_mut(), all_hashes) {
hook_cache::record_step_pass(cache, step, &hashes);
*cache_dirty = true;
}
}
}
}
Ok(())
}
pub fn validate_commit_msg(config: &ReleaseConfig) -> Result<(), ReleaseError> {
use std::io::Read;
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.map_err(|e| ReleaseError::Hook(format!("failed to read stdin: {e}")))?;
let json: serde_json::Value = serde_json::from_str(&input)
.map_err(|e| ReleaseError::Hook(format!("invalid JSON on stdin: {e}")))?;
let file = json["message_file"]
.as_str()
.ok_or_else(|| ReleaseError::Hook("missing 'message_file' in hook JSON".into()))?;
let content = std::fs::read_to_string(file)
.map_err(|e| ReleaseError::Hook(format!("cannot read commit message file: {e}")))?;
let first_line = content.lines().next().unwrap_or("").trim();
if first_line.starts_with("Merge ") {
return Ok(());
}
if first_line.starts_with("fixup! ")
|| first_line.starts_with("squash! ")
|| first_line.starts_with("amend! ")
{
return Ok(());
}
let re = regex::Regex::new(&config.commit_pattern)
.map_err(|e| ReleaseError::Hook(format!("invalid commit_pattern: {e}")))?;
if !re.is_match(first_line) {
let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
return Err(ReleaseError::Hook(format!(
"commit message does not follow Conventional Commits.\n\n\
\x20 Expected: <type>(<scope>): <description>\n\
\x20 Got: {first_line}\n\n\
\x20 Valid types: {}\n\
\x20 Breaking: append '!' before the colon, e.g. feat!: ...\n\n\
\x20 Examples:\n\
\x20 feat: add release dry-run flag\n\
\x20 fix(core): handle empty tag list\n\
\x20 feat!: redesign config format",
type_names.join(", "),
)));
}
if let Some(caps) = re.captures(first_line) {
let msg_type = caps.name("type").map(|m| m.as_str()).unwrap_or_default();
if !config.types.iter().any(|t| t.name == msg_type) {
let type_names: Vec<&str> = config.types.iter().map(|t| t.name.as_str()).collect();
return Err(ReleaseError::Hook(format!(
"commit type '{msg_type}' is not allowed.\n\n\
\x20 Valid types: {}",
type_names.join(", "),
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::HookEntry;
use std::collections::BTreeMap;
fn make_config(hooks: &[(&str, Vec<HookEntry>)]) -> HooksConfig {
let mut map = BTreeMap::new();
for (name, entries) in hooks {
map.insert(name.to_string(), entries.clone());
}
HooksConfig { hooks: map }
}
#[test]
fn creates_hook_scripts() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
let changed = sync_hooks(dir.path(), &config).unwrap();
assert!(changed);
let hook = dir.path().join(".githooks/pre-commit");
assert!(hook.exists());
let content = std::fs::read_to_string(&hook).unwrap();
assert!(content.contains("sr hook run pre-commit"));
assert!(content.contains(GENERATED_MARKER));
}
#[test]
fn idempotent_returns_false() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(&[(
"commit-msg",
vec![HookEntry::Simple("sr hook commit-msg".into())],
)]);
assert!(sync_hooks(dir.path(), &config).unwrap());
assert!(!sync_hooks(dir.path(), &config).unwrap());
}
#[test]
fn removes_stale_hooks() {
let dir = tempfile::tempdir().unwrap();
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-push"),
format!("{GENERATED_MARKER}\nold script"),
)
.unwrap();
std::fs::write(hooks_dir.join("post-checkout"), "#!/bin/sh\necho custom").unwrap();
let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
sync_hooks(dir.path(), &config).unwrap();
assert!(
!hooks_dir.join("pre-push").exists(),
"stale sr-managed hook should be removed"
);
assert!(
hooks_dir.join("post-checkout").exists(),
"non-sr-managed hook should be preserved"
);
assert!(hooks_dir.join("pre-commit").exists());
}
#[test]
fn backs_up_conflicting_hooks() {
let dir = tempfile::tempdir().unwrap();
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
let custom_content = "#!/bin/sh\necho custom commit-msg hook";
std::fs::write(hooks_dir.join("commit-msg"), custom_content).unwrap();
let config = make_config(&[(
"commit-msg",
vec![HookEntry::Simple("sr hook commit-msg".into())],
)]);
sync_hooks(dir.path(), &config).unwrap();
let backup = hooks_dir.join("commit-msg.bak");
assert!(backup.exists());
assert_eq!(std::fs::read_to_string(&backup).unwrap(), custom_content);
let content = std::fs::read_to_string(hooks_dir.join("commit-msg")).unwrap();
assert!(content.contains("sr hook run commit-msg"));
}
#[test]
fn empty_config_cleans_up() {
let dir = tempfile::tempdir().unwrap();
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit"),
format!("{GENERATED_MARKER}\nscript"),
)
.unwrap();
std::fs::write(hooks_dir.join(".sr-hooks-hash"), "oldhash").unwrap();
let config = make_config(&[]);
sync_hooks(dir.path(), &config).unwrap();
assert!(!hooks_dir.join("pre-commit").exists());
assert!(!hooks_dir.join(".sr-hooks-hash").exists());
}
#[test]
fn needs_sync_detects_changes() {
let dir = tempfile::tempdir().unwrap();
let config = make_config(&[("pre-commit", vec![HookEntry::Simple("echo hi".into())])]);
assert!(needs_sync(dir.path(), &config));
sync_hooks(dir.path(), &config).unwrap();
assert!(!needs_sync(dir.path(), &config));
let config2 =
make_config(&[("pre-commit", vec![HookEntry::Simple("echo changed".into())])]);
assert!(needs_sync(dir.path(), &config2));
}
}