use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use klasp_core::GATE_SCHEMA_VERSION;
use tempfile::TempDir;
const FIXTURE_GIT_COMMIT: &str = include_str!("fixtures/claude_commit_hook.json");
const FIXTURE_3X_PASS: &str = include_str!("fixtures/pre_commit/3x-pass.stdout");
const FIXTURE_3X_FAIL: &str = include_str!("fixtures/pre_commit/3x-fail.stdout");
const FIXTURE_4X_PASS: &str = include_str!("fixtures/pre_commit/4x-pass.stdout");
const FIXTURE_4X_FAIL: &str = include_str!("fixtures/pre_commit/4x-fail.stdout");
const FIXTURE_3X_VERSION: &str = include_str!("fixtures/pre_commit/3x-version.stdout");
const FIXTURE_4X_VERSION: &str = include_str!("fixtures/pre_commit/4x-version.stdout");
fn klasp_bin() -> &'static str {
env!("CARGO_BIN_EXE_klasp")
}
fn install_fake_pre_commit(scratch: &TempDir, version_stdout: &str) -> std::path::PathBuf {
let bin_dir = scratch.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("create shim bin dir");
let shim = bin_dir.join("pre-commit");
let body = format!(
r#"#!/usr/bin/env bash
set -u
case "${{1:-}}" in
--version)
cat <<'__VERSION_EOF__'
{version_stdout}__VERSION_EOF__
exit 0
;;
esac
if [ -n "${{FAKE_PRE_COMMIT_STDOUT:-}}" ]; then
cat "$FAKE_PRE_COMMIT_STDOUT"
fi
exit "${{FAKE_PRE_COMMIT_EXIT:-0}}"
"#,
);
std::fs::write(&shim, body).expect("write shim");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shim).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&shim, perms).expect("chmod shim");
}
bin_dir
}
fn spawn_gate(
stdin_payload: &str,
project_dir: &Path,
fake_pre_commit_dir: &Path,
extra_env: &[(&str, &str)],
) -> (Option<i32>, String) {
let path_var = match std::env::var_os("PATH") {
Some(existing) => {
let mut prefix = std::ffi::OsString::from(fake_pre_commit_dir.as_os_str());
prefix.push(":");
prefix.push(existing);
prefix
}
None => std::ffi::OsString::from(fake_pre_commit_dir.as_os_str()),
};
let mut cmd = Command::new(klasp_bin());
cmd.arg("gate")
.env("KLASP_GATE_SCHEMA", GATE_SCHEMA_VERSION.to_string())
.env("CLAUDE_PROJECT_DIR", project_dir)
.env("PATH", &path_var)
.current_dir(project_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in extra_env {
cmd.env(k, v);
}
let mut child = cmd.spawn().expect("spawn klasp binary");
child
.stdin
.as_mut()
.expect("piped stdin")
.write_all(stdin_payload.as_bytes())
.expect("write stdin");
let output = child.wait_with_output().expect("wait for klasp");
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if !stderr.is_empty() {
eprintln!("klasp gate stderr:\n{stderr}");
}
(output.status.code(), stderr)
}
fn write_fixture(scratch: &TempDir, name: &str, body: &str) -> std::path::PathBuf {
let path = scratch.path().join(name);
std::fs::write(&path, body).expect("write fixture");
path
}
fn write_klasp_toml(project_dir: &Path, body: &str) {
std::fs::write(project_dir.join("klasp.toml"), body).expect("write klasp.toml");
}
const PRE_COMMIT_KLASP_TOML: &str = r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "lint"
triggers = [{ on = ["commit"] }]
timeout_secs = 30
[checks.source]
type = "pre_commit"
"#;
#[test]
fn pre_commit_3x_pass_fixture_yields_exit_0() {
let project = TempDir::new().expect("tempdir");
let scratch = TempDir::new().expect("scratch");
let bin_dir = install_fake_pre_commit(&scratch, FIXTURE_3X_VERSION);
let fixture_path = write_fixture(&scratch, "stdout.txt", FIXTURE_3X_PASS);
write_klasp_toml(project.path(), PRE_COMMIT_KLASP_TOML);
let (code, _stderr) = spawn_gate(
FIXTURE_GIT_COMMIT,
project.path(),
&bin_dir,
&[
("FAKE_PRE_COMMIT_STDOUT", fixture_path.to_str().unwrap()),
("FAKE_PRE_COMMIT_EXIT", "0"),
],
);
assert_eq!(
code,
Some(0),
"pre-commit 3.x passing fixture must produce Verdict::Pass → exit 0",
);
}
#[test]
fn pre_commit_3x_fail_fixture_blocks_with_exit_2() {
let project = TempDir::new().expect("tempdir");
let scratch = TempDir::new().expect("scratch");
let bin_dir = install_fake_pre_commit(&scratch, FIXTURE_3X_VERSION);
let fixture_path = write_fixture(&scratch, "stdout.txt", FIXTURE_3X_FAIL);
write_klasp_toml(project.path(), PRE_COMMIT_KLASP_TOML);
let (code, stderr) = spawn_gate(
FIXTURE_GIT_COMMIT,
project.path(),
&bin_dir,
&[
("FAKE_PRE_COMMIT_STDOUT", fixture_path.to_str().unwrap()),
("FAKE_PRE_COMMIT_EXIT", "1"),
],
);
assert_eq!(
code,
Some(2),
"pre-commit 3.x failing fixture must produce Verdict::Fail → exit 2",
);
assert!(
stderr.contains("ruff"),
"expected `ruff` finding in block message, got: {stderr}",
);
assert!(
stderr.contains("ruff-format"),
"expected `ruff-format` finding in block message, got: {stderr}",
);
}
#[test]
fn pre_commit_4x_pass_fixture_yields_exit_0() {
let project = TempDir::new().expect("tempdir");
let scratch = TempDir::new().expect("scratch");
let bin_dir = install_fake_pre_commit(&scratch, FIXTURE_4X_VERSION);
let fixture_path = write_fixture(&scratch, "stdout.txt", FIXTURE_4X_PASS);
write_klasp_toml(project.path(), PRE_COMMIT_KLASP_TOML);
let (code, _stderr) = spawn_gate(
FIXTURE_GIT_COMMIT,
project.path(),
&bin_dir,
&[
("FAKE_PRE_COMMIT_STDOUT", fixture_path.to_str().unwrap()),
("FAKE_PRE_COMMIT_EXIT", "0"),
],
);
assert_eq!(
code,
Some(0),
"pre-commit 4.x passing fixture must produce Verdict::Pass → exit 0",
);
}
#[test]
fn pre_commit_4x_fail_fixture_blocks_with_exit_2() {
let project = TempDir::new().expect("tempdir");
let scratch = TempDir::new().expect("scratch");
let bin_dir = install_fake_pre_commit(&scratch, FIXTURE_4X_VERSION);
let fixture_path = write_fixture(&scratch, "stdout.txt", FIXTURE_4X_FAIL);
write_klasp_toml(project.path(), PRE_COMMIT_KLASP_TOML);
let (code, stderr) = spawn_gate(
FIXTURE_GIT_COMMIT,
project.path(),
&bin_dir,
&[
("FAKE_PRE_COMMIT_STDOUT", fixture_path.to_str().unwrap()),
("FAKE_PRE_COMMIT_EXIT", "1"),
],
);
assert_eq!(
code,
Some(2),
"pre-commit 4.x failing fixture must produce Verdict::Fail → exit 2",
);
assert!(
stderr.contains("ruff"),
"expected `ruff` finding in block message, got: {stderr}",
);
assert!(
stderr.contains("prettier"),
"expected `prettier` finding in block message, got: {stderr}",
);
}
#[test]
fn pre_commit_recipe_with_custom_hook_stage_and_config_path() {
let project = TempDir::new().expect("tempdir");
let scratch = TempDir::new().expect("scratch");
let bin_dir = scratch.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("create shim bin");
let shim = bin_dir.join("pre-commit");
let argv_log = scratch.path().join("argv.log");
let body = format!(
r#"#!/usr/bin/env bash
case "${{1:-}}" in
--version) echo "pre-commit 3.8.0"; exit 0 ;;
esac
printf '%s\n' "$@" > "{argv_log}"
exit 0
"#,
argv_log = argv_log.display(),
);
std::fs::write(&shim, body).expect("write shim");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shim).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&shim, perms).expect("chmod shim");
}
write_klasp_toml(
project.path(),
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "lint"
triggers = [{ on = ["commit"] }]
timeout_secs = 30
[checks.source]
type = "pre_commit"
hook_stage = "pre-push"
config_path = "tools/pre-commit.yaml"
"#,
);
let (code, _stderr) = spawn_gate(FIXTURE_GIT_COMMIT, project.path(), &bin_dir, &[]);
assert_eq!(code, Some(0), "shim returns 0 → gate must exit 0");
let argv = std::fs::read_to_string(&argv_log).expect("read argv log");
assert!(
argv.contains("--hook-stage\npre-push"),
"expected --hook-stage pre-push in argv, got:\n{argv}",
);
assert!(
argv.contains("-c\ntools/pre-commit.yaml"),
"expected -c tools/pre-commit.yaml in argv, got:\n{argv}",
);
assert!(
!argv.contains("--from-ref"),
"commit trigger must not pass --from-ref to pre-commit, got:\n{argv}",
);
assert!(
!argv.contains("--to-ref"),
"commit trigger must not pass --to-ref to pre-commit, got:\n{argv}",
);
}
#[test]
fn pre_commit_push_trigger_includes_ref_range_in_argv() {
use std::io::Write as _;
let project = TempDir::new().expect("tempdir");
let scratch = TempDir::new().expect("scratch");
let bin_dir = scratch.path().join("bin");
std::fs::create_dir_all(&bin_dir).expect("create shim bin");
let shim = bin_dir.join("pre-commit");
let argv_log = scratch.path().join("argv.log");
let body = format!(
r#"#!/usr/bin/env bash
case "${{1:-}}" in
--version) echo "pre-commit 3.8.0"; exit 0 ;;
esac
printf '%s\n' "$@" > "{argv_log}"
exit 0
"#,
argv_log = argv_log.display(),
);
std::fs::write(&shim, body).expect("write shim");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&shim).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&shim, perms).expect("chmod shim");
}
write_klasp_toml(
project.path(),
r#"
version = 1
[gate]
agents = ["claude_code"]
policy = "any_fail"
[[checks]]
name = "lint"
triggers = [{ on = ["push"] }]
timeout_secs = 30
[checks.source]
type = "pre_commit"
"#,
);
let push_payload = r#"{
"hook_event_name": "PreToolUse",
"tool_name": "Bash",
"tool_input": {
"command": "git push origin main",
"description": "Push the branch."
},
"session_id": "klasp-fixture-push",
"transcript_path": "/tmp/klasp-fixture/transcript.jsonl",
"cwd": "/tmp/klasp-fixture/repo"
}"#;
let path_var = match std::env::var_os("PATH") {
Some(existing) => {
let mut prefix = std::ffi::OsString::from(bin_dir.as_os_str());
prefix.push(":");
prefix.push(existing);
prefix
}
None => std::ffi::OsString::from(bin_dir.as_os_str()),
};
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_klasp"));
cmd.arg("gate")
.env(
"KLASP_GATE_SCHEMA",
klasp_core::GATE_SCHEMA_VERSION.to_string(),
)
.env("CLAUDE_PROJECT_DIR", project.path())
.env("PATH", &path_var)
.current_dir(project.path())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().expect("spawn klasp binary");
child
.stdin
.as_mut()
.expect("piped stdin")
.write_all(push_payload.as_bytes())
.expect("write stdin");
let output = child.wait_with_output().expect("wait for klasp");
assert_eq!(
output.status.code(),
Some(0),
"shim returns 0 → gate must exit 0"
);
let argv = std::fs::read_to_string(&argv_log).expect("read argv log");
assert!(
argv.contains("--from-ref"),
"push trigger must pass --from-ref to pre-commit, got:\n{argv}",
);
assert!(
argv.contains("--to-ref\nHEAD"),
"push trigger must pass --to-ref HEAD, got:\n{argv}",
);
}