use semver::Version as SemVer;
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::{env, ffi, fs, str, thread, time};
lazy_static! {
static ref TMPDIR_ROOT: PathBuf = {
let mut tmp = env::temp_dir();
tmp.push("cargo-husky-test");
ensure_empty_dir(&tmp);
unsafe {
::libc::atexit(cleanup_tmpdir);
}
tmp
};
static ref TESTDIR: PathBuf = fs::canonicalize(file!())
.unwrap()
.parent()
.unwrap()
.join("testdata");
}
#[allow(private_no_mangle_fns)]
#[no_mangle]
extern "C" fn cleanup_tmpdir() {
if TMPDIR_ROOT.exists() {
fs::remove_dir_all(TMPDIR_ROOT.as_path()).unwrap();
}
}
fn ensure_empty_dir(path: &Path) {
if path.exists() {
for entry in fs::read_dir(path).unwrap() {
fs::remove_dir_all(entry.unwrap().path()).unwrap();
}
} else {
fs::create_dir_all(path).unwrap();
}
}
fn tmpdir_for(name: &str) -> PathBuf {
let tmp = TMPDIR_ROOT.join(name);
ensure_empty_dir(&tmp);
tmp
}
fn open_cargo_toml(repo_dir: &Path) -> fs::File {
OpenOptions::new()
.write(true)
.append(true)
.open(repo_dir.join("Cargo.toml"))
.unwrap()
}
fn run_cargo<'a, I, S, P>(project_root: P, args: I) -> Result<Output, String>
where
I: IntoIterator<Item = S>,
S: AsRef<ffi::OsStr>,
P: AsRef<Path>,
{
let out = Command::new("cargo")
.args(args)
.current_dir(&project_root)
.output()
.unwrap();
if out.status.success() {
Ok(out)
} else {
Err(str::from_utf8(out.stderr.as_slice()).unwrap().to_string())
}
}
fn cargo_project_for(name: &str) -> PathBuf {
let dir = tmpdir_for(name);
run_cargo(&dir, &["init", "--lib"]).unwrap();
let mut cargo_toml = open_cargo_toml(&dir);
writeln!(
cargo_toml,
"\n\n[patch.crates-io]\ncargo-husky = {{ path = \"{}\" }}\n\n[dev-dependencies.cargo-husky]\nversion = \"{}\"",
fs::canonicalize(file!())
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.to_string_lossy(),
env!("CARGO_PKG_VERSION"),
).unwrap();
dir
}
fn hook_path(root: &Path, name: &str) -> PathBuf {
let mut path = root.to_owned();
path.push(".git");
path.push("hooks");
assert!(path.exists()); path.push(name);
return path;
}
fn get_hook_script(root: &Path, hook: &str) -> Option<String> {
let path = hook_path(root, hook);
let mut f = File::open(path).ok()?;
let mut s = String::new();
f.read_to_string(&mut s).unwrap();
Some(s)
}
fn decrease_patch(mut ver: SemVer) -> SemVer {
if ver.patch > 0 {
ver.patch -= 1;
return ver;
}
ver.patch = 9;
if ver.minor > 0 {
ver.minor -= 1;
return ver;
}
ver.minor = 9;
if ver.major > 0 {
ver.major -= 1;
return ver;
}
unreachable!();
}
#[test]
fn default_behavior() {
let root = cargo_project_for("default");
run_cargo(&root, &["test"]).unwrap();
let script = get_hook_script(&root, "pre-push").unwrap();
assert_eq!(script.lines().nth(0).unwrap(), "#!/bin/sh");
assert!(
script
.lines()
.nth(2)
.unwrap()
.contains(format!("set by cargo-husky v{}", env!("CARGO_PKG_VERSION")).as_str())
);
assert_eq!(script.lines().filter(|l| *l == "cargo test").count(), 1);
assert!(script.lines().all(|l| l != "cargo clippy"));
assert_eq!(get_hook_script(&root, "pre-commit"), None);
}
#[test]
#[cfg(not(target_os = "win32"))]
fn hook_file_is_executable() {
use std::os::unix::fs::PermissionsExt;
let root = cargo_project_for("unit-permission");
run_cargo(&root, &["test"]).unwrap();
let prepush_path = hook_path(&root, "pre-push");
let mode = File::open(&prepush_path)
.unwrap()
.metadata()
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o555, 0o555);
}
#[test]
fn change_features() {
let root = cargo_project_for("features");
let mut cargo_toml = open_cargo_toml(&root);
writeln!(
cargo_toml,
"default-features = false\nfeatures = [\"precommit-hook\", \"run-cargo-clippy\"]"
);
run_cargo(&root, &["test"]).unwrap();
assert_eq!(get_hook_script(&root, "pre-push"), None);
let script = get_hook_script(&root, "pre-commit").unwrap();
assert!(script.lines().all(|l| l != "cargo test"));
assert_eq!(script.lines().filter(|l| *l == "cargo clippy").count(), 1);
}
#[test]
fn hook_not_updated_twice() {
let root = cargo_project_for("not-update-twice");
run_cargo(&root, &["test"]).unwrap();
let prepush_path = hook_path(&root, "pre-push");
let first = File::open(&prepush_path)
.unwrap()
.metadata()
.unwrap()
.modified()
.unwrap();
fs::remove_dir_all(root.join("target")).unwrap();
thread::sleep(time::Duration::from_secs(1));
run_cargo(&root, &["test"]).unwrap();
let second = File::open(&prepush_path)
.unwrap()
.metadata()
.unwrap()
.modified()
.unwrap();
assert_eq!(first, second); }
#[test]
fn regenerate_hook_script_on_package_update() {
let root = cargo_project_for("package-update");
run_cargo(&root, &["test"]).unwrap();
let prepush_path = hook_path(&root, "pre-push");
let script = get_hook_script(&root, "pre-push").unwrap();
let before = format!("set by cargo-husky v{}", env!("CARGO_PKG_VERSION"));
let prev_version = decrease_patch(SemVer::parse(env!("CARGO_PKG_VERSION")).unwrap());
let after = format!("set by cargo-husky v{}", prev_version);
let script = script.replacen(before.as_str(), after.as_str(), 1);
let modified_before = {
let mut f = OpenOptions::new()
.write(true)
.read(true)
.truncate(true)
.open(&prepush_path)
.unwrap();
write!(f, "{}", script);
f.metadata().unwrap().modified().unwrap()
};
fs::remove_dir_all(root.join("target")).unwrap();
thread::sleep(time::Duration::from_secs(1));
run_cargo(&root, &["test"]).unwrap();
let modified_after = File::open(&prepush_path)
.unwrap()
.metadata()
.unwrap()
.modified()
.unwrap();
assert_ne!(modified_before, modified_after);
let script = get_hook_script(&root, "pre-push").unwrap();
assert!(
script
.lines()
.nth(2)
.unwrap()
.contains(format!("set by cargo-husky v{}", env!("CARGO_PKG_VERSION")).as_str())
);
}
macro_rules! another_hook_test {
($testcase:ident, $content:expr) => {
#[test]
fn $testcase() {
let root = cargo_project_for(stringify!($testcase));
let prepush_path = hook_path(&root, "pre-push");
let content = $content.to_string();
let modified_before = {
let mut f = File::create(&prepush_path).unwrap();
writeln!(f, "{}", content);
f.metadata().unwrap().modified().unwrap()
};
thread::sleep(time::Duration::from_secs(1));
run_cargo(&root, &["test"]).unwrap();
let modified_after = File::open(&prepush_path)
.unwrap()
.metadata()
.unwrap()
.modified()
.unwrap();
assert_eq!(modified_before, modified_after);
let script = get_hook_script(&root, "pre-push").unwrap();
assert_eq!(content + "\n", script);
}
};
}
another_hook_test!(
another_hook_less_than_3_lines,
"#!/bin/sh\necho 'hook put by someone else'"
);
another_hook_test!(
another_hook_more_than_3_lines,
"#!/bin/sh\n\n\necho 'hook put by someone else'"
);
fn copy_dir_recursive(from: &Path, to: &Path) {
if !to.exists() {
fs::create_dir_all(to).unwrap();
}
for entry in fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let child_from = entry.path();
let child_to = to.join(child_from.strip_prefix(from).unwrap());
if entry.file_type().unwrap().is_dir() {
copy_dir_recursive(&child_from, &child_to);
} else {
fs::copy(child_from, child_to).unwrap();
}
}
}
fn setup_user_hooks_feature(root: &Path) {
let mut cargo_toml = open_cargo_toml(&root);
writeln!(
cargo_toml,
"default-features = false\nfeatures = [\"user-hooks\"]" );
}
#[test]
fn user_hooks() {
let root = cargo_project_for("user-hooks");
setup_user_hooks_feature(&root);
let user_hooks = TESTDIR.join("user-hooks");
copy_dir_recursive(&user_hooks.join(".cargo-husky"), &root.join(".cargo-husky"));
run_cargo(&root, &["test"]).unwrap();
assert!(!hook_path(&root, "pre-push").exists()); assert!(hook_path(&root, "pre-commit").is_file());
assert!(hook_path(&root, "post-merge").is_file());
let check_line = format!(
"# This hook was set by cargo-husky v{}: {}",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_HOMEPAGE")
);
let s = get_hook_script(&root, "pre-commit").unwrap();
assert_eq!(s.lines().nth(0), Some("#! /bin/sh"));
assert_eq!(s.lines().nth(2), Some(check_line.as_str()));
assert_eq!(
s.lines().nth(4),
Some("# This is a user script for pre-commit hook with shebang")
);
let s = get_hook_script(&root, "post-merge").unwrap();
assert_eq!(s.lines().nth(0), Some("#"));
assert_eq!(s.lines().nth(2), Some(check_line.as_str()));
assert_eq!(
s.lines().nth(3),
Some("# Script without shebang (I'm not sure this is useful)")
);
}
fn assert_user_hooks_error(root: &Path) {
match run_cargo(&root, &["test"]) {
Ok(out) => assert!(
false,
"`cargo test` has unexpectedly successfully done: {:?}",
out
),
Err(err) => assert!(
format!("{}", err).contains("User hooks directory is not found or empty"),
"Unexpected output on `cargo test`: {}",
err
),
}
}
#[test]
fn user_hooks_dir_not_found() {
let root = cargo_project_for("user-hooks-dir-not-found");
setup_user_hooks_feature(&root);
assert_user_hooks_error(&root);
}
#[test]
fn user_hooks_dir_is_empty() {
for (idx, dir_path) in [
PathBuf::from(".cargo-husky"),
Path::new(".cargo-husky").join("hooks"),
]
.iter()
.enumerate()
{
let root = cargo_project_for(&format!("user-hooks-dir-empty-{}", idx));
setup_user_hooks_feature(&root);
fs::create_dir_all(&dir_path).unwrap();
assert_user_hooks_error(&root);
}
}
#[test]
#[cfg(not(target_os = "win32"))]
fn user_hooks_dir_only_contains_non_executable_file() {
let root = cargo_project_for("user-hooks-dir-without-executables");
setup_user_hooks_feature(&root);
let mut p = root.join(".cargo-husky");
p.push("hooks");
fs::create_dir_all(&p).unwrap();
let f1 = p.join("non-executable-file1");
writeln!(File::create(&f1).unwrap(), "this\nis\nnormal\ntest\nfile").unwrap();
assert!(f1.exists());
let f2 = p.join("non-executable-file2");
writeln!(
File::create(&f2).unwrap(),
"this\nis\nalso\nnormal\ntest\nfile"
).unwrap();
assert!(f2.exists());
assert_user_hooks_error(&root);
}
#[test]
#[cfg(not(target_os = "win32"))]
fn copied_user_hooks_are_executable() {
use std::os::unix::fs::PermissionsExt;
let root = cargo_project_for("copied-user-hooks-are-executable");
setup_user_hooks_feature(&root);
let mut p = root.join(".cargo-husky");
let user_hooks = TESTDIR.join("user-hooks");
copy_dir_recursive(&user_hooks.join(".cargo-husky"), &p);
p.push("hooks");
p.push("non-executable-file.txt");
writeln!(File::create(p).unwrap(), "foo\nbar\npiyo").unwrap();
run_cargo(&root, &["test"]).unwrap();
for name in &["pre-commit", "post-merge"] {
let hook = File::open(hook_path(&root, name)).unwrap();
let mode = hook.metadata().unwrap().permissions().mode();
assert_eq!(mode & 0o555, 0o555);
}
assert!(!hook_path(&root, "non-executable-file.txt").exists());
}