use crate::common::{
TestRepo, make_snapshot_cmd, repo, resolve_git_common_dir, setup_snapshot_settings,
wait_for_file, wait_for_file_content, wait_for_file_count,
};
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::thread;
use std::time::Duration;
const SLEEP_FOR_ABSENCE_CHECK: Duration = Duration::from_millis(500);
fn snapshot_switch(test_name: &str, repo: &TestRepo, args: &[&str]) {
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "switch", args, None);
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
fn test_user_post_create_hook_executes(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
log = "echo 'USER_POST_CREATE_RAN' > user_hook_marker.txt"
"#,
);
snapshot_switch("user_post_create_executes", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("user_hook_marker.txt");
assert!(
marker_file.exists(),
"User post-create hook should have created marker file"
);
let contents = fs::read_to_string(&marker_file).unwrap();
assert!(
contents.contains("USER_POST_CREATE_RAN"),
"Marker file should contain expected content"
);
}
#[rstest]
fn test_user_hooks_run_before_project_hooks(repo: TestRepo) {
repo.write_project_config(r#"post-create = "echo 'PROJECT_HOOK' >> hook_order.txt""#);
repo.commit("Add project config");
repo.write_test_config(
r#"[post-create]
log = "echo 'USER_HOOK' >> hook_order.txt"
"#,
);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'PROJECT_HOOK' >> hook_order.txt"]
"#,
);
snapshot_switch("user_hooks_before_project", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let order_file = worktree_path.join("hook_order.txt");
assert!(order_file.exists());
let contents = fs::read_to_string(&order_file).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "USER_HOOK", "User hook should run first");
assert_eq!(lines[1], "PROJECT_HOOK", "Project hook should run second");
}
#[rstest]
fn test_user_hooks_no_approval_required(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
setup = "echo 'NO_APPROVAL_NEEDED' > no_approval.txt"
"#,
);
snapshot_switch("user_hooks_no_approval", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("no_approval.txt");
assert!(
marker_file.exists(),
"User hook should run without pre-approval"
);
}
#[rstest]
fn test_no_hooks_flag_skips_all_hooks(repo: TestRepo) {
repo.write_project_config(r#"post-create = "echo 'PROJECT_HOOK' > project_marker.txt""#);
repo.commit("Add project config");
repo.write_test_config(
r#"[post-create]
log = "echo 'USER_HOOK' > user_marker.txt"
"#,
);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'PROJECT_HOOK' > project_marker.txt"]
"#,
);
snapshot_switch(
"no_hooks_skips_all_hooks",
&repo,
&["--create", "feature", "--no-hooks"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let user_marker = worktree_path.join("user_marker.txt");
assert!(
!user_marker.exists(),
"User hook should be skipped with --no-hooks"
);
let project_marker = worktree_path.join("project_marker.txt");
assert!(
!project_marker.exists(),
"Project hook should also be skipped with --no-hooks"
);
}
#[rstest]
fn test_user_post_create_hook_failure(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
failing = "exit 1"
"#,
);
snapshot_switch("user_post_create_failure", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
assert!(
worktree_path.exists(),
"Worktree should exist — it was created before pre-start ran"
);
}
#[rstest]
fn test_user_post_start_hook_executes(repo: TestRepo) {
repo.write_test_config(
r#"[post-start]
bg = "echo 'USER_POST_START_RAN' > user_bg_marker.txt"
"#,
);
snapshot_switch("user_post_start_executes", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("user_bg_marker.txt");
wait_for_file_content(&marker_file);
let contents = fs::read_to_string(&marker_file).unwrap();
assert!(
contents.contains("USER_POST_START_RAN"),
"User post-start hook should have run in background"
);
}
#[rstest]
fn test_user_post_start_skipped_with_no_hooks(repo: TestRepo) {
repo.write_test_config(
r#"[post-start]
bg = "echo 'USER_BG' > user_bg_marker.txt"
"#,
);
snapshot_switch(
"user_post_start_skipped_no_hooks",
&repo,
&["--create", "feature", "--no-hooks"],
);
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("user_bg_marker.txt");
assert!(
!marker_file.exists(),
"User post-start hook should be skipped with --no-hooks"
);
}
fn snapshot_merge(test_name: &str, repo: &TestRepo, args: &[&str], cwd: Option<&std::path::Path>) {
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "merge", args, cwd);
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
fn test_user_pre_merge_hook_executes(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
repo.write_test_config(
r#"[pre-merge]
check = "echo 'USER_PRE_MERGE_RAN' > user_premerge.txt"
"#,
);
snapshot_merge(
"user_pre_merge_executes",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
let marker_file = feature_wt.join("user_premerge.txt");
assert!(marker_file.exists(), "User pre-merge hook should have run");
}
#[rstest]
fn test_user_pre_merge_hook_failure_blocks_merge(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
repo.write_test_config(
r#"[pre-merge]
check = "exit 1"
"#,
);
snapshot_merge(
"user_pre_merge_failure",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
}
#[rstest]
fn test_user_pre_merge_skipped_with_no_hooks(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
repo.write_test_config(
r#"[pre-merge]
check = "echo 'USER_PRE_MERGE' > user_premerge_marker.txt"
"#,
);
snapshot_merge(
"user_pre_merge_skipped_no_hooks",
&repo,
&["main", "--yes", "--no-remove", "--no-hooks"],
Some(&feature_wt),
);
let marker_file = feature_wt.join("user_premerge_marker.txt");
assert!(
!marker_file.exists(),
"User pre-merge hook should be skipped with --no-hooks"
);
}
#[rstest]
#[cfg(unix)]
fn test_pre_merge_hook_receives_sigint(repo: TestRepo) {
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::io::Read;
use std::os::unix::process::CommandExt;
use std::process::Stdio;
repo.commit("Initial commit");
repo.write_project_config(
r#"[pre-merge]
long = "sh -c 'echo start >> hook.log; sleep 30; echo done >> hook.log'"
"#,
);
repo.commit("Add pre-merge hook");
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.args(["hook", "pre-merge", "--yes"]);
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
cmd.process_group(0); let mut child = cmd.spawn().expect("failed to spawn wt hook pre-merge");
let hook_log = repo.root_path().join("hook.log");
wait_for_file_content(&hook_log);
let wt_pgid = Pid::from_raw(child.id() as i32);
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGINT).expect("failed to send SIGINT to pgrp");
let status = child.wait().expect("failed to wait for wt");
use std::os::unix::process::ExitStatusExt;
assert!(
status.signal() == Some(2) || status.code() == Some(130),
"wt should be killed by SIGINT (signal 2) or exit 130, got: {status:?}"
);
thread::sleep(Duration::from_millis(500));
let mut contents = String::new();
std::fs::File::open(&hook_log)
.unwrap()
.read_to_string(&mut contents)
.unwrap();
assert!(
contents.trim() == "start",
"hook should not have reached 'done'; got: {contents:?}"
);
}
#[rstest]
#[cfg(unix)]
fn test_pre_merge_hook_receives_sigterm(repo: TestRepo) {
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
use std::io::Read;
use std::os::unix::process::CommandExt;
use std::process::Stdio;
repo.commit("Initial commit");
repo.write_project_config(
r#"[pre-merge]
long = "sh -c 'echo start >> hook.log; sleep 30; echo done >> hook.log'"
"#,
);
repo.commit("Add pre-merge hook");
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.args(["hook", "pre-merge", "--yes"]);
cmd.stdout(Stdio::null());
cmd.stderr(Stdio::null());
cmd.process_group(0); let mut child = cmd.spawn().expect("failed to spawn wt hook pre-merge");
let hook_log = repo.root_path().join("hook.log");
wait_for_file_content(&hook_log);
let wt_pgid = Pid::from_raw(child.id() as i32);
kill(Pid::from_raw(-wt_pgid.as_raw()), Signal::SIGTERM)
.expect("failed to send SIGTERM to pgrp");
let status = child.wait().expect("failed to wait for wt");
use std::os::unix::process::ExitStatusExt;
assert!(
status.signal() == Some(15) || status.code() == Some(143),
"wt should be killed by SIGTERM (signal 15) or exit 143, got: {status:?}"
);
thread::sleep(Duration::from_millis(500));
let mut contents = String::new();
std::fs::File::open(&hook_log)
.unwrap()
.read_to_string(&mut contents)
.unwrap();
assert!(
contents.trim() == "start",
"hook should not have reached 'done'; got: {contents:?}"
);
}
#[rstest]
fn test_user_post_merge_hook_executes(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
repo.write_test_config(
r#"[post-merge]
notify = "echo 'USER_POST_MERGE_RAN' > user_postmerge.txt"
"#,
);
snapshot_merge(
"user_post_merge_executes",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
let main_worktree = repo.root_path();
let marker_file = main_worktree.join("user_postmerge.txt");
wait_for_file(&marker_file);
}
fn snapshot_remove(test_name: &str, repo: &TestRepo, args: &[&str], cwd: Option<&std::path::Path>) {
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "remove", args, cwd);
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
fn test_user_pre_remove_hook_executes(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[pre-remove]
cleanup = "echo 'USER_PRE_REMOVE_RAN' > ../user_preremove_marker.txt"
"#,
);
snapshot_remove(
"user_pre_remove_executes",
&repo,
&["feature", "--force-delete"],
Some(repo.root_path()),
);
let marker_file = repo
.root_path()
.parent()
.unwrap()
.join("user_preremove_marker.txt");
assert!(marker_file.exists(), "User pre-remove hook should have run");
}
#[rstest]
fn test_user_pre_remove_failure_blocks_removal(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[pre-remove]
block = "exit 1"
"#,
);
snapshot_remove(
"user_pre_remove_failure",
&repo,
&["feature", "--force-delete"],
Some(repo.root_path()),
);
assert!(
feature_wt.exists(),
"Worktree should not be removed when pre-remove hook fails"
);
}
#[rstest]
fn test_user_pre_remove_skipped_with_no_hooks(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[pre-remove]
block = "exit 1"
"#,
);
snapshot_remove(
"user_pre_remove_skipped_no_hooks",
&repo,
&["feature", "--force-delete", "--no-hooks"],
Some(repo.root_path()),
);
let timeout = Duration::from_secs(5);
let poll_interval = Duration::from_millis(50);
let start = std::time::Instant::now();
while feature_wt.exists() && start.elapsed() < timeout {
thread::sleep(poll_interval);
}
assert!(
!feature_wt.exists(),
"Worktree should be removed when --no-hooks skips failing hook"
);
}
#[rstest]
fn test_user_post_remove_hook_executes(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[post-remove]
cleanup = "echo 'USER_POST_REMOVE_RAN' > ../user_postremove_marker.txt"
"#,
);
snapshot_remove(
"user_post_remove_executes",
&repo,
&["feature", "--force-delete"],
Some(repo.root_path()),
);
let marker_file = repo
.root_path()
.parent()
.unwrap()
.join("user_postremove_marker.txt");
crate::common::wait_for_file(&marker_file);
assert!(
marker_file.exists(),
"User post-remove hook should have run"
);
}
#[rstest]
fn test_post_remove_hooks_run_at_primary_worktree(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature");
let other_wt = repo.add_worktree("other");
repo.write_test_config(
r#"[post-remove]
cleanup = "echo done"
"#,
);
snapshot_remove(
"post_remove_runs_at_primary",
&repo,
&["feature", "--force-delete"],
Some(&other_wt),
);
}
#[rstest]
fn test_user_post_remove_template_vars_reference_removed_worktree(mut repo: TestRepo) {
let feature_wt_path =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
let feature_commit = repo
.git_command()
.args(["rev-parse", "HEAD"])
.current_dir(&feature_wt_path)
.run()
.unwrap();
let feature_commit = String::from_utf8_lossy(&feature_commit.stdout);
let feature_commit = feature_commit.trim();
let feature_short_commit = &feature_commit[..7];
repo.write_test_config(
r#"[post-remove]
capture = "echo 'branch={{ branch }} worktree_path={{ worktree_path }} worktree_name={{ worktree_name }} commit={{ commit }} short_commit={{ short_commit }}' > ../postremove_vars.txt"
"#,
);
repo.wt_command()
.args(["remove", "feature", "--force-delete", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
let vars_file = repo
.root_path()
.parent()
.unwrap()
.join("postremove_vars.txt");
crate::common::wait_for_file_content(&vars_file);
let content = std::fs::read_to_string(&vars_file).unwrap();
assert!(
content.contains("branch=feature"),
"branch should be the removed branch 'feature', got: {content}"
);
let feature_wt_name = feature_wt_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
content.contains(&format!("/{feature_wt_name} "))
|| content.contains(&format!("\\{feature_wt_name} ")),
"worktree_path should end with the removed worktree's name '{feature_wt_name}', got: {content}"
);
assert!(
content.contains(&format!("worktree_name={feature_wt_name}")),
"worktree_name should be the removed worktree's name '{feature_wt_name}', got: {content}"
);
assert!(
content.contains(&format!("commit={feature_commit}")),
"commit should be the removed worktree's commit '{feature_commit}', got: {content}"
);
assert!(
content.contains(&format!("short_commit={feature_short_commit}")),
"short_commit should be '{feature_short_commit}', got: {content}"
);
}
#[rstest]
fn test_user_post_remove_skipped_with_no_hooks(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[post-remove]
marker = "echo 'SHOULD_NOT_RUN' > ../no_hooks_postremove.txt"
"#,
);
snapshot_remove(
"user_post_remove_no_hooks",
&repo,
&["feature", "--force-delete", "--no-hooks"],
Some(repo.root_path()),
);
let timeout = Duration::from_secs(5);
let poll_interval = Duration::from_millis(50);
let start = std::time::Instant::now();
while feature_wt.exists() && start.elapsed() < timeout {
thread::sleep(poll_interval);
}
assert!(
!feature_wt.exists(),
"Worktree should be removed with --no-hooks"
);
let marker_file = repo
.root_path()
.parent()
.unwrap()
.join("no_hooks_postremove.txt");
thread::sleep(Duration::from_millis(500)); assert!(
!marker_file.exists(),
"Post-remove hook should be skipped when --no-hooks is used"
);
}
#[rstest]
fn test_user_post_remove_hook_runs_during_merge(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
repo.write_test_config(
r#"[post-remove]
cleanup = "echo 'POST_REMOVE_DURING_MERGE' > ../merge_postremove_marker.txt"
"#,
);
repo.wt_command()
.args(["merge", "main", "--yes"])
.current_dir(&feature_wt)
.output()
.unwrap();
let marker_file = repo
.root_path()
.parent()
.unwrap()
.join("merge_postremove_marker.txt");
crate::common::wait_for_file_content(&marker_file);
let contents = fs::read_to_string(&marker_file).unwrap();
assert!(
contents.contains("POST_REMOVE_DURING_MERGE"),
"Post-remove hook should run during wt merge with expected content"
);
}
#[rstest]
fn test_combined_post_remove_and_post_switch_hooks(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[post-remove]
cleanup = "echo removed"
[post-switch]
notify = "echo switched"
"#,
);
snapshot_remove(
"combined_post_remove_and_post_switch",
&repo,
&["feature", "--force-delete"],
Some(&feature_wt),
);
}
#[rstest]
fn test_standalone_hook_post_remove_invalid_template(repo: TestRepo) {
repo.write_project_config(r#"post-remove = "echo {{ invalid""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-remove", "--yes"]);
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"wt hook post-remove should fail with invalid template"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("syntax error"),
"Error should mention template expansion failure, got: {stderr}"
);
}
#[rstest]
fn test_standalone_hook_post_remove_name_filter_no_match(repo: TestRepo) {
repo.write_project_config(
r#"[post-remove]
cleanup = "echo cleanup"
"#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-remove", "nonexistent", "--yes"]);
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"wt hook post-remove should fail when name filter doesn't match"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No hook named") || stderr.contains("nonexistent"),
"Error should mention the unmatched filter, got: {stderr}"
);
}
#[rstest]
fn test_user_pre_commit_hook_executes(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("uncommitted.txt"), "uncommitted content").unwrap();
repo.write_test_config(
r#"[pre-commit]
lint = "echo 'USER_PRE_COMMIT_RAN' > user_precommit.txt"
"#,
);
snapshot_merge(
"user_pre_commit_executes",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
let marker_file = feature_wt.join("user_precommit.txt");
assert!(marker_file.exists(), "User pre-commit hook should have run");
}
#[rstest]
fn test_user_pre_commit_failure_blocks_commit(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("uncommitted.txt"), "uncommitted content").unwrap();
repo.write_test_config(
r#"[pre-commit]
lint = "exit 1"
"#,
);
snapshot_merge(
"user_pre_commit_failure",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
}
fn snapshot_step_commit(
test_name: &str,
repo: &TestRepo,
args: &[&str],
cwd: Option<&std::path::Path>,
) {
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "step", &[], cwd);
cmd.arg("commit");
cmd.args(args);
cmd.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'feat: test commit'",
);
assert_cmd_snapshot!(test_name, cmd);
});
}
#[rstest]
fn test_user_post_commit_hook_executes(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("new_file.txt"), "content").unwrap();
repo.write_test_config(
r#"[post-commit]
notify = "echo 'USER_POST_COMMIT_RAN' > user_postcommit.txt"
"#,
);
snapshot_step_commit("user_post_commit_executes", &repo, &[], Some(&feature_wt));
let marker_file = feature_wt.join("user_postcommit.txt");
wait_for_file_content(&marker_file);
let contents = fs::read_to_string(&marker_file).unwrap();
assert!(
contents.contains("USER_POST_COMMIT_RAN"),
"User post-commit hook should have run, got: {contents}"
);
}
#[rstest]
fn test_user_post_commit_skipped_with_no_hooks(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("new_file.txt"), "content").unwrap();
repo.write_test_config(
r#"[post-commit]
notify = "echo 'USER_POST_COMMIT_RAN' > user_postcommit.txt"
"#,
);
snapshot_step_commit(
"user_post_commit_skipped_no_hooks",
&repo,
&["--no-hooks"],
Some(&feature_wt),
);
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
let marker_file = feature_wt.join("user_postcommit.txt");
assert!(
!marker_file.exists(),
"User post-commit hook should be skipped with --no-hooks"
);
}
#[rstest]
fn test_user_post_commit_failure_does_not_block_commit(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("new_file.txt"), "content").unwrap();
repo.write_test_config(
r#"[post-commit]
failing = "exit 1"
"#,
);
snapshot_step_commit("user_post_commit_failure", &repo, &[], Some(&feature_wt));
let output = repo
.git_command()
.current_dir(&feature_wt)
.args(["log", "--oneline", "-1"])
.run()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("feat: test commit"),
"Commit should have succeeded despite post-commit hook failure, got: {stdout}"
);
}
#[rstest]
fn test_user_hook_template_variables(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
vars = "echo 'repo={{ repo }} branch={{ branch }}' > template_vars.txt"
"#,
);
snapshot_switch("user_hook_template_vars", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let vars_file = worktree_path.join("template_vars.txt");
assert!(vars_file.exists());
let contents = fs::read_to_string(&vars_file).unwrap();
assert!(
contents.contains("repo=repo"),
"Should have expanded repo variable: {}",
contents
);
assert!(
contents.contains("branch=feature"),
"Should have expanded branch variable: {}",
contents
);
}
#[rstest]
fn test_hook_template_variables_from_subdirectory(repo: TestRepo) {
repo.write_project_config(
r#"pre-merge = "echo '{{ worktree_path }}' > wt_path.txt && echo '{{ worktree_name }}' > wt_name.txt && pwd > hook_cwd.txt""#,
);
repo.commit("Add pre-merge hook");
let subdir = repo.root_path().join("src").join("components");
fs::create_dir_all(&subdir).unwrap();
let output = repo
.wt_command()
.args(["hook", "pre-merge", "--yes"])
.current_dir(&subdir) .output()
.expect("Failed to run wt hook pre-merge");
assert!(
output.status.success(),
"wt hook pre-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let wt_path = fs::read_to_string(repo.root_path().join("wt_path.txt"))
.expect("wt_path.txt should exist (hook should run from worktree root, not subdirectory)");
let wt_path = wt_path.trim();
assert_ne!(wt_path, ".", "worktree_path should not be relative '.'");
assert!(
wt_path.ends_with("repo"),
"worktree_path should end with repo dir name, got: {wt_path}"
);
let wt_name =
fs::read_to_string(repo.root_path().join("wt_name.txt")).expect("wt_name.txt should exist");
assert_eq!(
wt_name.trim(),
"repo",
"worktree_name should be the directory name, not 'unknown'"
);
let hook_cwd = fs::read_to_string(repo.root_path().join("hook_cwd.txt"))
.expect("hook_cwd.txt should exist");
let hook_cwd = hook_cwd.trim();
assert!(
!hook_cwd.contains("src/components"),
"Hook should run from worktree root, not subdirectory. CWD was: {hook_cwd}"
);
assert!(
hook_cwd.ends_with("repo"),
"Hook CWD should be worktree root, got: {hook_cwd}"
);
}
#[rstest]
fn test_user_and_project_unnamed_post_start(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo 'PROJECT_POST_START' > project_bg.txt""#);
repo.commit("Add project config");
repo.write_test_config(
r#"post-start = "echo 'USER_POST_START' > user_bg.txt"
"#,
);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"]
"#,
);
snapshot_switch(
"user_and_project_unnamed_post_start",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
wait_for_file(&worktree_path.join("user_bg.txt"));
wait_for_file(&worktree_path.join("project_bg.txt"));
assert!(
worktree_path.join("user_bg.txt").exists(),
"User post-start should have run"
);
assert!(
worktree_path.join("project_bg.txt").exists(),
"Project post-start should have run"
);
}
#[rstest]
fn test_user_and_project_post_start_both_run(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo 'PROJECT_POST_START' > project_bg.txt""#);
repo.commit("Add project config");
repo.write_test_config(
r#"[post-start]
bg = "echo 'USER_POST_START' > user_bg.txt"
"#,
);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"]
"#,
);
snapshot_switch(
"user_and_project_post_start",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
wait_for_file(&worktree_path.join("user_bg.txt"));
wait_for_file(&worktree_path.join("project_bg.txt"));
assert!(
worktree_path.join("user_bg.txt").exists(),
"User post-start should have run"
);
assert!(
worktree_path.join("project_bg.txt").exists(),
"Project post-start should have run"
);
}
#[rstest]
fn test_standalone_hook_post_create(repo: TestRepo) {
repo.write_project_config(r#"post-create = "echo 'STANDALONE_POST_CREATE' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-create", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-create should succeed"
);
let marker = repo.root_path().join("hook_ran.txt");
crate::common::wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("STANDALONE_POST_CREATE"));
}
#[rstest]
fn test_standalone_hook_pre_start_fails_on_failure(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "exit 1""#);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes"])
.output()
.unwrap();
assert!(
!output.status.success(),
"wt hook pre-start should exit non-zero when the hook fails (fail-fast, like all pre-* hooks)"
);
}
#[rstest]
fn test_standalone_hook_post_start(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo 'STANDALONE_POST_START' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt hook post-start should succeed");
let marker = repo.root_path().join("hook_ran.txt");
wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("STANDALONE_POST_START"));
}
#[rstest]
fn test_standalone_hook_post_start_foreground(repo: TestRepo) {
repo.write_project_config(
r#"post-start = "echo 'FOREGROUND_POST_START' && echo 'marker' > hook_ran.txt""#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes", "--foreground"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-start --foreground should succeed"
);
let marker = repo.root_path().join("hook_ran.txt");
assert!(
marker.exists(),
"hook should have completed synchronously with --foreground"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("FOREGROUND_POST_START"),
"hook stdout should appear in command output with --foreground, got: {stderr}"
);
}
#[rstest]
fn test_standalone_hook_pre_commit(repo: TestRepo) {
repo.write_project_config(r#"pre-commit = "echo 'STANDALONE_PRE_COMMIT' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "pre-commit", "--yes"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt hook pre-commit should succeed");
let marker = repo.root_path().join("hook_ran.txt");
assert!(marker.exists(), "pre-commit hook should have run");
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("STANDALONE_PRE_COMMIT"));
}
#[rstest]
fn test_standalone_hook_post_merge(repo: TestRepo) {
repo.write_project_config(r#"post-merge = "echo 'STANDALONE_POST_MERGE' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-merge", "--yes"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt hook post-merge should succeed");
let marker = repo.root_path().join("hook_ran.txt");
crate::common::wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("STANDALONE_POST_MERGE"));
}
#[rstest]
fn test_standalone_hook_pre_remove(repo: TestRepo) {
repo.write_project_config(r#"pre-remove = "echo 'STANDALONE_PRE_REMOVE' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "pre-remove", "--yes"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt hook pre-remove should succeed");
let marker = repo.root_path().join("hook_ran.txt");
assert!(marker.exists(), "pre-remove hook should have run");
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("STANDALONE_PRE_REMOVE"));
}
#[rstest]
fn test_standalone_hook_post_remove(repo: TestRepo) {
repo.write_project_config(r#"post-remove = "echo 'STANDALONE_POST_REMOVE' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-remove", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-remove should succeed (spawns in background)"
);
let marker = repo.root_path().join("hook_ran.txt");
crate::common::wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("STANDALONE_POST_REMOVE"));
}
#[rstest]
fn test_standalone_hook_post_remove_foreground(repo: TestRepo) {
repo.write_project_config(r#"post-remove = "echo 'FOREGROUND_POST_REMOVE' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-remove", "--yes", "--foreground"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-remove --foreground should succeed"
);
let marker = repo.root_path().join("hook_ran.txt");
assert!(marker.exists(), "post-remove hook should have run");
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("FOREGROUND_POST_REMOVE"));
}
#[rstest]
fn test_standalone_hook_no_hooks_configured(repo: TestRepo) {
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "pre-start", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook should exit 0 when no hooks configured, got: {}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("No pre-start hooks configured"),
"stderr should warn about missing hooks, got: {stderr}"
);
}
#[rstest]
fn test_hook_dry_run_shows_expanded_command(repo: TestRepo) {
repo.write_project_config(r#"pre-merge = "echo branch={{ branch }}""#);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"hook",
&["pre-merge", "--dry-run"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_hook_dry_run_does_not_execute(repo: TestRepo) {
repo.write_project_config(r#"post-create = "echo 'SHOULD_NOT_RUN' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-create", "--dry-run"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "dry-run should succeed");
let marker = repo.root_path().join("hook_ran.txt");
assert!(
!marker.exists(),
"dry-run should not execute the hook command"
);
}
#[rstest]
fn test_hook_dry_run_named_hooks(repo: TestRepo) {
repo.write_project_config(
r#"[pre-merge]
lint = "pre-commit run --all-files"
test = "cargo test"
"#,
);
let settings = setup_snapshot_settings(&repo);
let _guard = settings.bind_to_scope();
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"hook",
&["pre-merge", "--dry-run"],
Some(repo.root_path()),
));
}
#[rstest]
fn test_concurrent_hook_single_failure(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo HOOK_OUTPUT_MARKER; exit 1""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-start should succeed (spawns in background)"
);
let log_dir = resolve_git_common_dir(repo.root_path()).join("wt/logs");
wait_for_file_count(&log_dir, "log", 2);
let post_start_dir = log_dir
.join(worktrunk::path::sanitize_for_filename("main"))
.join("project")
.join("post-start");
let cmd_log = fs::read_dir(&post_start_dir)
.unwrap_or_else(|e| panic!("reading {post_start_dir:?}: {e}"))
.filter_map(|e| e.ok())
.find(|e| e.file_name().to_string_lossy().contains("cmd-0"))
.expect("Should have a cmd-0 log file");
wait_for_file_content(&cmd_log.path());
let log_content = fs::read_to_string(cmd_log.path()).unwrap();
assert!(
log_content.contains("HOOK_OUTPUT_MARKER"),
"Log should contain hook output, got: {log_content}"
);
}
#[rstest]
fn test_concurrent_hook_multiple_failures(repo: TestRepo) {
repo.write_project_config(
r#"[post-start]
first = "echo FIRST_OUTPUT"
second = "echo SECOND_OUTPUT"
"#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-start should succeed (spawns in background)"
);
let log_dir = resolve_git_common_dir(repo.root_path()).join("wt/logs");
wait_for_file_count(&log_dir, "log", 3);
let post_start_dir = log_dir
.join(worktrunk::path::sanitize_for_filename("main"))
.join("project")
.join("post-start");
let log_files: Vec<_> = fs::read_dir(&post_start_dir)
.unwrap_or_else(|e| panic!("reading {post_start_dir:?}: {e}"))
.filter_map(|e| e.ok())
.collect();
for (task, expected) in [("first", "FIRST_OUTPUT"), ("second", "SECOND_OUTPUT")] {
let log_file = log_files
.iter()
.find(|e| e.file_name().to_string_lossy().starts_with(task))
.unwrap_or_else(|| panic!("should have log file for {task}"));
wait_for_file_content(&log_file.path());
let content = fs::read_to_string(log_file.path()).unwrap();
assert!(
content.contains(expected),
"Log for {task} should contain {expected}, got: {content}"
);
}
}
#[rstest]
fn test_concurrent_hook_user_and_project(repo: TestRepo) {
repo.write_test_config(
r#"[post-start]
user = "echo 'USER_HOOK' > user_hook_ran.txt"
"#,
);
repo.write_project_config(r#"post-start = "echo 'PROJECT_HOOK' > project_hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-start should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let user_marker = repo.root_path().join("user_hook_ran.txt");
let project_marker = repo.root_path().join("project_hook_ran.txt");
wait_for_file_content(&user_marker);
wait_for_file_content(&project_marker);
let user_content = fs::read_to_string(&user_marker).unwrap();
let project_content = fs::read_to_string(&project_marker).unwrap();
assert!(user_content.contains("USER_HOOK"));
assert!(project_content.contains("PROJECT_HOOK"));
}
#[rstest]
fn test_concurrent_hook_post_switch(repo: TestRepo) {
repo.write_project_config(r#"post-switch = "echo 'POST_SWITCH' > hook_ran.txt""#);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-switch", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-switch should succeed"
);
let marker = repo.root_path().join("hook_ran.txt");
wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert!(content.contains("POST_SWITCH"));
}
#[rstest]
fn test_concurrent_hook_with_name_filter(repo: TestRepo) {
repo.write_project_config(
r#"[post-start]
first = "echo 'FIRST' > first.txt"
second = "echo 'SECOND' > second.txt"
"#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes", "first"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook post-start --name first should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let first_marker = repo.root_path().join("first.txt");
let second_marker = repo.root_path().join("second.txt");
wait_for_file_content(&first_marker);
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(!second_marker.exists(), "second hook should NOT have run");
}
#[rstest]
fn test_concurrent_hook_invalid_name_filter(repo: TestRepo) {
repo.write_project_config(
r#"[post-start]
first = "echo 'FIRST'"
"#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes", "nonexistent"]);
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"wt hook post-start --name nonexistent should fail"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("nonexistent") && stderr.contains("No command named"),
"Error should mention command not found, got: {stderr}"
);
assert!(
stderr.contains("project:first"),
"Error should list available commands, got: {stderr}"
);
}
#[rstest]
fn test_hook_multiple_name_filters(repo: TestRepo) {
repo.write_project_config(
r#"[pre-merge]
first = "echo FIRST"
second = "echo SECOND"
third = "echo THIRD"
"#,
);
assert_cmd_snapshot!(
"hook_multiple_name_filters",
make_snapshot_cmd(
&repo,
"hook",
&["pre-merge", "first", "second", "--yes"],
None
)
);
}
#[rstest]
fn test_hook_multiple_name_filters_none_match(repo: TestRepo) {
repo.write_project_config(
r#"[pre-merge]
first = "echo FIRST"
"#,
);
assert_cmd_snapshot!(
"hook_multiple_name_filters_none_match",
make_snapshot_cmd(&repo, "hook", &["pre-merge", "foo", "bar", "--yes"], None)
);
}
#[rstest]
fn test_var_flag_overrides_template_variable(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
test = "echo '{{ target }}' > target_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"post-create",
"--yes",
"--var",
"target=CUSTOM_TARGET",
])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success(), "Hook should succeed");
let output_file = repo.root_path().join("target_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("CUSTOM_TARGET"),
"Variable should be overridden in hook, got: {contents}"
);
}
#[rstest]
fn test_var_flag_multiple_variables(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
test = "echo '{{ target }} {{ remote }}' > multi_var_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"post-create",
"--yes",
"--var",
"target=FIRST",
"--var",
"remote=SECOND",
])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success(), "Hook should succeed");
let output_file = repo.root_path().join("multi_var_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("FIRST") && contents.contains("SECOND"),
"Both variables should be overridden, got: {contents}"
);
}
#[rstest]
fn test_var_flag_overrides_builtin_variable(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
test = "echo '{{ branch }}' > branch_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"post-create",
"--yes",
"--var",
"branch=CUSTOM_BRANCH_NAME",
])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success(), "Hook should succeed");
let output_file = repo.root_path().join("branch_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("CUSTOM_BRANCH_NAME"),
"Custom variable should override builtin, got: {contents}"
);
}
#[rstest]
fn test_var_flag_invalid_format_fails() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_wt"))
.args(["hook", "post-create", "--var", "no_equals_sign"])
.output()
.expect("Failed to run wt");
assert!(!output.status.success(), "Invalid --var format should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("invalid KEY=VALUE") || stderr.contains("no `=` found"),
"Error should mention invalid format, got: {stderr}"
);
}
#[test]
fn test_var_flag_unknown_variable_fails() {
let output = std::process::Command::new(env!("CARGO_BIN_EXE_wt"))
.args(["hook", "post-create", "--var", "custom_var=value"])
.output()
.expect("Failed to run wt");
assert!(!output.status.success(), "Unknown variable should fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown variable"),
"Error should mention unknown variable, got: {stderr}"
);
}
#[rstest]
fn test_var_flag_last_value_wins(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
test = "echo '{{ target }}' > target_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"post-create",
"--yes",
"--var",
"target=FIRST",
"--var",
"target=SECOND",
])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success());
let output_file = repo.root_path().join("target_output.txt");
let contents = std::fs::read_to_string(&output_file).expect("Should have created output file");
assert!(
contents.contains("SECOND"),
"Last --var value should win, got: {contents}"
);
}
#[rstest]
fn test_var_flag_deprecated_alias_works(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
test = "echo '{{ main_worktree }}' > alias_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"post-create",
"--yes",
"--var",
"main_worktree=/custom/path",
])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success());
let output_file = repo.root_path().join("alias_output.txt");
let contents = std::fs::read_to_string(&output_file).expect("Should have created output file");
assert!(
contents.contains("/custom/path"),
"Deprecated alias should be overridden, got: {contents}"
);
}
#[rstest]
fn test_user_hooks_preserve_toml_order(repo: TestRepo) {
repo.write_test_config(
r#"[post-create]
vscode = "echo '1' >> hook_order.txt"
claude = "echo '2' >> hook_order.txt"
copy = "echo '3' >> hook_order.txt"
submodule = "echo '4' >> hook_order.txt"
"#,
);
snapshot_switch("user_hooks_preserve_order", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let order_file = worktree_path.join("hook_order.txt");
assert!(order_file.exists(), "hook_order.txt should be created");
let contents = fs::read_to_string(&order_file).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(
lines,
vec!["1", "2", "3", "4"],
"Hooks should execute in TOML insertion order (vscode, claude, copy, submodule)"
);
}
#[rstest]
fn test_user_pre_switch_hook_executes(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"[pre-switch]
check = "echo 'USER_PRE_SWITCH_RAN' > pre_switch_marker.txt"
"#,
);
snapshot_switch("user_pre_switch_executes", &repo, &["feature"]);
let marker_file = repo.root_path().join("pre_switch_marker.txt");
assert!(
marker_file.exists(),
"User pre-switch hook should have created marker in source worktree"
);
let contents = fs::read_to_string(&marker_file).unwrap();
assert!(
contents.contains("USER_PRE_SWITCH_RAN"),
"Marker file should contain expected content"
);
}
#[rstest]
fn test_user_pre_switch_failure_blocks_switch(repo: TestRepo) {
repo.write_test_config(
r#"[pre-switch]
block = "exit 1"
"#,
);
snapshot_switch("user_pre_switch_failure", &repo, &["--create", "feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
assert!(
!worktree_path.exists(),
"Worktree should not be created when pre-switch hook fails"
);
}
#[rstest]
fn test_user_pre_switch_skipped_with_no_hooks(repo: TestRepo) {
repo.write_test_config(
r#"[pre-switch]
check = "echo 'SHOULD_NOT_RUN' > pre_switch_marker.txt"
"#,
);
snapshot_switch(
"user_pre_switch_no_hooks",
&repo,
&["--create", "feature", "--no-hooks"],
);
let marker_file = repo.root_path().join("pre_switch_marker.txt");
assert!(
!marker_file.exists(),
"Pre-switch hook should be skipped with --no-hooks"
);
}
#[rstest]
fn test_user_pre_switch_manual_hook(repo: TestRepo) {
repo.write_test_config(
r#"[pre-switch]
check = "echo 'MANUAL_PRE_SWITCH' > pre_switch_marker.txt"
"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "hook", &["pre-switch"], None);
assert_cmd_snapshot!("user_pre_switch_manual", cmd);
});
let marker_file = repo.root_path().join("pre_switch_marker.txt");
assert!(
marker_file.exists(),
"Manual pre-switch hook should have created marker"
);
}
#[rstest]
fn test_user_pre_switch_branch_var_is_destination(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature-dest");
repo.write_test_config(
r#"[pre-switch]
check = "echo '{{ branch }}' > pre_switch_branch.txt"
"#,
);
snapshot_switch(
"user_pre_switch_branch_destination",
&repo,
&["feature-dest"],
);
let marker_file = repo.root_path().join("pre_switch_branch.txt");
assert!(
marker_file.exists(),
"Pre-switch hook should have created marker"
);
let contents = fs::read_to_string(&marker_file).unwrap();
assert_eq!(
contents.trim(),
"feature-dest",
"{{{{ branch }}}} should be the destination branch 'feature-dest', got: '{}'",
contents.trim(),
);
}
#[rstest]
fn test_remove_current_worktree_fires_post_switch_hook(mut repo: TestRepo) {
repo.write_project_config(
r#"post-switch = "echo 'POST_SWITCH_AFTER_REMOVE' > post_switch_marker.txt""#,
);
repo.commit("Add project config with post-switch hook");
let feature_path = repo.add_worktree("feature");
repo.wt_command()
.args(["remove", "feature", "--force-delete", "--yes"])
.current_dir(&feature_path)
.output()
.unwrap();
let marker = repo.root_path().join("post_switch_marker.txt");
wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert!(
content.contains("POST_SWITCH_AFTER_REMOVE"),
"Post-switch hook should run when removing current worktree, got: {content}"
);
}
#[rstest]
fn test_pre_switch_vars_point_to_destination(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
repo.write_test_config(
r#"[pre-switch]
capture = "echo 'wt_path={{ worktree_path }} base={{ base }} base_wt={{ base_worktree_path }} cwd={{ cwd }}' > pre_switch_vars.txt"
"#,
);
repo.wt_command()
.args(["switch", "feature", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
let vars_file = repo.root_path().join("pre_switch_vars.txt");
let content = fs::read_to_string(&vars_file).unwrap();
let feature_name = feature_path.file_name().unwrap().to_string_lossy();
let main_name = repo
.root_path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
content.contains(&format!("/{feature_name} "))
|| content.contains(&format!("\\{feature_name} ")),
"worktree_path should point to destination '{feature_name}', got: {content}"
);
assert!(
content.contains("base=main"),
"base should be source branch 'main', got: {content}"
);
assert!(
content.contains(&format!("/{main_name}")) || content.contains(&format!("\\{main_name}")),
"cwd should point to source worktree '{main_name}', got: {content}"
);
}
#[rstest]
fn test_post_remove_has_target_vars(mut repo: TestRepo) {
repo.add_worktree("feature");
repo.write_test_config(
r#"[post-remove]
capture = "echo 'branch={{ branch }} target={{ target }} target_wt={{ target_worktree_path }}' > ../postremove_target.txt"
"#,
);
repo.wt_command()
.args(["remove", "feature", "--force-delete", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
let vars_file = repo
.root_path()
.parent()
.unwrap()
.join("postremove_target.txt");
crate::common::wait_for_file_content(&vars_file);
let content = fs::read_to_string(&vars_file).unwrap();
assert!(
content.contains("branch=feature"),
"branch should be removed branch 'feature', got: {content}"
);
assert!(
content.contains("target=main"),
"target should be destination 'main', got: {content}"
);
let main_name = repo
.root_path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
content.contains(&main_name),
"target_worktree_path should contain primary worktree name '{main_name}', got: {content}"
);
}
#[rstest]
fn test_post_switch_has_base_vars_for_existing(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
repo.write_test_config(
r#"[post-switch]
capture = "echo 'branch={{ branch }} base={{ base }}' > post_switch_base.txt"
"#,
);
repo.wt_command()
.args(["switch", "feature", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
let vars_file = feature_path.join("post_switch_base.txt");
crate::common::wait_for_file_content(&vars_file);
let content = fs::read_to_string(&vars_file).unwrap();
assert!(
content.contains("branch=feature"),
"branch should be destination 'feature', got: {content}"
);
assert!(
content.contains("base=main"),
"base should be source 'main', got: {content}"
);
}
#[rstest]
fn test_cwd_always_exists_in_post_remove(mut repo: TestRepo) {
repo.add_worktree("feature");
repo.write_test_config(
r#"[post-remove]
check = "test -d {{ cwd }} && echo 'cwd_exists=true' > ../cwd_check.txt || echo 'cwd_exists=false' > ../cwd_check.txt"
"#,
);
repo.wt_command()
.args(["remove", "feature", "--force-delete", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
let check_file = repo.root_path().parent().unwrap().join("cwd_check.txt");
crate::common::wait_for_file_content(&check_file);
let content = fs::read_to_string(&check_file).unwrap();
assert!(
content.contains("cwd_exists=true"),
"cwd should point to an existing directory, got: {content}"
);
}
#[rstest]
fn test_user_post_start_pipeline_serial_ordering(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
"echo SETUP_DONE > pipeline_marker.txt",
{ bg = "cat pipeline_marker.txt > bg_saw_marker.txt" }
]
"#,
);
snapshot_switch(
"user_post_start_pipeline_ordering",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let bg_file = worktree_path.join("bg_saw_marker.txt");
wait_for_file_content(&bg_file);
let content = fs::read_to_string(&bg_file).unwrap();
assert!(
content.contains("SETUP_DONE"),
"Concurrent step should see serial step's output, got: {content}"
);
}
#[rstest]
fn test_user_post_start_pipeline_failure_skips_later_steps(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
"exit 1",
{ bg = "echo SHOULD_NOT_RUN > should_not_exist.txt" }
]
"#,
);
snapshot_switch(
"user_post_start_pipeline_failure",
&repo,
&["--create", "feature"],
);
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("should_not_exist.txt");
assert!(
!marker_file.exists(),
"Later pipeline steps should NOT run after serial step failure"
);
}
#[rstest]
fn test_user_post_start_pipeline_lazy_vars_foreground(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
"git config worktrunk.state.main.vars.name '{{ branch | sanitize }}-postgres'",
{ db = "echo {{ vars.name }} > lazy_expanded.txt" }
]
"#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes", "--foreground"]);
let output = cmd.output().expect("Failed to run foreground hook");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"Foreground hook should succeed.\nstdout: {}\nstderr: {stderr}",
String::from_utf8_lossy(&output.stdout),
);
let marker_file = repo.root_path().join("lazy_expanded.txt");
assert!(
marker_file.exists(),
"Foreground lazy expansion should create marker file"
);
let content = fs::read_to_string(&marker_file).unwrap().trim().to_string();
assert_eq!(
content, "main-postgres",
"Lazy step should see var set by prior step"
);
}
#[rstest]
fn test_user_post_start_pipeline_lazy_vars_background(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
"git config worktrunk.state.{{ branch }}.vars.name '{{ branch | sanitize }}-postgres'",
{ db = "echo {{ vars.name }} > lazy_bg_expanded.txt" }
]
"#,
);
snapshot_switch(
"user_post_start_pipeline_lazy_vars_bg",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("lazy_bg_expanded.txt");
wait_for_file_content(&marker_file);
let content = fs::read_to_string(&marker_file).unwrap().trim().to_string();
assert_eq!(
content, "feature-postgres",
"Background lazy step should see var set by prior step"
);
}
#[rstest]
fn test_user_post_start_pipeline_concurrent_all_run(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
{ a = "echo AAA > concurrent_a.txt", b = "echo BBB > concurrent_b.txt" }
]
"#,
);
snapshot_switch(
"user_post_start_pipeline_concurrent_all",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let file_a = worktree_path.join("concurrent_a.txt");
let file_b = worktree_path.join("concurrent_b.txt");
wait_for_file_content(&file_a);
wait_for_file_content(&file_b);
let a = fs::read_to_string(&file_a).unwrap();
let b = fs::read_to_string(&file_b).unwrap();
assert!(
a.contains("AAA"),
"concurrent command 'a' should run, got: {a}"
);
assert!(
b.contains("BBB"),
"concurrent command 'b' should run, got: {b}"
);
}
#[rstest]
fn test_user_post_start_pipeline_concurrent_partial_failure(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
{ fail = "exit 1", ok = "echo SURVIVED > concurrent_survivor.txt" },
"echo SHOULD_NOT_RUN > after_concurrent.txt"
]
"#,
);
snapshot_switch(
"user_post_start_pipeline_concurrent_failure",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let survivor = worktree_path.join("concurrent_survivor.txt");
wait_for_file_content(&survivor);
let content = fs::read_to_string(&survivor).unwrap();
assert!(
content.contains("SURVIVED"),
"Non-failing concurrent command should still complete, got: {content}"
);
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
let after = worktree_path.join("after_concurrent.txt");
assert!(
!after.exists(),
"Steps after a failed concurrent group should not run"
);
}
#[rstest]
fn test_user_post_start_pipeline_shell_escaping(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
"git config worktrunk.state.{{ branch }}.vars.tricky 'hello world $HOME \"quotes\"'",
{ check = "echo {{ vars.tricky }} > escaped_output.txt" }
]
"#,
);
let mut cmd = crate::common::wt_command();
cmd.current_dir(repo.root_path());
cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path());
cmd.args(["hook", "post-start", "--yes", "--foreground"]);
let output = cmd.output().expect("Failed to run foreground hook");
assert!(
output.status.success(),
"Hook should succeed.\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
let marker_file = repo.root_path().join("escaped_output.txt");
assert!(marker_file.exists(), "Escaped output file should exist");
let content = fs::read_to_string(&marker_file).unwrap().trim().to_string();
assert!(
content.contains("hello world"),
"Spaces should not cause word splitting, got: {content}"
);
assert!(
content.contains("$HOME"),
"$HOME should be literal, not expanded, got: {content}"
);
assert!(
content.contains("\"quotes\""),
"Quotes should survive escaping, got: {content}"
);
}
#[rstest]
fn test_user_post_start_pipeline_hook_name_per_step(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
{ step_one = "echo {{ hook_name }} > step_one_name.txt" },
{ step_two = "echo {{ hook_name }} > step_two_name.txt" }
]
"#,
);
snapshot_switch(
"user_post_start_pipeline_hook_name_per_step",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let step_one_file = worktree_path.join("step_one_name.txt");
let step_two_file = worktree_path.join("step_two_name.txt");
wait_for_file_content(&step_one_file);
wait_for_file_content(&step_two_file);
let step_one_name = fs::read_to_string(&step_one_file)
.unwrap()
.trim()
.to_string();
let step_two_name = fs::read_to_string(&step_two_file)
.unwrap()
.trim()
.to_string();
assert_eq!(
step_one_name, "step_one",
"Step 1 should see its own hook_name"
);
assert_eq!(
step_two_name, "step_two",
"Step 2 should see its own hook_name, not step 1's"
);
}
#[rstest]
fn test_user_post_switch_pipeline_via_switch_create(repo: TestRepo) {
repo.write_test_config(
r#"post-switch = [
"echo SWITCH_STEP_1 > switch_step1.txt",
{ check = "cat switch_step1.txt > switch_step2.txt" }
]
"#,
);
snapshot_switch(
"user_post_switch_pipeline_via_create",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let step2_file = worktree_path.join("switch_step2.txt");
wait_for_file_content(&step2_file);
let content = fs::read_to_string(&step2_file).unwrap();
assert!(
content.contains("SWITCH_STEP_1"),
"Pipeline serial ordering should be preserved for post-switch, got: {content}"
);
}
#[rstest]
fn test_user_post_remove_pipeline_serial_ordering(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature");
repo.write_test_config(
r#"post-remove = [
"echo REMOVE_STEP_1 > ../remove_step1.txt",
"cat ../remove_step1.txt > ../remove_step2.txt"
]
"#,
);
snapshot_remove(
"user_post_remove_pipeline_ordering",
&repo,
&["feature", "--force-delete"],
Some(repo.root_path()),
);
let parent = repo.root_path().parent().unwrap();
let step2_file = parent.join("remove_step2.txt");
wait_for_file_content(&step2_file);
let content = fs::read_to_string(&step2_file).unwrap();
assert!(
content.contains("REMOVE_STEP_1"),
"Step 2 should see step 1's output (serial pipeline), got: {content}"
);
}
#[rstest]
fn test_standalone_hook_name_filtered_lazy_template(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [
{ setup = "echo setup" },
{ db = "echo {{ vars.name }} > lazy_filtered.txt" }
]
"#,
);
repo.run_git(&["config", "worktrunk.state.main.vars.name", "test-db"]);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "hook", &["post-start", "db"], None);
assert_cmd_snapshot!("standalone_hook_name_filtered_lazy_template", cmd);
});
let marker_file = repo.root_path().join("lazy_filtered.txt");
wait_for_file_content(&marker_file);
let content = fs::read_to_string(&marker_file).unwrap().trim().to_string();
assert_eq!(
content, "test-db",
"Lazy template should expand {{ vars.name }} from git config"
);
}
#[rstest]
fn test_multi_remove_hook_announcements_include_branch(repo: TestRepo) {
repo.write_test_config(
r#"[post-remove]
cleanup = "echo done"
"#,
);
snapshot_remove(
"multi_remove_hook_branch_context",
&repo,
&["feature-a", "feature-b", "--force-delete"],
Some(repo.root_path()),
);
}