use std::path::{Path, PathBuf};
pub const PRE_PUSH_HOOK: &str = include_str!("../assets/hooks/pre-push");
pub const HOOK_MARKER: &str = "trusty-mpm-push-guard:";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HookInstall {
Installed(PathBuf),
AlreadyCurrent(PathBuf),
Refused(String),
}
pub fn effective_hooks_dir(repo_path: &Path) -> Result<PathBuf, String> {
let custom = git_config_get(repo_path, "core.hooksPath");
if let Some(custom) = custom {
return Err(format!(
"core.hooksPath is set to {custom:?}; another hook manager owns this repository"
));
}
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.output()
.map_err(|e| format!("git rev-parse --git-common-dir failed to spawn: {e}"))?;
if !out.status.success() {
return Err(format!(
"git rev-parse --git-common-dir failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
));
}
let common = String::from_utf8_lossy(&out.stdout).trim().to_string();
if common.is_empty() {
return Err("git rev-parse --git-common-dir returned nothing".to_string());
}
Ok(PathBuf::from(common).join("hooks"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuardState {
Missing(PathBuf),
Current(PathBuf),
Outdated(PathBuf),
Foreign(String),
}
pub fn inspect_pre_push_guard(repo_path: &Path) -> GuardState {
let hooks_dir = match effective_hooks_dir(repo_path) {
Ok(d) => d,
Err(reason) => return GuardState::Foreign(reason),
};
let hook_path = hooks_dir.join("pre-push");
match std::fs::symlink_metadata(&hook_path) {
Ok(meta) if meta.file_type().is_symlink() => {
return GuardState::Foreign(format!(
"{} is a symlink; another hook manager owns it",
hook_path.display()
));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return GuardState::Missing(hook_path);
}
Err(e) => {
return GuardState::Foreign(format!("cannot stat {}: {e}", hook_path.display()));
}
}
match std::fs::read(&hook_path) {
Ok(bytes) if !contains_marker(&bytes) => GuardState::Foreign(format!(
"a non-trusty-mpm pre-push hook already exists at {}",
hook_path.display()
)),
Ok(bytes) if bytes == PRE_PUSH_HOOK.as_bytes() => GuardState::Current(hook_path),
Ok(_) => GuardState::Outdated(hook_path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => GuardState::Missing(hook_path),
Err(e) => GuardState::Foreign(format!(
"cannot read existing {} ({e}); refusing to overwrite a hook that cannot be inspected",
hook_path.display()
)),
}
}
pub fn install_pre_push_guard(repo_path: &Path) -> Result<HookInstall, String> {
let hook_path = match inspect_pre_push_guard(repo_path) {
GuardState::Current(p) => return Ok(HookInstall::AlreadyCurrent(p)),
GuardState::Foreign(reason) => return Ok(HookInstall::Refused(reason)),
GuardState::Missing(p) | GuardState::Outdated(p) => p,
};
let hooks_dir = hook_path
.parent()
.ok_or_else(|| format!("{} has no parent directory", hook_path.display()))?
.to_path_buf();
std::fs::create_dir_all(&hooks_dir)
.map_err(|e| format!("failed to create {}: {e}", hooks_dir.display()))?;
write_hook_atomically(&hooks_dir, &hook_path)?;
Ok(HookInstall::Installed(hook_path))
}
fn contains_marker(bytes: &[u8]) -> bool {
let needle = HOOK_MARKER.as_bytes();
bytes.windows(needle.len()).any(|w| w == needle)
}
fn write_hook_atomically(hooks_dir: &Path, hook_path: &Path) -> Result<(), String> {
let tmp_path = hooks_dir.join(format!("pre-push.tmp.{}", std::process::id()));
let cleanup = |e: String| {
let _ = std::fs::remove_file(&tmp_path);
e
};
std::fs::write(&tmp_path, PRE_PUSH_HOOK)
.map_err(|e| cleanup(format!("failed to write {}: {e}", tmp_path.display())))?;
set_executable(&tmp_path).map_err(cleanup)?;
std::fs::rename(&tmp_path, hook_path).map_err(|e| {
cleanup(format!(
"failed to rename {} onto {}: {e}",
tmp_path.display(),
hook_path.display()
))
})?;
Ok(())
}
pub fn install_and_log(repo_path: &Path) {
match install_pre_push_guard(repo_path) {
Ok(HookInstall::Installed(p)) => {
tracing::info!(hook = %p.display(), "cross-branch push guard installed (#2867)");
}
Ok(HookInstall::AlreadyCurrent(_)) => {}
Ok(HookInstall::Refused(reason)) => {
tracing::warn!(
repo = %repo_path.display(),
"cross-branch push guard NOT installed (#2867): {reason}"
);
}
Err(e) => {
tracing::warn!(
repo = %repo_path.display(),
"cross-branch push guard install failed (non-fatal): {e}"
);
}
}
}
fn git_config_get(repo_path: &Path, key: &str) -> Option<String> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["config", "--get", key])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
if value.is_empty() { None } else { Some(value) }
}
fn set_executable(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("failed to chmod {}: {e}", path.display()))?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_repo() -> Option<(tempfile::TempDir, PathBuf)> {
let dir = crate::test_support::hermetic_temp_dir();
let path = dir.path().to_path_buf();
let ok = std::process::Command::new("git")
.args(["init", "-q", "-b", "main"])
.arg(&path)
.status()
.ok()?;
if !ok.success() {
return None;
}
Some((dir, path))
}
#[test]
fn bundled_hook_carries_marker_and_shebang() {
assert!(
PRE_PUSH_HOOK.starts_with("#!/bin/sh"),
"the bundled hook must be a POSIX sh script"
);
assert!(
PRE_PUSH_HOOK.contains(HOOK_MARKER),
"the bundled hook must carry the ownership marker {HOOK_MARKER}"
);
assert!(
PRE_PUSH_HOOK.contains("TM_ALLOW_CROSS_BRANCH_PUSH"),
"the bundled hook must document its override env var"
);
}
#[test]
fn hooks_dir_resolves_to_common_dir() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = effective_hooks_dir(&repo).expect("hooks dir must resolve in a fresh repo");
assert!(
hooks.ends_with("hooks"),
"resolved hooks dir must end in `hooks`, got {}",
hooks.display()
);
assert!(
hooks.is_absolute(),
"resolved hooks dir must be absolute, got {}",
hooks.display()
);
}
#[test]
fn hooks_dir_errs_on_custom_hookspath() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
assert!(
std::process::Command::new("git")
.arg("-C")
.arg(&repo)
.args(["config", "core.hooksPath", "/somewhere/else"])
.status()
.expect("git config")
.success()
);
let err = effective_hooks_dir(&repo).expect_err("a custom hooksPath must be refused");
assert!(
err.contains("core.hooksPath"),
"refusal must name core.hooksPath, got: {err}"
);
match install_pre_push_guard(&repo).expect("install must not hard-error") {
HookInstall::Refused(reason) => assert!(reason.contains("core.hooksPath")),
other => panic!("expected Refused, got {other:?}"),
}
}
#[test]
fn installs_then_reports_already_current() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let first = install_pre_push_guard(&repo).expect("first install");
let hook_path = match &first {
HookInstall::Installed(p) => p.clone(),
other => panic!("expected Installed, got {other:?}"),
};
assert_eq!(
std::fs::read_to_string(&hook_path).expect("read hook"),
PRE_PUSH_HOOK
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&hook_path)
.expect("stat hook")
.permissions()
.mode();
assert_eq!(
mode & 0o111,
0o111,
"hook must be executable, mode {mode:o}"
);
}
let second = install_pre_push_guard(&repo).expect("second install");
assert_eq!(
second,
HookInstall::AlreadyCurrent(hook_path),
"a repeat install must be a no-op"
);
}
#[test]
fn reinstalls_over_an_older_trusty_mpm_revision() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
std::fs::create_dir_all(&hooks).expect("mkdir hooks");
std::fs::write(
hooks.join("pre-push"),
"#!/bin/sh\n# trusty-mpm-push-guard: v0\nexit 0\n",
)
.expect("seed old hook");
match install_pre_push_guard(&repo).expect("install") {
HookInstall::Installed(p) => {
assert_eq!(std::fs::read_to_string(p).expect("read"), PRE_PUSH_HOOK);
}
other => panic!("an older trusty-mpm revision must be upgraded, got {other:?}"),
}
}
#[test]
fn refuses_foreign_hook() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
std::fs::create_dir_all(&hooks).expect("mkdir hooks");
let foreign = "#!/bin/sh\n# husky\nexit 0\n";
std::fs::write(hooks.join("pre-push"), foreign).expect("seed foreign hook");
match install_pre_push_guard(&repo).expect("install must not hard-error") {
HookInstall::Refused(reason) => {
assert!(reason.contains("non-trusty-mpm"), "got: {reason}");
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(hooks.join("pre-push")).expect("read"),
foreign,
"a foreign hook must never be overwritten"
);
}
#[test]
fn refuses_non_utf8_foreign_hook() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
std::fs::create_dir_all(&hooks).expect("mkdir hooks");
let mut foreign: Vec<u8> = b"#!/bin/sh\n# lefthook ".to_vec();
foreign.extend_from_slice(&[0xff, 0xfe]);
foreign.extend_from_slice(b" binary marker\nexit 0\n");
assert!(
std::str::from_utf8(&foreign).is_err(),
"the fixture must genuinely not be UTF-8, or this test proves nothing"
);
std::fs::write(hooks.join("pre-push"), &foreign).expect("seed foreign hook");
match install_pre_push_guard(&repo).expect("install must not hard-error") {
HookInstall::Refused(reason) => {
assert!(reason.contains("non-trusty-mpm"), "got: {reason}");
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(
std::fs::read(hooks.join("pre-push")).expect("read"),
foreign,
"a non-UTF-8 foreign hook must be byte-identical after a refusal"
);
}
#[cfg(unix)]
#[test]
fn refuses_symlinked_hook() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
std::fs::create_dir_all(&hooks).expect("mkdir hooks");
let target = hooks.join("managed-by-someone-else.sh");
let target_body = "#!/bin/sh\n# husky\nexit 0\n";
std::fs::write(&target, target_body).expect("seed target");
std::os::unix::fs::symlink(&target, hooks.join("pre-push")).expect("symlink");
match install_pre_push_guard(&repo).expect("install must not hard-error") {
HookInstall::Refused(reason) => {
assert!(reason.contains("symlink"), "got: {reason}");
}
other => panic!("expected Refused, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&target).expect("read target"),
target_body,
"the symlink target must never be written through"
);
}
#[cfg(unix)]
#[test]
fn refuses_unreadable_hook() {
use std::os::unix::fs::PermissionsExt;
let Some((_dir, repo)) = temp_repo() else {
return;
};
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
std::fs::create_dir_all(&hooks).expect("mkdir hooks");
let hook = hooks.join("pre-push");
std::fs::write(&hook, "#!/bin/sh\nexit 0\n").expect("seed hook");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o000)).expect("chmod 000");
if std::fs::read(&hook).is_ok() {
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o644))
.expect("restore");
return;
}
let outcome = install_pre_push_guard(&repo).expect("install must not hard-error");
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o644)).expect("restore");
match outcome {
HookInstall::Refused(reason) => {
assert!(reason.contains("cannot read"), "got: {reason}");
}
other => panic!("expected Refused for an unreadable hook, got {other:?}"),
}
assert_eq!(
std::fs::read_to_string(&hook).expect("read"),
"#!/bin/sh\nexit 0\n",
"an unreadable hook must be byte-identical after a refusal"
);
}
#[test]
fn inspect_reports_missing_then_current() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
match inspect_pre_push_guard(&repo) {
GuardState::Missing(p) => assert!(p.ends_with("hooks/pre-push"), "{}", p.display()),
other => panic!("a fresh repo must report Missing, got {other:?}"),
}
install_pre_push_guard(&repo).expect("install");
match inspect_pre_push_guard(&repo) {
GuardState::Current(_) => {}
other => panic!("after install the probe must report Current, got {other:?}"),
}
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
std::fs::write(
hooks.join("pre-push"),
"#!/bin/sh\n# trusty-mpm-push-guard: v0\nexit 0\n",
)
.expect("downgrade hook");
match inspect_pre_push_guard(&repo) {
GuardState::Outdated(_) => {}
other => panic!("an older revision must report Outdated, got {other:?}"),
}
}
#[test]
fn atomic_write_leaves_no_temp_file_behind() {
let Some((_dir, repo)) = temp_repo() else {
return;
};
install_pre_push_guard(&repo).expect("install");
let hooks = effective_hooks_dir(&repo).expect("hooks dir");
let leftovers: Vec<_> = std::fs::read_dir(&hooks)
.expect("read hooks dir")
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.starts_with("pre-push.tmp."))
.collect();
assert!(
leftovers.is_empty(),
"the atomic write must clean up after itself, found: {leftovers:?}"
);
}
}