use std::path::Path;
use assert_cmd::Command;
fn git(dir: &Path, args: &[&str]) -> String {
let output = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn head_message(dir: &Path) -> String {
git(dir, &["log", "-1", "--format=%B"]).trim().to_string()
}
fn init_hooks_repo(dir: &Path) {
git(dir, &["init"]);
git(dir, &["config", "user.name", "Test"]);
git(dir, &["config", "user.email", "test@test.com"]);
}
#[test]
fn hooks_run_arg_passthrough_substitutes_msg_token() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("commit-msg.hooks"), "! echo {msg}\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args([
"--color",
"never",
"hook",
"run",
"commit-msg",
"--",
"/tmp/test-msg",
])
.current_dir(dir.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("/tmp/test-msg"),
"output should contain the substituted path, got:\nstdout: {stdout}\nstderr: {stderr}"
);
}
#[test]
fn hooks_run_pre_commit_workflow() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit.hooks"),
"echo \"lint ok\"\nfalse\n? false\n",
)
.unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.code(1);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
stderr.contains('\u{2713}'),
"should contain check mark for passing command, got: {stderr}"
);
assert!(
stderr.contains('\u{2717}'),
"should contain cross mark for failing command, got: {stderr}"
);
assert!(
stderr.contains('\u{26a0}'),
"should contain warning mark for advisory command, got: {stderr}"
);
}
#[test]
fn hooks_run_commit_msg_bad_message_fails() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let bin = Command::cargo_bin("git-std")
.unwrap()
.get_program()
.to_owned();
let bin_str = bin.to_string_lossy();
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("commit-msg.hooks"),
format!("! {bin_str} lint --file {{msg}}\n"),
)
.unwrap();
let msg_file = dir.path().join("COMMIT_MSG");
std::fs::write(&msg_file, "bad message\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args([
"--color",
"never",
"hook",
"run",
"commit-msg",
"--",
msg_file.to_str().unwrap(),
])
.current_dir(dir.path())
.assert()
.code(1);
}
#[test]
fn hooks_run_commit_msg_good_message_passes() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let bin = Command::cargo_bin("git-std")
.unwrap()
.get_program()
.to_owned();
let bin_str = bin.to_string_lossy();
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("commit-msg.hooks"),
format!("! {bin_str} lint --file {{msg}}\n"),
)
.unwrap();
let msg_file = dir.path().join("COMMIT_MSG");
std::fs::write(&msg_file, "feat: valid commit\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args([
"--color",
"never",
"hook",
"run",
"commit-msg",
"--",
msg_file.to_str().unwrap(),
])
.current_dir(dir.path())
.assert()
.success();
}
#[test]
fn hooks_full_install_cycle() {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init"]);
git(dir.path(), &["config", "user.name", "Test"]);
git(dir.path(), &["config", "user.email", "test@test.com"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit.hooks"),
"echo \"pre-commit ok\"\n",
)
.unwrap();
let bin = Command::cargo_bin("git-std")
.unwrap()
.get_program()
.to_owned();
let bin_str = bin.to_string_lossy();
std::fs::write(
hooks_dir.join("commit-msg.hooks"),
format!("! {bin_str} lint --file {{msg}}\n"),
)
.unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["init"])
.env("GIT_STD_HOOKS_ENABLE", "pre-commit,commit-msg")
.current_dir(dir.path())
.assert()
.success();
let hooks_path = git(dir.path(), &["config", "core.hooksPath"]);
assert_eq!(hooks_path, ".githooks");
let pre_commit_shim = hooks_dir.join("pre-commit");
let commit_msg_shim = hooks_dir.join("commit-msg");
assert!(pre_commit_shim.exists(), "pre-commit shim should exist");
assert!(commit_msg_shim.exists(), "commit-msg shim should exist");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&pre_commit_shim).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"pre-commit shim should be executable"
);
let perms = std::fs::metadata(&commit_msg_shim).unwrap().permissions();
assert!(
perms.mode() & 0o111 != 0,
"commit-msg shim should be executable"
);
}
let bin_path = Command::cargo_bin("git-std")
.unwrap()
.get_program()
.to_owned();
let bin_dir = Path::new(&bin_path).parent().unwrap();
let path_env = format!(
"{}:{}",
bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
std::fs::write(dir.path().join("hello.txt"), "hello\n").unwrap();
let status = std::process::Command::new("git")
.args(["add", "hello.txt"])
.current_dir(dir.path())
.status()
.unwrap();
assert!(status.success(), "git add should succeed");
let output = std::process::Command::new("git")
.args(["commit", "-m", "feat: add hello"])
.current_dir(dir.path())
.env("PATH", &path_env)
.output()
.unwrap();
assert!(
output.status.success(),
"git commit with valid message should succeed when hooks are installed.\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let msg = head_message(dir.path());
assert!(
msg.starts_with("feat: add hello"),
"commit message should start with 'feat: add hello', got: {msg:?}",
);
}
#[test]
fn hooks_run_fail_fast_skips_remaining_on_failure() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-push.hooks"),
"true\nfalse\necho should-not-run\n",
)
.unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-push"])
.current_dir(dir.path())
.assert()
.code(1);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains('\u{2713}'),
"should contain check mark for passing command, got: {combined}"
);
assert!(
combined.contains('\u{2717}'),
"should contain cross mark for failing command, got: {combined}"
);
assert!(
combined.contains("skipped (fail-fast)"),
"should report skipped commands, got: {combined}"
);
assert!(
!combined.contains("should-not-run"),
"skipped command output should not appear, got: {combined}"
);
}
#[test]
fn hooks_run_fail_fast_prefix_overrides_collect_mode() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit.hooks"),
"true\n!false\necho should-not-run\n",
)
.unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.code(1);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("skipped (fail-fast)"),
"should report skipped commands when ! prefix triggers fail-fast, got: {combined}"
);
assert!(
!combined.contains("should-not-run"),
"skipped command output should not appear, got: {combined}"
);
}
#[test]
fn hooks_run_fix_mode_staged_files_passed_as_positional_args() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("hello.txt"), "hello\n").unwrap();
git(dir.path(), &["add", "hello.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit.hooks"),
"~ echo \"staged: $@\"\n",
)
.unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("hello.txt"),
"$@ should contain staged file names, got:\n{combined}"
);
}
#[test]
fn hooks_run_staged_files_passed_to_normal_commands() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("world.txt"), "world\n").unwrap();
git(dir.path(), &["add", "world.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "echo \"files: $@\"\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("world.txt"),
"$@ should contain staged file names for plain commands, got:\n{combined}"
);
}
#[test]
fn hooks_run_fix_mode_pre_commit_restages_formatted_content() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("fmt.txt"), "line1\n").unwrap();
git(dir.path(), &["add", "fmt.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit.hooks"),
"~ for f in \"$@\"; do echo 'formatted' >> \"$f\"; done\n",
)
.unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let staged_content = std::process::Command::new("git")
.args(["show", ":fmt.txt"])
.current_dir(dir.path())
.output()
.unwrap();
let staged_str = String::from_utf8_lossy(&staged_content.stdout);
assert!(
staged_str.contains("formatted"),
"staged content should include formatter output, got: {staged_str}"
);
}
#[test]
fn hooks_run_fix_mode_non_pre_commit_warns_and_treats_as_fail_fast() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("commit-msg.hooks"), "~ true\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "commit-msg"])
.current_dir(dir.path())
.assert()
.success();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
stderr.contains("warning:"),
"should print a warning for ~ in non-pre-commit, got:\n{stderr}"
);
assert!(
stderr.contains("pre-commit"),
"warning should mention pre-commit, got:\n{stderr}"
);
}
#[test]
fn hooks_run_fix_mode_non_pre_commit_failing_command_fails() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("commit-msg.hooks"), "~ false\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "commit-msg"])
.current_dir(dir.path())
.assert()
.code(1);
}
#[test]
fn hooks_run_fix_mode_preserves_staged_deletions() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("to-delete.txt"), "content\n").unwrap();
std::fs::write(dir.path().join("to-keep.txt"), "keep\n").unwrap();
git(dir.path(), &["add", "to-delete.txt", "to-keep.txt"]);
git(dir.path(), &["commit", "-m", "initial"]);
git(dir.path(), &["rm", "to-delete.txt"]);
std::fs::write(dir.path().join("to-keep.txt"), "modified\n").unwrap();
git(dir.path(), &["add", "to-keep.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ true\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let status_output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=D"])
.current_dir(dir.path())
.output()
.unwrap();
let deleted = String::from_utf8_lossy(&status_output.stdout);
assert!(
deleted.contains("to-delete.txt"),
"to-delete.txt should still be staged for deletion after fix-mode hook, got:\n{deleted}"
);
let status_output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=M"])
.current_dir(dir.path())
.output()
.unwrap();
let modified = String::from_utf8_lossy(&status_output.stdout);
assert!(
modified.contains("to-keep.txt"),
"to-keep.txt should still be staged after fix-mode hook, got:\n{modified}"
);
}
#[test]
fn hooks_run_fix_mode_excludes_deleted_files_from_args() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("deleted.txt"), "gone\n").unwrap();
std::fs::write(dir.path().join("kept.txt"), "here\n").unwrap();
git(dir.path(), &["add", "deleted.txt", "kept.txt"]);
git(dir.path(), &["commit", "-m", "initial"]);
git(dir.path(), &["rm", "deleted.txt"]);
std::fs::write(dir.path().join("kept.txt"), "modified\n").unwrap();
git(dir.path(), &["add", "kept.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ echo \"args: $@\"\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("kept.txt"),
"kept.txt should appear in $@, got:\n{combined}"
);
assert!(
!combined.contains("deleted.txt"),
"deleted.txt should not appear in $@, got:\n{combined}"
);
}
#[test]
fn hooks_run_no_stash_dance_without_fix_commands() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("file.txt"), "content\n").unwrap();
git(dir.path(), &["add", "file.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "true\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
!stderr.contains("stash"),
"no stash messages expected for hook without ~ commands, got:\n{stderr}"
);
}
#[test]
fn hooks_run_fix_mode_preserves_staged_deletions_on_failfast() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("to-delete.txt"), "content\n").unwrap();
std::fs::write(dir.path().join("to-keep.txt"), "keep\n").unwrap();
git(dir.path(), &["add", "to-delete.txt", "to-keep.txt"]);
git(dir.path(), &["commit", "-m", "initial"]);
git(dir.path(), &["rm", "to-delete.txt"]);
std::fs::write(dir.path().join("to-keep.txt"), "modified\n").unwrap();
git(dir.path(), &["add", "to-keep.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ false\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.code(1);
let status_output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=D"])
.current_dir(dir.path())
.output()
.unwrap();
let deleted = String::from_utf8_lossy(&status_output.stdout);
assert!(
deleted.contains("to-delete.txt"),
"to-delete.txt should still be staged for deletion after fail-fast, got:\n{deleted}"
);
}
#[test]
fn hooks_run_fix_mode_preserves_renamed_files() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("old.txt"), "hello").unwrap();
git(dir.path(), &["add", "old.txt"]);
git(dir.path(), &["commit", "-m", "initial"]);
git(dir.path(), &["mv", "old.txt", "new.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ true\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let status_output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-status"])
.current_dir(dir.path())
.output()
.unwrap();
let name_status = String::from_utf8_lossy(&status_output.stdout);
assert!(
name_status.contains("new.txt"),
"new.txt should be in the staged files after stash dance, got:\n{name_status}"
);
let staged_output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
.current_dir(dir.path())
.output()
.unwrap();
let staged_names = String::from_utf8_lossy(&staged_output.stdout);
assert!(
!staged_names.contains("old.txt"),
"old.txt should not be in the staged files after rename, got:\n{staged_names}"
);
}
#[test]
fn hooks_run_fix_mode_does_not_stage_formatter_created_files() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("src.txt"), "source\n").unwrap();
git(dir.path(), &["add", "src.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(
hooks_dir.join("pre-commit.hooks"),
"~ sh -c \"echo created > extra.txt\"\n",
)
.unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
assert!(
dir.path().join("extra.txt").exists(),
"extra.txt should exist on disk after formatter ran"
);
let staged_output = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only"])
.current_dir(dir.path())
.output()
.unwrap();
let staged = String::from_utf8_lossy(&staged_output.stdout);
assert!(
!staged.contains("extra.txt"),
"extra.txt should not be staged (formatter side effect), got:\n{staged}"
);
assert!(
staged.contains("src.txt"),
"src.txt should still be staged after hook, got:\n{staged}"
);
}
#[test]
fn hooks_run_fix_mode_preserves_binary_files() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let png_header: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
std::fs::write(dir.path().join("image.bin"), png_header).unwrap();
git(dir.path(), &["add", "image.bin"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ true\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let show_output = std::process::Command::new("git")
.args(["show", ":image.bin"])
.current_dir(dir.path())
.output()
.unwrap();
assert!(
show_output.status.success(),
"git show :image.bin should succeed"
);
assert_eq!(
show_output.stdout, png_header,
"staged binary content should be byte-identical after stash dance"
);
}
#[test]
fn hooks_run_fix_mode_failure_prints_hints() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("file.txt"), "content\n").unwrap();
git(dir.path(), &["add", "file.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ false\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.code(1);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
stderr.contains("hint: to skip this hook:"),
"should print skip-hook hint, got:\n{stderr}"
);
assert!(
stderr.contains("--no-verify"),
"should mention --no-verify, got:\n{stderr}"
);
assert!(
stderr.contains("GIT_STD_SKIP_HOOKS=1"),
"should mention GIT_STD_SKIP_HOOKS, got:\n{stderr}"
);
assert!(
stderr.contains(".githooks/pre-commit.hooks"),
"should reference the hooks file, got:\n{stderr}"
);
}
#[test]
fn hooks_run_fix_mode_skips_restage_of_formatter_deleted_files() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
std::fs::write(dir.path().join("victim.txt"), "original\n").unwrap();
std::fs::write(dir.path().join("survivor.txt"), "keep\n").unwrap();
git(dir.path(), &["add", "victim.txt", "survivor.txt"]);
git(dir.path(), &["commit", "-m", "initial"]);
std::fs::write(dir.path().join("victim.txt"), "modified\n").unwrap();
std::fs::write(dir.path().join("survivor.txt"), "also modified\n").unwrap();
git(dir.path(), &["add", "victim.txt", "survivor.txt"]);
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ rm -f victim.txt\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
stderr.contains("victim.txt") && stderr.contains("deleted by formatter"),
"should warn about formatter-deleted file, got:\n{stderr}"
);
let deletions = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=D"])
.current_dir(dir.path())
.output()
.unwrap();
let deleted = String::from_utf8_lossy(&deletions.stdout);
assert!(
!deleted.contains("victim.txt"),
"victim.txt should not be staged as deletion, got:\n{deleted}"
);
let staged = std::process::Command::new("git")
.args(["diff", "--cached", "--name-only", "--diff-filter=ACMR"])
.current_dir(dir.path())
.output()
.unwrap();
let staged_files = String::from_utf8_lossy(&staged.stdout);
assert!(
staged_files.contains("survivor.txt"),
"survivor.txt should still be staged, got:\n{staged_files}"
);
}
#[test]
fn hooks_run_fix_mode_rejects_staged_submodules() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let sub_source = tempfile::tempdir().unwrap();
git(sub_source.path(), &["init", "--bare"]);
let sub_work = tempfile::tempdir().unwrap();
git(sub_work.path(), &["init"]);
git(sub_work.path(), &["config", "user.name", "Test"]);
git(sub_work.path(), &["config", "user.email", "test@test.com"]);
std::fs::write(sub_work.path().join("readme.txt"), "sub\n").unwrap();
git(sub_work.path(), &["add", "readme.txt"]);
git(sub_work.path(), &["commit", "-m", "init sub"]);
git(
sub_work.path(),
&[
"remote",
"add",
"origin",
sub_source.path().to_str().unwrap(),
],
);
git(sub_work.path(), &["push", "origin", "HEAD"]);
let sub_url = sub_source.path().to_str().unwrap();
let output = std::process::Command::new("git")
.args(["submodule", "add", sub_url, "submod"])
.current_dir(dir.path())
.env("GIT_ALLOW_PROTOCOL", "file")
.output()
.unwrap();
if !output.status.success() {
eprintln!(
"skipping: git submodule add failed: {}",
String::from_utf8_lossy(&output.stderr)
);
return;
}
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "~ true\n").unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.code(1);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
assert!(
stderr.contains("submodule"),
"should mention submodules in error, got:\n{stderr}"
);
}
#[test]
fn hooks_run_from_subdirectory() {
let dir = tempfile::tempdir().unwrap();
init_hooks_repo(dir.path());
let hooks_dir = dir.path().join(".githooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
std::fs::write(hooks_dir.join("pre-commit.hooks"), "echo ok\n").unwrap();
Command::cargo_bin("git-std")
.unwrap()
.args(["init"])
.env("GIT_STD_HOOKS_ENABLE", "pre-commit")
.current_dir(dir.path())
.assert()
.success();
let subdir = dir.path().join("src").join("nested");
std::fs::create_dir_all(&subdir).unwrap();
let assert = Command::cargo_bin("git-std")
.unwrap()
.args(["--color", "never", "hook", "run", "pre-commit"])
.current_dir(&subdir)
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
let stderr = String::from_utf8_lossy(&assert.get_output().stderr);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains('\u{2713}'),
"hooks run from subdirectory should succeed and show check mark, got:\n{combined}"
);
}