use std::fs;
use std::io;
use std::os::unix::fs::{PermissionsExt, symlink};
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::cli::HooksCommand;
const CANONICAL_REL: &str = ".githooks/pre-push";
const HOOK_REL: &str = ".git/hooks/pre-push";
const SYMLINK_TARGET: &str = "../../.githooks/pre-push";
pub fn run(cmd: HooksCommand) -> netsky_core::Result<()> {
let root = repo_root()?;
match cmd {
HooksCommand::Install { force } => install(&root, force),
HooksCommand::Uninstall => uninstall(&root),
HooksCommand::Status { json } => status(&root, json),
}
}
pub(crate) fn install(repo_root: &Path, force: bool) -> netsky_core::Result<()> {
let canonical = repo_root.join(CANONICAL_REL);
if !canonical.exists() {
return Err(netsky_core::Error::Invalid(format!(
"canonical hook missing: {} — run from a netsky checkout",
canonical.display()
)));
}
ensure_exec(&canonical)?;
let target = repo_root.join(HOOK_REL);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
match classify(&target) {
HookState::Absent => {
symlink(SYMLINK_TARGET, &target)?;
println!(
"[hooks install] linked {} -> {SYMLINK_TARGET}",
target.display()
);
Ok(())
}
HookState::CanonicalLink => {
println!(
"[hooks install] {} already canonical; no-op",
target.display()
);
Ok(())
}
HookState::Drift => {
if !force {
print_drift(&target, &canonical)?;
println!("[hooks install] re-run with --force to overwrite.");
return Ok(());
}
fs::remove_file(&target)?;
symlink(SYMLINK_TARGET, &target)?;
println!(
"[hooks install] replaced drifted hook at {}",
target.display()
);
Ok(())
}
}
}
pub(crate) fn uninstall(repo_root: &Path) -> netsky_core::Result<()> {
let target = repo_root.join(HOOK_REL);
match target.symlink_metadata() {
Ok(_) => {
fs::remove_file(&target)?;
println!("[hooks uninstall] removed {}", target.display());
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
println!("[hooks uninstall] {} already absent", target.display());
}
Err(e) => return Err(netsky_core::Error::Io(e)),
}
Ok(())
}
pub(crate) fn status(repo_root: &Path, json: bool) -> netsky_core::Result<()> {
let canonical = repo_root.join(CANONICAL_REL);
let target = repo_root.join(HOOK_REL);
let canonical_present = canonical.exists();
let canonical_exec = canonical_present && is_exec(&canonical);
let state = classify(&target);
let link_target = fs::read_link(&target).ok().map(|p| p.display().to_string());
let bypass = std::env::var("SKIP_PREPUSH_CHECK").unwrap_or_default();
let bypass_reason = std::env::var("SKIP_PREPUSH_REASON").unwrap_or_default();
if json {
let verdict = match state {
HookState::CanonicalLink if canonical_present && canonical_exec => "green",
HookState::CanonicalLink => "yellow",
HookState::Absent | HookState::Drift => "red",
};
let state_str = match state {
HookState::Absent => "absent",
HookState::CanonicalLink => "canonical",
HookState::Drift => "drift",
};
let summary = match state {
HookState::CanonicalLink if canonical_present && canonical_exec => {
"pre-push hook installed and canonical".to_string()
}
HookState::CanonicalLink => "canonical symlink present but canonical file off".into(),
HookState::Absent => "pre-push hook absent (run `netsky hooks install`)".into(),
HookState::Drift => "pre-push hook drifted (run `netsky hooks install --force`)".into(),
};
let envelope = serde_json::json!({
"command": "hooks status",
"status": verdict,
"summary": summary,
"generated_at": chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
"data": {
"repo_root": repo_root.display().to_string(),
"canonical_path": canonical.display().to_string(),
"canonical_present": canonical_present,
"canonical_executable": canonical_exec,
"hook_path": target.display().to_string(),
"hook_state": state_str,
"hook_link_target": link_target,
"bypass_env_set": !bypass.is_empty(),
"bypass_env_value": bypass,
"bypass_reason_set": !bypass_reason.is_empty(),
"bypass_reason_value": bypass_reason,
},
});
println!("{}", serde_json::to_string_pretty(&envelope)?);
return Ok(());
}
println!("canonical: {}", canonical.display());
println!(
" present: {}",
if canonical_present { "yes" } else { "NO" }
);
if canonical_present {
println!(" executable: {}", yes_no(canonical_exec));
}
println!("hook: {}", target.display());
println!(
" state: {}",
match state {
HookState::Absent => "absent (run `netsky hooks install`)",
HookState::CanonicalLink => "canonical symlink",
HookState::Drift => "PRESENT BUT DRIFTED (run `netsky hooks install --force`)",
}
);
if let Some(lt) = &link_target {
println!(" link target: {lt}");
}
println!(
"bypass env: SKIP_PREPUSH_CHECK={}",
if bypass.is_empty() {
"<unset>"
} else {
&bypass
}
);
println!(
" reason: SKIP_PREPUSH_REASON={}",
if bypass_reason.is_empty() {
"<unset>"
} else {
&bypass_reason
}
);
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum HookState {
Absent,
CanonicalLink,
Drift,
}
pub(crate) fn classify(target: &Path) -> HookState {
match fs::symlink_metadata(target) {
Err(_) => HookState::Absent,
Ok(meta) => {
if meta.file_type().is_symlink() {
match fs::read_link(target) {
Ok(link) if link.as_path() == Path::new(SYMLINK_TARGET) => {
HookState::CanonicalLink
}
_ => HookState::Drift,
}
} else {
HookState::Drift
}
}
}
}
fn repo_root() -> netsky_core::Result<PathBuf> {
let out = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(netsky_core::Error::Io)?;
if !out.status.success() {
return Err(netsky_core::Error::Invalid(
"not inside a git working tree".into(),
));
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
return Err(netsky_core::Error::Invalid(
"git rev-parse returned empty root".into(),
));
}
Ok(PathBuf::from(s))
}
fn ensure_exec(path: &Path) -> netsky_core::Result<()> {
let meta = fs::metadata(path).map_err(netsky_core::Error::Io)?;
let mut perms = meta.permissions();
let mode = perms.mode();
if mode & 0o111 != 0o111 {
perms.set_mode(mode | 0o755);
fs::set_permissions(path, perms).map_err(netsky_core::Error::Io)?;
}
Ok(())
}
fn is_exec(path: &Path) -> bool {
fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
fn print_drift(target: &Path, canonical: &Path) -> netsky_core::Result<()> {
let existing = fs::read_to_string(target).unwrap_or_default();
let want = fs::read_to_string(canonical).map_err(netsky_core::Error::Io)?;
println!(
"[hooks install] {} exists and differs from {}:",
target.display(),
canonical.display()
);
println!("--- existing ---");
println!("{}", existing.trim_end());
println!("--- canonical ---");
println!("{}", want.trim_end());
Ok(())
}
fn yes_no(b: bool) -> &'static str {
if b { "yes" } else { "NO" }
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::symlink;
use tempfile::tempdir;
fn init_repo_with_canonical() -> tempfile::TempDir {
let td = tempdir().unwrap();
let root = td.path();
fs::create_dir_all(root.join(".git/hooks")).unwrap();
fs::create_dir_all(root.join(".githooks")).unwrap();
fs::write(root.join(CANONICAL_REL), "#!/usr/bin/env bash\nexec true\n").unwrap();
let mut perms = fs::metadata(root.join(CANONICAL_REL))
.unwrap()
.permissions();
perms.set_mode(0o755);
fs::set_permissions(root.join(CANONICAL_REL), perms).unwrap();
td
}
#[test]
fn classify_absent_on_empty_dir() {
let td = tempdir().unwrap();
assert_eq!(
classify(&td.path().join(".git/hooks/pre-push")),
HookState::Absent
);
}
#[test]
fn classify_canonical_on_correct_symlink() {
let td = init_repo_with_canonical();
let target = td.path().join(HOOK_REL);
symlink(SYMLINK_TARGET, &target).unwrap();
assert_eq!(classify(&target), HookState::CanonicalLink);
}
#[test]
fn classify_drift_on_regular_file() {
let td = init_repo_with_canonical();
let target = td.path().join(HOOK_REL);
fs::write(&target, "#!/bin/sh\necho other\n").unwrap();
assert_eq!(classify(&target), HookState::Drift);
}
#[test]
fn classify_drift_on_wrong_symlink() {
let td = init_repo_with_canonical();
let target = td.path().join(HOOK_REL);
symlink("/dev/null", &target).unwrap();
assert_eq!(classify(&target), HookState::Drift);
}
#[test]
fn classify_canonical_points_at_missing_canonical() {
let td = init_repo_with_canonical();
let target = td.path().join(HOOK_REL);
symlink(SYMLINK_TARGET, &target).unwrap();
fs::remove_file(td.path().join(CANONICAL_REL)).unwrap();
assert_eq!(classify(&target), HookState::CanonicalLink);
}
#[test]
fn install_from_clean_creates_symlink() {
let td = init_repo_with_canonical();
install(td.path(), false).unwrap();
let target = td.path().join(HOOK_REL);
assert_eq!(classify(&target), HookState::CanonicalLink);
assert_eq!(
fs::read_link(&target).unwrap(),
PathBuf::from(SYMLINK_TARGET)
);
}
#[test]
fn install_is_idempotent() {
let td = init_repo_with_canonical();
install(td.path(), false).unwrap();
install(td.path(), false).unwrap();
install(td.path(), false).unwrap();
assert_eq!(
classify(&td.path().join(HOOK_REL)),
HookState::CanonicalLink
);
}
#[test]
fn install_without_force_leaves_drift_intact() {
let td = init_repo_with_canonical();
let target = td.path().join(HOOK_REL);
fs::write(&target, "#!/bin/sh\necho legacy\n").unwrap();
install(td.path(), false).unwrap();
assert_eq!(classify(&target), HookState::Drift);
assert_eq!(
fs::read_to_string(&target).unwrap(),
"#!/bin/sh\necho legacy\n"
);
}
#[test]
fn install_with_force_replaces_drift() {
let td = init_repo_with_canonical();
let target = td.path().join(HOOK_REL);
fs::write(&target, "#!/bin/sh\necho legacy\n").unwrap();
install(td.path(), true).unwrap();
assert_eq!(classify(&target), HookState::CanonicalLink);
}
#[test]
fn install_missing_canonical_errors() {
let td = tempdir().unwrap();
fs::create_dir_all(td.path().join(".git/hooks")).unwrap();
let err = install(td.path(), false).unwrap_err();
assert!(
matches!(err, netsky_core::Error::Invalid(ref m) if m.contains("canonical hook missing"))
);
}
#[test]
fn uninstall_removes_symlink() {
let td = init_repo_with_canonical();
install(td.path(), false).unwrap();
uninstall(td.path()).unwrap();
assert_eq!(classify(&td.path().join(HOOK_REL)), HookState::Absent);
}
#[test]
fn uninstall_absent_is_noop() {
let td = init_repo_with_canonical();
uninstall(td.path()).unwrap();
assert_eq!(classify(&td.path().join(HOOK_REL)), HookState::Absent);
}
#[test]
fn install_makes_canonical_executable() {
let td = init_repo_with_canonical();
let canonical = td.path().join(CANONICAL_REL);
let mut p = fs::metadata(&canonical).unwrap().permissions();
p.set_mode(0o644);
fs::set_permissions(&canonical, p).unwrap();
install(td.path(), false).unwrap();
assert!(is_exec(&canonical));
}
}