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 => status(&root),
}
}
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) -> netsky_core::Result<()> {
let canonical = repo_root.join(CANONICAL_REL);
let target = repo_root.join(HOOK_REL);
println!("canonical: {}", canonical.display());
println!(
" present: {}",
if canonical.exists() { "yes" } else { "NO" }
);
if canonical.exists() {
println!(" executable: {}", yes_no(is_exec(&canonical)));
}
println!("hook: {}", target.display());
let state = classify(&target);
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 Ok(link) = fs::read_link(&target) {
println!(" link target: {}", link.display());
}
let bypass = std::env::var("SKIP_PREPUSH_CHECK").unwrap_or_default();
println!(
"bypass env: SKIP_PREPUSH_CHECK={}",
if bypass.is_empty() {
"<unset>"
} else {
&bypass
}
);
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));
}
}