Files
max-sixty__worktrunk/tests/integration_tests/user_hooks.rs
Maximilian Roos 9e7ad0144d fix(hooks): expand everything but vars.* in a preview (#3638)
Follow-up to #3635, from two reviews that landed after it merged.

## The `vars.*` preview was worse than #3635 claimed

`render_template_preview` short-circuits on
`template_references_var(template, "vars")`, returning the raw template.
So one `vars.` token disabled expansion for the entire command:

```
pre-commit = "deploy --branch={{ branch }} --repo={{ repo }} --env={{ vars.env }}"

before:  deploy --branch={{ branch }} --repo={{ repo }} --env={{ vars.env }}
after:   deploy --branch=main --repo=repo --env={{ vars.env }}
```

#3635 described this as "a `vars.*` template renders raw", which is true
but understates it: `{{ branch }}` and `{{ repo }}` stopped expanding
too, in a command whose whole job is to show the expansion. That
short-circuit predates #3635 and has been degrading `wt hook <type>
--dry-run` the same way; #3635 only extended it to `wt hook show
--expanded`.

The fix is at the source rather than at either caller. A preview now
injects a stand-in object for `vars` that renders each reference back as
itself, nested access included (`{{ vars.config.port }}` round-trips),
while every other variable expands normally. `VarsMode::Resolve` keeps
execution reading real values from git config; only previews pass
`VarsMode::Literal`. A preview also no longer spawns the git read that
resolving `vars` required.

`vars.*` stays literal on purpose: those values are read when the step
runs, after an earlier step in the pipeline may have written them, so a
value resolved at preview time can differ from the one the run uses.

Nothing covered this, which is why the suite stayed green through the
regression. `test_hook_show_expanded_matches_dry_run` now sets a var and
asserts the listing and the dry-run both leave it alone while expanding
`{{ branch }}` beside it.

## The syntax gate is a type error now

#3635 moved the template syntax check out of `prepare_steps` into a free
`validate_pipeline_syntax` that both execution funnels had to remember
to call. `prepare_steps` now returns a `PreparedPipeline` the caller
must resolve: `.validated()` for the paths that run hooks,
`.into_unvalidated()` for the listing, which annotates a broken template
in place rather than blanking itself. Forgetting is a compile error, the
same property `ApprovedHookPlan` gives hook approval.

## Smaller items

Four cross-references went stale when the syntax check moved:
`PreparedCommand.template`, `validate_template_syntax`, the `switch.rs`
skip comment, and `HOOK_INFRASTRUCTURE_VARS` (which still named two
deleted functions). The `--expanded` behavior is now documented in the
sentence that already owns `{{ vars.<key> }}` semantics, with its three
generated mirrors regenerated.

`PreparedStep::commands()` replaces two hand-rolled matches in
`hooks.rs`. `default_branch` moves inside
`build_manual_hook_template_vars` — only the commit-hook arm reads it,
and resolving it can cost a `git ls-remote` on a fresh clone, so the
other eight hook types no longer pay for it. The listing carries its
expansion state in an `Option<String>` instead of re-deriving "was this
expanded?" from whether a context exists.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:44:29 -07:00

4080 lines
136 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for user-level hooks (~/.config/worktrunk/config.toml)
//!
//! User hooks differ from project hooks:
//! - Run for all repositories
//! - Execute before project hooks
//! - Don't require approval
//! - Skipped together with project hooks via --no-hooks
use crate::common::{
SLEEP_FOR_ABSENCE_CHECK, TestRepo, make_snapshot_cmd, make_snapshot_cmd_with_global_flags,
repo, resolve_git_common_dir, setup_snapshot_settings, wait_for_file, wait_for_file_content,
wait_for_file_count,
};
use insta::assert_snapshot;
use insta_cmd::assert_cmd_snapshot;
use path_slash::PathExt as _;
use rstest::rstest;
use std::fs;
use std::thread;
use std::time::Duration;
// ============================================================================
// User Post-Create Hook Tests
// ============================================================================
/// Helper to create snapshot for switch commands
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_pre_start_hook_executes(repo: TestRepo) {
// Write user config with pre-start hook (no project config)
repo.write_test_config(
r#"[pre-start]
log = "echo 'USER_PRE_CREATE_RAN' > user_hook_marker.txt"
"#,
);
snapshot_switch("user_pre_start_executes", &repo, &["--create", "feature"]);
// Verify user hook actually ran
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 pre-start hook should have created marker file"
);
let contents = fs::read_to_string(&marker_file).unwrap();
assert!(
contents.contains("USER_PRE_CREATE_RAN"),
"Marker file should contain expected content"
);
}
#[rstest]
fn test_user_hooks_run_before_project_hooks(repo: TestRepo) {
// Create project config with pre-start hook
repo.write_project_config(r#"pre-start = "echo 'PROJECT_HOOK' >> hook_order.txt""#);
repo.commit("Add project config");
// Write user config with user hook AND pre-approve project command
repo.write_test_config(
r#"[pre-start]
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"]);
// Verify execution order
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) {
// Write user config with hook but NO pre-approved commands
// (unlike project hooks, user hooks don't require approval)
repo.write_test_config(
r#"[pre-start]
setup = "echo 'NO_APPROVAL_NEEDED' > no_approval.txt"
"#,
);
snapshot_switch("user_hooks_no_approval", &repo, &["--create", "feature"]);
// Verify hook ran without approval
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) {
// Create project config with pre-start hook
repo.write_project_config(r#"pre-start = "echo 'PROJECT_HOOK' > project_marker.txt""#);
repo.commit("Add project config");
// Write user config with both user hook and pre-approved project command
repo.write_test_config(
r#"[pre-start]
log = "echo 'USER_HOOK' > user_marker.txt"
"#,
);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'PROJECT_HOOK' > project_marker.txt"]
"#,
);
// Create worktree with --no-hooks (skips ALL hooks)
snapshot_switch(
"no_hooks_skips_all_hooks",
&repo,
&["--create", "feature", "--no-hooks"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
// User hook should NOT have run
let user_marker = worktree_path.join("user_marker.txt");
assert!(
!user_marker.exists(),
"User hook should be skipped with --no-hooks"
);
// Project hook should also NOT have run (--no-hooks skips ALL 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_pre_start_hook_failure(repo: TestRepo) {
// Write user config with failing hook
repo.write_test_config(
r#"[pre-start]
failing = "exit 1"
"#,
);
// A failing pre-start hook aborts with FailFast. The worktree is created
// before pre-start runs, so it exists even though the command exits non-zero.
snapshot_switch("user_pre_start_failure", &repo, &["--create", "feature"]);
// Worktree exists (created before pre-start ran) but the command failed
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"
);
}
// ============================================================================
// User Post-Start Hook Tests (Background)
// ============================================================================
#[rstest]
fn test_user_post_start_hook_executes(repo: TestRepo) {
// Write user config with post-start hook (background)
repo.write_test_config(
r#"[post-start]
bg = "echo 'USER_POST_CREATE_RAN' > user_bg_marker.txt"
"#,
);
snapshot_switch("user_post_start_executes", &repo, &["--create", "feature"]);
// Wait for background hook to complete and write content
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_CREATE_RAN"),
"User post-start hook should have run in background"
);
}
#[rstest]
fn test_background_hook_sees_worktrunk_foreground_env_var(repo: TestRepo) {
// Background hooks run via the detached `wt hook run-pipeline` runner; wt
// sets WORKTRUNK_FOREGROUND=-1 on that process so descendants (shell,
// user command, nested `wt` invocations) can tell they're backgrounded.
// `pre-*` hooks run synchronously in the foreground `wt` process, so they
// must NOT see the sentinel.
repo.write_test_config(
r#"[post-start]
capture-bg = "echo \"bg=$WORKTRUNK_FOREGROUND\" > post_start_env.txt"
[pre-start]
capture-fg = "echo \"fg=$WORKTRUNK_FOREGROUND\" > pre_start_env.txt"
"#,
);
snapshot_switch(
"background_hook_env_var_visible",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
// pre-start wrote its marker synchronously; post-start runs in the
// background, so poll for the file.
let bg_marker = worktree_path.join("post_start_env.txt");
wait_for_file_content(&bg_marker);
let bg = fs::read_to_string(&bg_marker).unwrap();
assert_eq!(
bg.trim(),
"bg=-1",
"post-start (background) hook should see WORKTRUNK_FOREGROUND=-1, got {bg:?}"
);
let fg_marker = worktree_path.join("pre_start_env.txt");
let fg = fs::read_to_string(&fg_marker).unwrap();
assert_eq!(
fg.trim(),
"fg=",
"pre-start (foreground) hook should NOT see WORKTRUNK_FOREGROUND set, got {fg:?}"
);
}
#[rstest]
fn test_user_post_start_skipped_with_no_hooks(repo: TestRepo) {
// Write user config with post-start hook
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"],
);
// Wait to ensure background hook would have had time to run
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"
);
}
// ============================================================================
// User Pre-Merge Hook Tests
// ============================================================================
/// Helper for merge snapshots
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) {
// Create feature worktree with a commit
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
// Write user config with pre-merge hook
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),
);
// Verify user hook ran
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) {
// Create feature worktree with a commit
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
// Write user config with failing pre-merge hook
repo.write_test_config(
r#"[pre-merge]
check = "exit 1"
"#,
);
// Failing pre-merge hook should block the merge
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) {
// Create feature worktree with a commit
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
// Write user config with pre-merge hook that creates a marker
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),
);
// User hook should NOT have run (--no-hooks skips all hooks)
let marker_file = feature_wt.join("user_premerge_marker.txt");
assert!(
!marker_file.exists(),
"User pre-merge hook should be skipped with --no-hooks"
);
}
///
/// Real Ctrl-C sends SIGINT to the entire foreground process group. We simulate this by:
/// 1. Spawning wt in its own process group (so we don't kill the test runner)
/// 2. Sending SIGINT to that process group (which includes wt and its hook children)
#[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");
// Project pre-merge hook: write start, then sleep, then write done (if not interrupted)
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");
// Spawn wt in its own process group (so SIGINT to that group doesn't kill the test)
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); // wt becomes leader of its own process group
let mut child = cmd.spawn().expect("failed to spawn wt hook pre-merge");
// Wait until hook writes "start" to hook.log (verifies the hook is running)
let hook_log = repo.root_path().join("hook.log");
wait_for_file_content(&hook_log);
// Send SIGINT to wt's process group (wt's PID == its PGID since it's the leader)
// This simulates real Ctrl-C which sends SIGINT to the foreground process group
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");
// wt was killed by signal, so code() returns None and we check the signal
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:?}"
);
// Give the (killed) hook a moment; it must not append "done"
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
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");
// Project pre-merge hook: write start, then sleep, then write done (if not interrupted)
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");
// Spawn wt in its own process group (so SIGTERM to that group doesn't kill the test)
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); // wt becomes leader of its own process group
let mut child = cmd.spawn().expect("failed to spawn wt hook pre-merge");
// Wait until hook writes "start" to hook.log (verifies the hook is running)
let hook_log = repo.root_path().join("hook.log");
wait_for_file_content(&hook_log);
// Send SIGTERM to wt's process group (wt's PID == its PGID since it's the leader)
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");
// wt was killed by signal, so code() returns None and we check the signal
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:?}"
);
// Give the (killed) hook a moment; it must not append "done"
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
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:?}"
);
}
/// A signal-derived exit in one hook step must abort the rest of the pipeline
/// rather than treating the signal like an ordinary per-step failure. Drives
/// the `handle_command_error` interrupt branch end-to-end through the hook
/// path (the for-each test in `for_each.rs` covers the worktree-loop branch).
///
/// Implementation mirrors `test_for_each_aborts_on_signal_exit`: the first
/// hook step self-signals via SIGTERM after touching a marker. SIGINT against
/// the parent wt would kill the test harness, so we drive the same
/// `ChildProcessExited { signal: Some(_), .. }` path with an in-child signal.
#[rstest]
#[cfg(unix)]
fn test_pre_merge_pipeline_aborts_on_signal_exit(repo: TestRepo) {
repo.commit("Initial commit");
// Two pre-merge pipeline steps: the first writes a marker then
// self-signals with SIGTERM; the second (which must NOT run) would write
// its own marker.
repo.write_project_config(
r#"pre-merge = [
{ abort = "sh -c 'echo first >> hook.log; kill -TERM $$'" },
{ after = "sh -c 'echo second >> hook.log'" },
]
"#,
);
repo.commit("Add pre-merge hooks");
let output = crate::common::wt_command()
.current_dir(repo.root_path())
.args(["hook", "pre-merge", "--yes"])
.output()
.expect("run wt hook pre-merge");
// 128 + SIGTERM (15) = 143
assert_eq!(
output.status.code(),
Some(143),
"expected exit 143 (SIGTERM); got {:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
let hook_log = repo.root_path().join("hook.log");
let contents = std::fs::read_to_string(&hook_log).unwrap_or_default();
assert_eq!(
contents.trim(),
"first",
"second hook step ran after the first was killed by signal; got: {contents:?}",
);
}
// ============================================================================
// User Post-Merge Hook Tests
// ============================================================================
#[rstest]
fn test_user_post_merge_hook_executes(mut repo: TestRepo) {
// Create feature worktree with a commit
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
// Write user config with post-merge hook
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),
);
// Post-merge runs in the destination (main) worktree (poll for pipeline runner)
let main_worktree = repo.root_path();
let marker_file = main_worktree.join("user_postmerge.txt");
wait_for_file(&marker_file);
}
#[rstest]
fn test_combined_user_and_project_post_merge(mut repo: TestRepo) {
repo.write_project_config(
r#"[post-merge]
install = "echo 'PROJECT_RAN' > project_postmerge.txt"
"#,
);
repo.commit("Add project config");
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
repo.write_test_config(
r#"[post-merge]
sync = "echo 'USER_RAN' > user_postmerge.txt"
"#,
);
snapshot_merge(
"combined_user_and_project_post_merge",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
let main_worktree = repo.root_path();
wait_for_file(&main_worktree.join("user_postmerge.txt"));
wait_for_file(&main_worktree.join("project_postmerge.txt"));
}
// ============================================================================
// User Pre-Remove Hook Tests
// ============================================================================
/// Helper for remove snapshots
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) {
// Create a worktree to remove
let _feature_wt = repo.add_worktree("feature");
// Write user config with pre-remove hook
// Hook writes to parent dir (temp dir) since the worktree itself gets removed
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()),
);
// Verify user hook ran (writes to parent dir since worktree is being removed)
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) {
// Create a worktree to remove
let feature_wt = repo.add_worktree("feature");
// Write user config with failing pre-remove hook
repo.write_test_config(
r#"[pre-remove]
block = "exit 1"
"#,
);
snapshot_remove(
"user_pre_remove_failure",
&repo,
&["feature", "--force-delete"],
Some(repo.root_path()),
);
// Worktree should still exist (removal blocked by failing hook)
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) {
// Create a worktree to remove
let feature_wt = repo.add_worktree("feature");
// Write user config with pre-remove hook that would block
repo.write_test_config(
r#"[pre-remove]
block = "exit 1"
"#,
);
// With --no-hooks, all hooks (including the failing one) should be skipped
snapshot_remove(
"user_pre_remove_skipped_no_hooks",
&repo,
&["feature", "--force-delete", "--no-hooks"],
Some(repo.root_path()),
);
// Worktree should be removed (hooks skipped)
// Background removal needs time to complete
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"
);
}
// ============================================================================
// User Post-Remove Hook Tests
// ============================================================================
#[rstest]
fn test_user_post_remove_hook_executes(mut repo: TestRepo) {
// Create a worktree to remove
let _feature_wt = repo.add_worktree("feature");
// Write user config with post-remove hook
// Hook writes to parent dir (temp dir) since the worktree itself is removed
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()),
);
// Wait for background hook to complete
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"
);
}
/// Post-remove hooks run at the primary worktree, not cwd. When removing a
/// non-current worktree from a linked worktree, the output should show `@ [path]`
/// pointing to the primary worktree where hooks execute.
#[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"
"#,
);
// Remove feature from the "other" worktree (not primary)
snapshot_remove(
"post_remove_runs_at_primary",
&repo,
&["feature", "--force-delete"],
Some(&other_wt),
);
}
/// Verify that post-remove hook template variables reference the removed worktree,
/// not the worktree where the hook executes from.
#[rstest]
fn test_user_post_remove_template_vars_reference_removed_worktree(mut repo: TestRepo) {
// Create a worktree with a unique commit to verify commit capture
let feature_wt_path =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
// Get the commit SHA of the feature worktree BEFORE removal
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];
// Write user config that captures template variables to a file
// Hook writes to parent dir (temp dir) since the worktree itself is removed
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"
"#,
);
// Run from main worktree, remove the feature worktree
repo.wt_command()
.args(["remove", "feature", "--force-delete", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
// Wait for background hook to complete
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();
// Verify branch is the removed branch
assert!(
content.contains("branch=feature"),
"branch should be the removed branch 'feature', got: {content}"
);
// Extract worktree name for cross-platform comparison.
// Hooks run in Git Bash on Windows, which converts paths to MSYS2 format
// (/c/Users/... instead of C:\Users\... or C:/Users/...). Instead of trying
// to match exact path formats, verify the path ends with the worktree name.
let feature_wt_name = feature_wt_path
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
// Verify worktree_path is the removed worktree's path (not the main worktree)
// The worktree_path in hook output should end with the worktree directory name
assert!(
content.contains(&format!("/{feature_wt_name} "))
|| content.contains(&format!(r"\{feature_wt_name} ")),
"worktree_path should end with the removed worktree's name '{feature_wt_name}', got: {content}"
);
// Verify worktree_name is the removed worktree's directory name
assert!(
content.contains(&format!("worktree_name={feature_wt_name}")),
"worktree_name should be the removed worktree's name '{feature_wt_name}', got: {content}"
);
// Verify commit is the removed worktree's commit (not main worktree's commit)
assert!(
content.contains(&format!("commit={feature_commit}")),
"commit should be the removed worktree's commit '{feature_commit}', got: {content}"
);
// Verify short_commit is the first 7 chars of the removed worktree's commit
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) {
// Create a worktree to remove
let feature_wt = repo.add_worktree("feature");
// Write user config with post-remove hook that creates a marker
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()),
);
// Worktree should be removed
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"
);
// Post-remove hook should NOT have run
let marker_file = repo
.root_path()
.parent()
.unwrap()
.join("no_hooks_postremove.txt");
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!marker_file.exists(),
"Post-remove hook should be skipped when --no-hooks is used"
);
}
/// Verify that post-remove hooks run during `wt merge` (which removes the worktree).
/// This tests the main production use case for post-remove hooks.
#[rstest]
fn test_user_post_remove_hook_runs_during_merge(mut repo: TestRepo) {
// Create feature worktree with a commit
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature content", "Add feature");
// Write user config with post-remove hook
// Hook writes to temp dir (parent of repo) since worktree is removed
repo.write_test_config(
r#"[post-remove]
cleanup = "echo 'POST_REMOVE_DURING_MERGE' > ../merge_postremove_marker.txt"
"#,
);
// Run merge from feature worktree - this should trigger post-remove hooks
repo.wt_command()
.args(["merge", "main", "--yes"])
.current_dir(&feature_wt)
.output()
.unwrap();
// Wait for background hook to complete
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"
);
}
/// `wt merge` with removal fires post-remove, post-switch, and post-merge in
/// sequence. They should share one `Running …` announce line so the user sees
/// a single status line for the whole command, not three.
#[rstest]
fn test_merge_combines_post_remove_post_switch_post_merge(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature", "Add feature");
repo.write_test_config(
r#"[post-remove]
cleanup = "echo removed"
[post-switch]
notify = "echo switched"
[post-merge]
sync = "echo merged"
"#,
);
snapshot_merge(
"merge_combines_post_remove_post_switch_post_merge",
&repo,
&["main", "--yes"],
Some(&feature_wt),
);
}
/// `wt merge` (no squash) with uncommitted changes auto-commits via
/// `CommitOptions::commit`, which threads the merge announcer through. The
/// post-commit phase should join post-remove + post-switch + post-merge on
/// one combined announce line — the non-squash sibling of
/// [`test_merge_squash_combines_post_commit_post_remove_post_switch_post_merge`].
#[rstest]
fn test_merge_auto_commit_combines_post_commit_post_remove_post_switch_post_merge(
mut repo: TestRepo,
) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature", "Add feature");
// Leave an uncommitted change so the merge auto-commits via
// CommitOptions::commit (the path that exercises the announcer Some arm).
std::fs::write(feature_wt.join("dirty.txt"), "uncommitted").unwrap();
repo.write_test_config(
r#"[post-commit]
mark = "echo committed"
[post-remove]
cleanup = "echo removed"
[post-switch]
notify = "echo switched"
[post-merge]
sync = "echo merged"
"#,
);
snapshot_merge(
"merge_auto_commit_combines_post_commit_post_remove_post_switch_post_merge",
&repo,
&["main", "--yes", "--no-squash"],
Some(&feature_wt),
);
}
/// `wt merge --squash` fires post-commit (from the squash phase), post-remove,
/// post-switch (from worktree removal), and post-merge. All four should share
/// one `Running …` announce line so the user sees a single status line for
/// the whole command, not four.
#[rstest]
fn test_merge_squash_combines_post_commit_post_remove_post_switch_post_merge(mut repo: TestRepo) {
// Squash needs >1 commit ahead of main to actually run.
let feature_wt = repo.add_worktree_with_commit("feature", "feature1.txt", "one", "feat: one");
repo.commit_in_worktree(&feature_wt, "feature2.txt", "two", "feat: two");
repo.write_test_config(
r#"[post-commit]
mark = "echo committed"
[post-remove]
cleanup = "echo removed"
[post-switch]
notify = "echo switched"
[post-merge]
sync = "echo merged"
"#,
);
snapshot_merge(
"merge_squash_combines_post_commit_post_remove_post_switch_post_merge",
&repo,
&["main", "--yes", "--squash"],
Some(&feature_wt),
);
}
/// When post-merge template prep errors after post-remove + post-switch are
/// already registered, the announcer's `Drop` impl flushes the pending hooks
/// so they still spawn — preserving the prior fire-and-forget behavior in
/// which earlier hooks couldn't be lost by a later failure.
#[rstest]
fn test_merge_drops_pending_hooks_when_post_merge_fails(mut repo: TestRepo) {
let feature_wt =
repo.add_worktree_with_commit("feature", "feature.txt", "feature", "Add feature");
let temp_root = repo.root_path().parent().unwrap();
let post_remove_marker = temp_root.join("drop_postremove_marker.txt");
let post_merge_marker = temp_root.join("drop_postmerge_marker.txt");
// post-merge has a template syntax error, so `register` errors after
// post-remove + post-switch are already pending in the announcer.
// (Semantic errors like undefined variables no longer fail prep — they
// surface when the runner renders the step; see
// test_background_hook_undefined_var_fails_in_runner.)
// Convert to forward slashes so the rendered shell command parses the same
// way under Windows Git Bash (where backslashes in unquoted paths get
// eaten as escape-of-next-char).
repo.write_test_config(&format!(
r#"[post-remove]
cleanup = "echo POST_REMOVE_RAN > {}"
[post-switch]
notify = "echo switched"
[post-merge]
sync = "echo POST_MERGE_RAN > {} {{{{ bad..syntax }}}}"
"#,
post_remove_marker.to_slash_lossy(),
post_merge_marker.to_slash_lossy(),
));
let output = repo
.wt_command()
.args(["merge", "main", "--yes"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(
!output.status.success(),
"merge should fail when a post-merge template has a syntax error"
);
// Drop flushed the pending pipelines: post-remove ran despite the failure.
crate::common::wait_for_file_content(&post_remove_marker);
let contents = fs::read_to_string(&post_remove_marker).unwrap();
assert!(
contents.contains("POST_REMOVE_RAN"),
"post-remove should have spawned via Drop: {contents}"
);
// post-merge never registered (template prep failed), so its marker stays absent.
assert!(
!post_merge_marker.exists(),
"post-merge marker should not exist (template prep failed before spawn)"
);
}
/// A semantic template error (undefined variable) in a background hook does
/// not fail the foreground command: templates render when each step runs, so
/// the error surfaces in the detached runner and lands in its log. Only
/// syntax errors abort at prep (see
/// test_merge_drops_pending_hooks_when_post_merge_fails).
#[rstest]
fn test_background_hook_undefined_var_fails_in_runner(repo: TestRepo) {
repo.write_test_config(
r#"[post-start]
broken = "echo {{ does_not_exist }} > should_not_exist.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(),
"background spawn should succeed; rendering fails later in the runner.\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
// The runner renders the template, fails, and reports into its own log
// at `{branch}/{source}/{hook_type}/runner.log`.
let runner_log = resolve_git_common_dir(repo.root_path())
.join("wt/logs")
.join(worktrunk::path::sanitize_for_filename("main"))
.join("user")
.join("post-start")
.join("runner.log");
wait_for_file_content(&runner_log);
let log_content = fs::read_to_string(&runner_log).unwrap();
// Same label the foreground path renders for this command (see the
// foreground_pipeline_undefined_var_runs_earlier_steps snapshot).
assert_snapshot!(log_content, @"
✗ Failed to expand user:broken: undefined value @ line 1
  echo {{ does_not_exist }} > should_not_exist.txt
↳ Available variables: args, base, base_worktree_path, branch, commit, cwd, default_branch, hook_name, hook_type, main_worktree, main_worktree_path, primary_worktree_path, remote, remote_url, repo, repo_path, repo_root, short_commit, target, target_worktree_path, upstream, worktree, worktree_name, worktree_path
");
// The step never ran.
assert!(!repo.root_path().join("should_not_exist.txt").exists());
}
/// Runner-log failure messages label steps the way the foreground does:
/// named steps by command name, unnamed steps by the expanded command.
#[rstest]
fn test_background_hook_failure_labels_in_runner_log(repo: TestRepo) {
repo.write_test_config(
r#"post-start = [{ broken = "exit 7" }]
post-switch = "exit 9"
"#,
);
for hook_type in ["post-start", "post-switch"] {
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", hook_type, "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"background spawn should succeed; the step fails later in the runner.\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
}
let runner_log = |hook_type: &str| {
resolve_git_common_dir(repo.root_path())
.join("wt/logs")
.join(worktrunk::path::sanitize_for_filename("main"))
.join("user")
.join(hook_type)
.join("runner.log")
};
let named = runner_log("post-start");
wait_for_file_content(&named);
assert_snapshot!(fs::read_to_string(&named).unwrap(), @"✗ command failed with exit code 7: broken");
let unnamed = runner_log("post-switch");
wait_for_file_content(&unnamed);
assert_snapshot!(fs::read_to_string(&unnamed).unwrap(), @"✗ command failed with exit code 9: exit 9");
}
/// A semantic template error in a foreground pipeline step surfaces when that
/// step runs: earlier steps execute first, then the broken step's render
/// aborts the pipeline. (Pre-change, prep expanded every template upfront and
/// nothing ran.) Syntax errors still abort before step 1.
#[rstest]
fn test_foreground_pipeline_undefined_var_runs_earlier_steps(repo: TestRepo) {
repo.write_test_config(
r#"pre-merge = [
{ first = "echo FIRST_RAN > first_marker.txt" },
{ broken = "echo {{ does_not_exist }}" },
]
"#,
);
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-merge", "--yes"]);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
assert_cmd_snapshot!("foreground_pipeline_undefined_var_runs_earlier_steps", cmd);
});
let marker = repo.root_path().join("first_marker.txt");
assert!(
marker.exists(),
"step 1 should have run before step 2's template error"
);
}
/// The other half of that contract: a *syntax* error anywhere in the pipeline
/// aborts before step 1. Preparation parses every template
/// (`PreparedPipeline::validated`), so a pipeline that can't render in full never
/// starts — where a semantic error, rendered per step, lets earlier steps run.
///
/// Driven through `wt merge`'s pre-commit hooks rather than `wt hook
/// pre-commit`, because the `wt hook` CLI parses every template up front to
/// route shorthand arguments (`referenced_vars_union`) and would catch the
/// error before preparation, leaving the preparation-time guard untested.
#[rstest]
fn test_foreground_pipeline_syntax_error_aborts_before_first_step(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 = [
{ first = "echo FIRST_RAN > syntax_first_marker.txt" },
{ broken = "echo {{ bad..syntax }}" },
]
"#,
);
let output = repo
.wt_command()
.args(["merge", "main", "--yes", "--no-remove"])
.current_dir(&feature_wt)
.output()
.unwrap();
assert!(
!output.status.success(),
"an unparsable template should fail the merge"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("syntax error"),
"error should name the syntax failure, got: {stderr}"
);
assert!(
!feature_wt.join("syntax_first_marker.txt").exists(),
"step 1 must not run when a later step's template can't parse: {stderr}"
);
}
/// When removing the current worktree (cd back to main), both post-remove and
/// post-switch hooks fire. They should appear on a single combined announcement line.
#[rstest]
fn test_combined_post_remove_and_post_switch_hooks(mut repo: TestRepo) {
let feature_wt = repo.add_worktree("feature");
// Configure both post-remove and post-switch user hooks
repo.write_test_config(
r#"[post-remove]
cleanup = "echo removed"
[post-switch]
notify = "echo switched"
"#,
);
// Remove from inside the feature worktree — triggers cd back to main,
// which means changed_directory=true and both hook types fire.
snapshot_remove(
"combined_post_remove_and_post_switch",
&repo,
&["feature", "--force-delete"],
Some(&feature_wt),
);
}
/// Verbose variant of [`test_combined_post_remove_and_post_switch_hooks`].
///
/// Under `-v`, the announcer prints a `template variables:` table for each
/// registered hook type, iterating over pipelines and skipping those whose
/// hook type doesn't match — that filter branch needs at least two hook
/// types in one batch to fire. Removing the current worktree's branch is
/// the only path that registers both `post-remove` and `post-switch`.
#[rstest]
fn test_combined_post_remove_and_post_switch_hooks_verbose(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"
"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd_with_global_flags(
&repo,
"remove",
&["feature", "--force-delete"],
Some(&feature_wt),
&["-v"],
);
assert_cmd_snapshot!("combined_post_remove_and_post_switch_verbose", cmd);
});
}
// Note: The `return Ok(())` path in spawn_hooks_after_remove when UserConfig::load()
// fails is defensive code for an extremely rare race condition where config becomes
// invalid between command startup and hook execution. This is not easily testable
// without complex timing manipulation.
#[rstest]
fn test_standalone_hook_post_remove_invalid_template(repo: TestRepo) {
// Write project config with invalid template syntax (unclosed braces)
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) {
// Write project config with a named hook
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());
// Use a name filter that doesn't match any configured hook
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}"
);
}
// ============================================================================
// User Pre-Commit Hook Tests
// ============================================================================
#[rstest]
fn test_user_pre_commit_hook_executes(mut repo: TestRepo) {
// Create feature worktree
let feature_wt = repo.add_worktree("feature");
// Add uncommitted changes (triggers pre-commit during merge)
fs::write(feature_wt.join("uncommitted.txt"), "uncommitted content").unwrap();
// Write user config with pre-commit hook
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),
);
// Verify user hook ran
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) {
// Create feature worktree
let feature_wt = repo.add_worktree("feature");
// Add uncommitted changes
fs::write(feature_wt.join("uncommitted.txt"), "uncommitted content").unwrap();
// Write user config with failing pre-commit hook
repo.write_test_config(
r#"[pre-commit]
lint = "exit 1"
"#,
);
// Failing pre-commit hook should block the merge
snapshot_merge(
"user_pre_commit_failure",
&repo,
&["main", "--yes", "--no-remove"],
Some(&feature_wt),
);
}
// ============================================================================
// User Post-Commit Hook Tests (Background, via `wt step commit`)
// ============================================================================
/// Helper for step commit snapshots
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) {
// Create feature worktree with staged changes
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("new_file.txt"), "content").unwrap();
// Write user config with post-commit hook
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));
// Post-commit runs in background in the worktree where the commit happened
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) {
// Create feature worktree with staged changes
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("new_file.txt"), "content").unwrap();
// Write user config with post-commit hook
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),
);
// Wait to ensure background hook would have had time to run
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) {
// Create feature worktree with staged changes
let feature_wt = repo.add_worktree("feature");
fs::write(feature_wt.join("new_file.txt"), "content").unwrap();
// Write user config with failing post-commit hook
repo.write_test_config(
r#"[post-commit]
failing = "exit 1"
"#,
);
snapshot_step_commit("user_post_commit_failure", &repo, &[], Some(&feature_wt));
// The commit should have succeeded despite post-commit hook failure
// (post-commit runs in background and doesn't affect exit code)
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}"
);
}
// ============================================================================
// Template Variable Tests
// ============================================================================
#[rstest]
fn test_user_hook_template_variables(repo: TestRepo) {
// Write user config with hook using template variables
repo.write_test_config(
r#"[pre-start]
vars = "echo 'repo={{ repo }} branch={{ branch }}' > template_vars.txt"
"#,
);
snapshot_switch("user_hook_template_vars", &repo, &["--create", "feature"]);
// Verify template variables were expanded
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) {
// Hook that writes template variables and pwd to files so we can verify their values.
// This tests that running from a subdirectory still resolves worktree_path to the
// worktree root (not "." or the subdirectory) and sets hook CWD to the root.
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");
// Create a subdirectory and run the hook from there
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) // override: run from subdirectory
.output()
.expect("Failed to run wt hook pre-merge");
assert!(
output.status.success(),
"wt hook pre-merge failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// worktree_path should be the worktree root, not "." or the subdirectory.
// On Windows, to_posix_path() converts C:\... to /c/..., so check that the path
// is not relative rather than using is_absolute() (which rejects POSIX-style paths).
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}"
);
// worktree_name should be the directory name, not "unknown"
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'"
);
// Hook CWD should be the worktree root, not the subdirectory
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}"
);
}
// ============================================================================
// Combined User and Project Hooks Tests
// ============================================================================
/// Test that both user and project unnamed hooks of the same type run and get unique log names.
/// This exercises the unnamed index tracking when multiple unnamed hooks share the same hook type.
#[rstest]
fn test_user_and_project_unnamed_post_start(repo: TestRepo) {
// Create project config with unnamed post-start hook
repo.write_project_config(r#"post-start = "echo 'PROJECT_POST_START' > project_bg.txt""#);
repo.commit("Add project config");
// Write user config with unnamed hook AND pre-approve project command
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 both background commands
wait_for_file(&worktree_path.join("user_bg.txt"));
wait_for_file(&worktree_path.join("project_bg.txt"));
// Both should have run
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) {
// Create project config with post-start hook
repo.write_project_config(r#"post-start = "echo 'PROJECT_POST_START' > project_bg.txt""#);
repo.commit("Add project config");
// Write user config with user hook AND pre-approve project command
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 both background commands
wait_for_file(&worktree_path.join("user_bg.txt"));
wait_for_file(&worktree_path.join("project_bg.txt"));
// Both should have run
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"
);
}
// ============================================================================
// Standalone Hook Execution Tests (wt hook <type>)
// ============================================================================
#[rstest]
fn test_standalone_hook_failure_omits_skip_hint(repo: TestRepo) {
// `wt hook <type>` is the user explicitly requesting hooks. When a hook
// fails, suggesting `--no-hooks` makes no sense (they didn't ask for the
// operation that runs hooks; they asked for the hooks themselves). The
// hint must be reserved for operation-driven hooks (merge, commit, ...).
repo.write_project_config(r#"pre-merge = "exit 1""#);
let mut cmd = repo.wt_command();
cmd.args(["hook", "pre-merge", "--yes"]);
let output = cmd.output().unwrap();
assert!(
!output.status.success(),
"wt hook pre-merge should fail when hook exits non-zero"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("--no-hooks"),
"wt hook <type> failure must not suggest --no-hooks; got: {stderr}"
);
}
#[rstest]
fn test_standalone_hook_pre_start(repo: TestRepo) {
// Write project config with pre-start hook
repo.write_project_config(r#"pre-start = "echo 'STANDALONE_PRE_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", "pre-start", "--yes"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "wt hook pre-start 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_PRE_CREATE"));
}
#[rstest]
fn test_standalone_hook_create_alias_runs_silently(repo: TestRepo) {
// `wt hook pre-create` is a deprecated alias for `wt hook pre-start`. It
// still runs the canonical `pre-start` hook, and Phase 1 of the rename
// emits no warning (issue #2838).
repo.write_project_config(r#"pre-start = "echo 'CREATE_ALIAS' > 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-create", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"wt hook pre-create should succeed (alias for pre-start)"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.to_lowercase().contains("deprecated"),
"phase 1 emits no deprecation warning, got: {stderr}"
);
// Verify the aliased hook actually ran.
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("CREATE_ALIAS"));
}
#[rstest]
fn test_standalone_hook_pre_start_fails_on_failure(repo: TestRepo) {
// pre-start hooks use FailFast like all other pre-* hooks — consistent with
// the symmetric pre (blocking, fail-fast) / post (background, warn) pattern.
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) {
// Write project config with post-start hook
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");
// Hook spawns in background - wait for marker file
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) {
// Write project config with post-start hook that echoes to both file and stdout
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"
);
// With --foreground, marker file should exist immediately (no waiting)
let marker = repo.root_path().join("hook_ran.txt");
assert!(
marker.exists(),
"hook should have completed synchronously with --foreground"
);
// Output should contain the hook's stdout (not just spawned message)
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) {
// Write project config with pre-commit hook
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");
// Hook should have run
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) {
// Write project config with post-merge hook
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");
// Hook runs in background — wait for it to write the marker file
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_post_merge_combined_user_and_project(repo: TestRepo) {
// Both user and project configs contribute to post-merge — a single
// `Running post-merge:` announce line must cover both sources.
repo.write_project_config(r#"post-merge = "echo 'PROJECT_RAN' > project.txt""#);
repo.write_test_config(
r#"[post-merge]
notify = "echo 'USER_RAN' > user.txt"
"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "hook", &["post-merge", "--yes"], None);
assert_cmd_snapshot!("standalone_hook_post_merge_combined_sources", cmd);
});
let root = repo.root_path();
wait_for_file(&root.join("user.txt"));
wait_for_file(&root.join("project.txt"));
}
#[rstest]
fn test_standalone_hook_pre_remove(repo: TestRepo) {
// Write project config with pre-remove hook
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");
// Hook should have run
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) {
// Write project config with post-remove hook
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)"
);
// Wait for background hook to complete and write content
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) {
// Write project config with post-remove hook
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"
);
// Hook runs in foreground, so marker should exist immediately
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) {
// No project config, no user config with hooks: `wt hook` should exit 0
// with a warning — running hooks that don't exist is a no-op, not an error.
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}"
);
}
// ============================================================================
// Dry-Run Tests
// ============================================================================
/// --dry-run shows expanded commands without executing them
#[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();
// No --yes needed: --dry-run skips approval
assert_cmd_snapshot!(make_snapshot_cmd(
&repo,
"hook",
&["pre-merge", "--dry-run"],
Some(repo.root_path()),
));
}
/// --dry-run does not execute the hook command
#[rstest]
fn test_hook_dry_run_does_not_execute(repo: TestRepo) {
repo.write_project_config(r#"pre-start = "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", "pre-start", "--dry-run"]);
let output = cmd.output().unwrap();
assert!(output.status.success(), "dry-run should succeed");
// Hook should NOT have run
let marker = repo.root_path().join("hook_ran.txt");
assert!(
!marker.exists(),
"dry-run should not execute the hook command"
);
}
/// --dry-run shows named hooks with source:name labels
#[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()),
));
}
// ============================================================================
// Background Hook Execution Tests (post-start, post-switch)
// ============================================================================
#[rstest]
fn test_concurrent_hook_single_failure(repo: TestRepo) {
// Write project config with a hook that writes output before failing
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();
// Background spawning always succeeds (spawn succeeded, failure is logged)
assert!(
output.status.success(),
"wt hook post-start should succeed (spawns in background)"
);
// Wait for log files: runner log + per-command log (cmd-0, unnamed single command)
let log_dir = resolve_git_common_dir(repo.root_path()).join("wt/logs");
wait_for_file_count(&log_dir, "log", 2);
// Hook logs live at `{branch}/project/post-start/{name}.log`.
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 content to be written (command runs async)
wait_for_file_content(&cmd_log.path());
let log_content = fs::read_to_string(cmd_log.path()).unwrap();
// Verify the hook actually ran and wrote output (not just that file was created)
assert!(
log_content.contains("HOOK_OUTPUT_MARKER"),
"Log should contain hook output, got: {log_content}"
);
}
#[rstest]
fn test_concurrent_hook_multiple_failures(repo: TestRepo) {
// Write project config with multiple named hooks (table format).
// Map configs run as a concurrent group in one pipeline runner,
// each command producing its own log file.
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();
// Background spawning always succeeds (spawn succeeded)
assert!(
output.status.success(),
"wt hook post-start should succeed (spawns in background)"
);
// Wait for per-command log files: runner log + first + second
let log_dir = resolve_git_common_dir(repo.root_path()).join("wt/logs");
wait_for_file_count(&log_dir, "log", 3);
// Hook logs live at `{branch}/project/post-start/{name}.log`.
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();
// Verify each command's output is in its own log file
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) {
// Write user config with post-start hook (using table format for named hook)
repo.write_test_config(
r#"[post-start]
user = "echo 'USER_HOOK' > user_hook_ran.txt"
"#,
);
// Write project config with post-start hook
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)
);
// Both hooks spawn in background - wait for marker files
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) {
// Write project config with post-switch hook
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"
);
// Hook spawns in background - wait for marker file
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) {
// Write project config with multiple named hooks
repo.write_project_config(
r#"[post-start]
first = "echo 'FIRST' > first.txt"
second = "echo 'SECOND' > second.txt"
"#,
);
// Run only the "first" hook by name
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)
);
// First hook spawns in background - wait for marker file
let first_marker = repo.root_path().join("first.txt");
let second_marker = repo.root_path().join("second.txt");
wait_for_file_content(&first_marker);
// Fixed sleep for absence check - second hook should NOT have run
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) {
// Write project config with named hooks
repo.write_project_config(
r#"[post-start]
first = "echo 'FIRST'"
"#,
);
// Try to run a non-existent hook by name
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}"
);
// Should list available commands
assert!(
stderr.contains("project:first"),
"Error should list available commands, got: {stderr}"
);
}
#[rstest]
fn test_hook_multiple_name_filters(repo: TestRepo) {
// Write project config with three named hooks
repo.write_project_config(
r#"pre-merge = [
{first = "echo FIRST"},
{second = "echo SECOND"},
{third = "echo THIRD"},
]
"#,
);
// Run only "first" and "second" by passing multiple names — "third" should not run
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) {
// Write project config with named hooks
repo.write_project_config(
r#"[pre-merge]
first = "echo FIRST"
"#,
);
// Run with multiple names that don't match any configured hook
assert_cmd_snapshot!(
"hook_multiple_name_filters_none_match",
make_snapshot_cmd(&repo, "hook", &["pre-merge", "foo", "bar", "--yes"], None)
);
}
// ============================================================================
// Custom Variable (--var) Tests
// ============================================================================
#[rstest]
fn test_var_flag_overrides_template_variable(repo: TestRepo) {
// Write user config with a hook that uses a template variable
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ target }}' > target_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--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) {
// Write user config with a hook that uses multiple template variables
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ target }} {{ remote }}' > multi_var_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--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) {
// Write user config with a hook that uses the builtin branch variable
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ branch }}' > branch_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--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() {
// Test that invalid KEY=VALUE format is rejected
let output = crate::common::wt_command()
.args(["hook", "pre-start", "--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("expected KEY=VALUE"),
"Error should mention invalid format, got: {stderr}"
);
}
#[rstest]
fn test_var_flag_custom_variable(repo: TestRepo) {
// Custom variable names (not built-in template vars) are accepted and
// injected into the template context, matching alias behavior.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ custom_var }}' > custom_var_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--var", "custom_var=hello"])
.output()
.expect("Failed to run wt hook");
assert!(
output.status.success(),
"Custom variable should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let output_file = repo.root_path().join("custom_var_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("hello"),
"Custom variable should be expanded, got: {contents}"
);
}
#[rstest]
fn test_var_flag_last_value_wins(repo: TestRepo) {
// Test that when the same variable is specified multiple times, the last value wins
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ target }}' > target_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--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_shorthand_overrides_template_variable(repo: TestRepo) {
// `--KEY=VALUE` is equivalent to `--var KEY=VALUE` for template variables.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ branch }}' > shorthand_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--branch=SHORTHAND_BRANCH"])
.output()
.expect("Failed to run wt hook");
assert!(
output.status.success(),
"Hook should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let output_file = repo.root_path().join("shorthand_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("SHORTHAND_BRANCH"),
"Shorthand should override template variable, got: {contents}"
);
}
#[rstest]
fn test_var_shorthand_mixed_with_long_form(repo: TestRepo) {
// Shorthand and `--var` forms coexist in the same invocation.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ branch }} {{ target }}' > mixed_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--yes",
"--branch=SHORT",
"--var",
"target=LONG",
])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success());
let output_file = repo.root_path().join("mixed_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("SHORT") && contents.contains("LONG"),
"Both forms should coexist, got: {contents}"
);
}
#[rstest]
fn test_var_shorthand_custom_variable(repo: TestRepo) {
// Custom variable names (not built-in template vars) are accepted and
// injected into the template context, matching alias behavior. Hyphens in
// variable names are canonicalized to underscores.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ my_env }}' > custom_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--my-env=staging"])
.output()
.expect("Failed to run wt hook");
assert!(
output.status.success(),
"Custom variable should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let output_file = repo.root_path().join("custom_output.txt");
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("staging"),
"Custom variable with hyphens should be canonicalized and expanded, got: {contents}"
);
}
#[rstest]
fn test_shorthand_unreferenced_forwards_to_args(repo: TestRepo) {
// `--KEY=VALUE` shorthand for an unreferenced KEY is smart-routed to
// `{{ args }}` — the hook template captures the flag verbatim.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ args }}' > args_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--unused-var=value"])
.output()
.expect("Failed to run wt hook");
assert!(
output.status.success(),
"Hook should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let contents = fs::read_to_string(repo.root_path().join("args_output.txt")).unwrap();
assert!(
contents.contains("--unused-var=value"),
"Unreferenced shorthand should be forwarded to {{{{ args }}}}, got: {contents}"
);
}
#[rstest]
fn test_shorthand_referenced_binds_not_args(repo: TestRepo) {
// When KEY is referenced by any hook template, `--KEY=VALUE` binds
// `{{ KEY }}` and is NOT forwarded to `{{ args }}`.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ my_env }}:{{ args }}' > combined_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--my-env=staging"])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success());
let contents = fs::read_to_string(repo.root_path().join("combined_output.txt")).unwrap();
assert_eq!(contents.trim(), "staging:");
}
#[rstest]
fn test_post_double_dash_forwards_to_args(repo: TestRepo) {
// Tokens after `--` forward verbatim into `{{ args }}`.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ args }}' > dashdash_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--", "--fast", "extra"])
.output()
.expect("Failed to run wt hook");
assert!(
output.status.success(),
"Hook should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let contents = fs::read_to_string(repo.root_path().join("dashdash_output.txt")).unwrap();
assert!(
contents.contains("--fast") && contents.contains("extra"),
"Post-`--` tokens should forward verbatim to {{{{ args }}}}, got: {contents}"
);
}
#[rstest]
fn test_var_deprecation_warning(repo: TestRepo) {
// Explicit `--var` still force-binds but emits a deprecation warning
// pointing at `--KEY=VALUE` shorthand.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ my_env }}' > deprecated_output.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--var", "my_env=staging"])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("--var is deprecated"),
"Expected --var deprecation warning, got: {stderr}"
);
let contents = fs::read_to_string(repo.root_path().join("deprecated_output.txt")).unwrap();
assert_eq!(contents.trim(), "staging");
}
#[rstest]
fn test_args_indexing_and_length_in_hook_template(repo: TestRepo) {
// `{{ args }}` is a ShellArgs sequence — indexing, length, and iteration
// all work the same as in alias templates.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ args[0] | default('none') }}:{{ args | length }}' > args_seq.txt"
"#,
);
let output = repo
.wt_command()
.args(["hook", "pre-start", "--yes", "--", "first", "second"])
.output()
.expect("Failed to run wt hook");
assert!(output.status.success());
let contents = fs::read_to_string(repo.root_path().join("args_seq.txt")).unwrap();
assert_eq!(contents.trim(), "first:2");
}
#[rstest]
fn test_mixed_var_shorthand_and_forwarded_args(repo: TestRepo) {
// Explicit `--var` binds, referenced shorthand binds, unreferenced shorthand
// + post-`--` tokens forward — all coexist in one invocation without
// cross-contamination.
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ my_env }}|{{ override }}|{{ args }}' > mixed_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--yes",
"--my-env=prod",
"--var",
"override=forced",
"--unused=x",
"--",
"extra",
])
.output()
.expect("Failed to run wt hook");
assert!(
output.status.success(),
"Hook should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let contents = fs::read_to_string(repo.root_path().join("mixed_output.txt")).unwrap();
let trimmed = contents.trim();
assert!(
trimmed.starts_with("prod|forced|"),
"Expected my_env + override bound, got: {trimmed}"
);
assert!(
trimmed.contains("--unused=x") && trimmed.contains("extra"),
"Unreferenced + post-`--` tokens should forward to {{{{ args }}}}, got: {trimmed}"
);
}
#[test]
fn test_var_shorthand_does_not_leak_into_hook_show() {
// `wt hook show` doesn't accept `--var`, so shorthand preprocessing must
// leave its argv alone — an unknown flag should still produce clap's
// "unexpected argument" error, not a template-variable error.
let output = crate::common::wt_command()
.args(["hook", "show", "--branch=feature"])
.output()
.expect("Failed to run wt");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unexpected argument") || stderr.contains("--branch"),
"Expected clap to reject --branch on `hook show`, got: {stderr}"
);
}
#[rstest]
fn test_var_flag_deprecated_alias_works(repo: TestRepo) {
// Test that deprecated variable aliases (main_worktree, repo_root, worktree) can be overridden
repo.write_test_config(
r#"[pre-start]
test = "echo '{{ main_worktree }}' > alias_output.txt"
"#,
);
let output = repo
.wt_command()
.args([
"hook",
"pre-start",
"--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}"
);
}
// ============================================================================
// Hook Order Preservation Tests (Issue #737)
// ============================================================================
/// Test that user hooks execute in TOML insertion order, not alphabetical
/// See: https://github.com/max-sixty/worktrunk/issues/737
#[rstest]
fn test_user_hooks_preserve_toml_order(repo: TestRepo) {
// Write user config with hooks in specific order (NOT alphabetical: vscode, claude, copy, submodule)
// If order were alphabetical, it would be: claude, copy, submodule, vscode
// Table-form commands run concurrently; WORKTRUNK_TEST_SERIAL_CONCURRENT
// runs the group one command at a time in declaration order, so the file
// appends (and the snapshot) expose the parsed TOML order.
repo.write_test_config(
r#"[pre-start]
vscode = "echo '1' >> hook_order.txt"
claude = "echo '2' >> hook_order.txt"
copy = "echo '3' >> hook_order.txt"
submodule = "echo '4' >> hook_order.txt"
"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "feature"], None);
cmd.env("WORKTRUNK_TEST_SERIAL_CONCURRENT", "1");
assert_cmd_snapshot!("user_hooks_preserve_order", cmd);
});
// Verify execution order by reading the output file
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();
// Hooks should execute in TOML order: 1, 2, 3, 4
assert_eq!(
lines,
vec!["1", "2", "3", "4"],
"Hooks should execute in TOML insertion order (vscode, claude, copy, submodule)"
);
}
// ============================================================================
// User Pre-Switch Hook Tests
// ============================================================================
/// Test that a pre-switch hook executes before switching to an existing worktree
#[rstest]
fn test_user_pre_switch_hook_executes(mut repo: TestRepo) {
// Create a worktree to switch to
let _feature_wt = repo.add_worktree("feature");
// Write user config with pre-switch hook that creates a marker in the current worktree
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"]);
// Verify user hook ran in the source worktree (main), not the destination
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"
);
}
/// Test that a failing pre-switch hook blocks the switch (including --create)
#[rstest]
fn test_user_pre_switch_failure_blocks_switch(repo: TestRepo) {
// Write user config with failing pre-switch hook
repo.write_test_config(
r#"[pre-switch]
block = "exit 1"
"#,
);
// Failing pre-switch should prevent worktree creation
snapshot_switch("user_pre_switch_failure", &repo, &["--create", "feature"]);
// Worktree should NOT have been created
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"
);
}
/// Test that --no-hooks skips the pre-switch hook
#[rstest]
fn test_user_pre_switch_skipped_with_no_hooks(repo: TestRepo) {
// Write user config with pre-switch hook that creates a marker
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"],
);
// Pre-switch hook should NOT have run (--no-hooks skips all 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"
);
}
/// Test that `wt hook pre-switch` runs pre-switch hooks manually
#[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"
);
}
/// Test that `{{ branch }}` in pre-switch hooks is the destination branch argument, not the source.
#[rstest]
fn test_user_pre_switch_branch_var_is_destination(mut repo: TestRepo) {
let _feature_wt = repo.add_worktree("feature-dest");
// Write pre-switch hook that records {{ branch }} into a marker file
repo.write_test_config(
r#"[pre-switch]
check = "echo '{{ branch }}' > pre_switch_branch.txt"
"#,
);
snapshot_switch(
"user_pre_switch_branch_destination",
&repo,
&["feature-dest"],
);
// {{ branch }} should be the destination branch, not the source (main)
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(),
);
}
/// A symbolic switch argument (`@`, `-`, `^`) is resolved to its concrete branch
/// name before the pre-switch hook runs, so `{{ branch }}` carries the resolved
/// destination — not the literal token. Here `@` resolves to the current branch.
#[rstest]
fn test_user_pre_switch_branch_var_resolves_symbolic(repo: TestRepo) {
repo.write_test_config(
r#"[pre-switch]
check = "echo '{{ branch }}' > pre_switch_symbolic.txt"
"#,
);
// `@` resolves to the current branch (main), not the literal "@".
snapshot_switch("user_pre_switch_branch_symbolic", &repo, &["@"]);
let marker_file = repo.root_path().join("pre_switch_symbolic.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(),
"main",
"symbolic '@' should resolve to the concrete branch 'main', got: '{}'",
contents.trim(),
);
}
/// When removing the current worktree, post-switch hooks should fire
/// because the user is implicitly switched back to the primary worktree.
/// Regression test for https://github.com/max-sixty/worktrunk/issues/1450
///
/// Config is committed before creating the worktree, so both worktrees
/// have .config/wt.toml — isolating the bug to the deleted-cwd problem.
#[rstest]
fn test_remove_current_worktree_fires_post_switch_hook(mut repo: TestRepo) {
// Write and commit project config BEFORE creating the worktree,
// so the feature worktree also has .config/wt.toml
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");
// Remove from WITHIN the feature worktree (current worktree removal)
repo.wt_command()
.args(["remove", "feature", "--force-delete", "--yes"])
.current_dir(&feature_path)
.output()
.unwrap();
// Post-switch hook should fire in the primary worktree
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}"
);
}
// ==========================================================================
// Active model: directional template variables
// ==========================================================================
/// Pre-switch to existing worktree: worktree_path = destination (Active),
/// base_worktree_path = source, cwd = source.
#[rstest]
fn test_pre_switch_vars_point_to_destination(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
// Hook captures worktree_path, base_worktree_path, and cwd
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();
// worktree_path should be the destination (Active)
assert!(
content.contains(&format!("/{feature_name} "))
|| content.contains(&format!(r"\{feature_name} ")),
"worktree_path should point to destination '{feature_name}', got: {content}"
);
// base should be the source branch
assert!(
content.contains("base=main"),
"base should be source branch 'main', got: {content}"
);
// cwd should be the source (where the hook actually runs)
assert!(
content.contains(&format!("/{main_name}")) || content.contains(&format!(r"\{main_name}")),
"cwd should point to source worktree '{main_name}', got: {content}"
);
}
/// Regression test for #2309: `wt switch -` should resolve the symbolic
/// argument before setting up pre-switch hook template variables, so
/// `{{ target }}`, `{{ target_worktree_path }}`, and the Active bare vars
/// (`{{ worktree_path }}`, `{{ worktree_name }}`) reflect the actual destination
/// instead of the raw `-` argument or the source worktree.
#[rstest]
fn test_pre_switch_vars_with_dash_shortcut(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
// Establish switch history: main -> feature. This records main as previous,
// so `wt switch -` from feature resolves back to main.
repo.wt_command()
.args(["switch", "feature", "--yes"])
.current_dir(repo.root_path())
.output()
.unwrap();
// Install the pre-switch hook after the history-building switch so the
// capture reflects only the `-` switch we care about.
repo.write_test_config(
r#"[pre-switch]
capture = "echo 'target={{ target }} target_wt={{ target_worktree_path }} wt_path={{ worktree_path }} wt_name={{ worktree_name }}' > pre_switch_dash.txt"
"#,
);
let switch_output = repo
.wt_command()
.args(["switch", "-", "--yes"])
.current_dir(&feature_path)
.output()
.unwrap();
assert!(
switch_output.status.success(),
"`wt switch -` should succeed with a pre-switch hook referencing target_worktree_path.\n\
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&switch_output.stdout),
String::from_utf8_lossy(&switch_output.stderr),
);
let vars_file = feature_path.join("pre_switch_dash.txt");
let content = fs::read_to_string(&vars_file).unwrap();
let main_name = repo
.root_path()
.file_name()
.unwrap()
.to_string_lossy()
.to_string();
assert!(
content.contains("target=main"),
"{{{{ target }}}} should resolve to 'main' when using `-`, got: {content}"
);
// Both `target_worktree_path` and `worktree_path` should end at the main
// worktree directory — the layout is `... /<main_name> <next-field>=`.
assert!(
content.contains(&format!("/{main_name} wt_path="))
|| content.contains(&format!(r"\{main_name} wt_path=")),
"{{{{ target_worktree_path }}}} should point to the main worktree, got: {content}"
);
assert!(
content.contains(&format!("/{main_name} wt_name="))
|| content.contains(&format!(r"\{main_name} wt_name=")),
"{{{{ worktree_path }}}} should point to the main worktree (Active), got: {content}"
);
assert!(
content.contains(&format!("wt_name={main_name}")),
"{{{{ worktree_name }}}} should be the main worktree name, got: {content}"
);
}
/// Post-remove: target/target_worktree_path point to where user ends up.
#[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();
// branch should be the removed branch (Active)
assert!(
content.contains("branch=feature"),
"branch should be removed branch 'feature', got: {content}"
);
// target should be the destination branch (where user ends up)
assert!(
content.contains("target=main"),
"target should be destination 'main', got: {content}"
);
// target_worktree_path should be the primary worktree
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}"
);
}
/// Post-switch for existing switches: base vars reference the source worktree.
#[rstest]
fn test_post_switch_has_base_vars_for_existing(mut repo: TestRepo) {
let feature_path = repo.add_worktree("feature");
// Post-switch hooks run in the DESTINATION worktree (feature), so write
// to a path relative to the worktree that will exist after switch.
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();
// File is written in the destination (feature) worktree
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();
// branch should be the destination (Active)
assert!(
content.contains("branch=feature"),
"branch should be destination 'feature', got: {content}"
);
// base should be the source branch we switched from
assert!(
content.contains("base=main"),
"base should be source 'main', got: {content}"
);
}
/// cwd always exists on disk — even when worktree_path points to a deleted directory.
#[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}"
);
}
// ============================================================================
// Pipeline Tests (list form)
// ============================================================================
#[rstest]
fn test_user_post_start_pipeline_serial_ordering(repo: TestRepo) {
// Pipeline: serial step creates a marker, concurrent step reads it.
// Serial steps run in order, so the marker exists when the
// concurrent step runs.
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) {
// First step fails → second step should not run (pipeline aborts on failure).
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"],
);
// Give background commands time to run (if they were going to)
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) {
// Pipeline step 1 sets a var, step 2 uses it via {{ vars.name }}.
// Foreground mode exercises the in-process execution-time render path,
// which reads vars fresh from git config per step.
repo.write_test_config(
r#"post-start = [
"git config worktrunk.state.main.vars.name '{{ branch | sanitize }}-postgres'",
{ db = "echo {{ vars.name }} > lazy_expanded.txt" }
]
"#,
);
// Run the hook in foreground on the main worktree.
// Step 1 uses `git config` directly (avoids needing `wt` on PATH in CI).
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),
);
// With foreground, marker file should exist immediately
let marker_file = repo.root_path().join("lazy_expanded.txt");
assert!(
marker_file.exists(),
"Foreground render should create marker file"
);
let content = fs::read_to_string(&marker_file).unwrap().trim().to_string();
assert_eq!(
content, "main-postgres",
"Step 2 should see var set by prior step"
);
}
#[rstest]
fn test_user_post_start_pipeline_lazy_vars_background(repo: TestRepo) {
// Pipeline step 1 sets a var via git config (not `wt config` — bare `wt`
// isn't on PATH in the detached background process). Step 2 references
// {{ vars.name }}, which is expanded just-in-time by the background
// pipeline runner reading fresh vars from git config.
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) {
// Concurrent group: both commands should run and produce output.
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) {
// One command in a concurrent group fails. The other should still
// complete (pipeline waits for all children), and later steps should
// not run (group reported as failed).
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");
// The surviving command should complete despite the sibling failing.
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}"
);
// The step after the failed group should NOT run.
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) {
// Template values containing shell metacharacters must be safely
// escaped. Step 1 sets a var with spaces, quotes, and a dollar sign.
// Step 2 expands it into a shell command — without shell_escape=true,
// the value would be word-split or trigger expansion.
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" }
]
"#,
);
// Use foreground so we can check the result immediately.
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();
// The value should arrive intact — not word-split, not $HOME-expanded.
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}"
);
}
// ============================================================================
// Pipeline hook_name isolation (Bug 2 regression test)
// ============================================================================
#[rstest]
fn test_user_post_start_pipeline_hook_name_per_step(repo: TestRepo) {
// Each step in a pipeline should see its own hook_name, not the first step's name.
// Before the fix, step 2 would see step 1's hook_name because the shared pipeline
// context included hook_name from the first command's context_json.
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) {
// Post-switch with pipeline config, triggered by `wt switch --create`.
// This exercises the pipeline branch in `spawn_switch_background_hooks`,
// which spawns each hook type's pipeline independently.
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}"
);
}
// ============================================================================
// Post-remove pipeline (Bug 1 regression test)
// ============================================================================
#[rstest]
fn test_user_post_remove_pipeline_serial_ordering(mut repo: TestRepo) {
// Post-remove with a pipeline config should preserve serial ordering.
// Before the fix, prepare_post_remove_commands returned flat commands,
// so pipeline configs lost serial/concurrent semantics.
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()),
);
// Step 2 reads step 1's output. With pipeline semantics, step 2 runs after step 1.
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}"
);
}
// ============================================================================
// Name-filtered vars template (Bug 3 regression test)
// ============================================================================
#[rstest]
fn test_standalone_hook_name_filtered_vars_template(repo: TestRepo) {
// A pipeline step that uses {{ vars.X }} should expand correctly when
// name-filtered via `wt hook post-start db`. Before the fix, the flat
// spawn path passed the raw unexpanded template to the shell.
//
// vars.* are read from git config, so we pre-set the value.
repo.write_test_config(
r#"post-start = [
{ setup = "echo setup" },
{ db = "echo {{ vars.name }} > vars_filtered.txt" }
]
"#,
);
// Pre-set vars.name via git config (same mechanism as pipeline step 1 would use).
// Test repo starts on main branch.
repo.run_git(&["config", "worktrunk.state.main.vars.name", "test-db"]);
// Run just the 'db' step by name. This goes through the flat background path
// since name filtering bypasses the pipeline runner.
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_vars_template", cmd);
});
let marker_file = repo.root_path().join("vars_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",
"Template should expand {{ vars.name }} from git config"
);
}
/// Multi-remove hook announcements include the branch name for disambiguation
#[rstest]
fn test_multi_remove_hook_announcements_include_branch(repo: TestRepo) {
// fixture already has feature-a, feature-b, feature-c worktrees
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()),
);
}
/// Foreground hooks pass the directive file through to child processes,
/// so inner `wt switch --create` can write cd directives back to the
/// parent shell via the CD directive file.
#[rstest]
fn test_foreground_hook_passes_directive_file(repo: TestRepo) {
use crate::common::{configure_directive_files, directive_files, wt_bin};
repo.commit("initial");
let wt = wt_bin();
let wt_str = wt.to_string_lossy();
assert!(
!wt_str.contains('\''),
"wt binary path should not contain single quotes: {wt_str}"
);
let wt_toml = wt_str.replace('\\', r"\\");
// Pre-start hook that creates a new worktree via `wt switch --create`.
// If the CD directive file is passed through, the inner wt will write a
// path to it. If scrubbed, it prints the "shell integration not
// installed" hint instead.
repo.write_test_config(&format!(
r#"
[pre-start]
setup = "'{wt_toml}' switch --create hook-created --no-hooks"
"#,
));
let (cd_path, exec_path, _guard) = directive_files();
let mut cmd = repo.wt_command();
configure_directive_files(&mut cmd, &cd_path, &exec_path);
// Run the pre-start hook manually in foreground
cmd.args(["hook", "pre-start", "setup"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"hook failed: stdout={}\nstderr={}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
let cd_content = std::fs::read_to_string(&cd_path).unwrap_or_default();
assert!(
!cd_content.trim().is_empty(),
"foreground hook running `wt switch --create` should write a path to \
the CD directive file, got: {cd_content:?}"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.contains("shell integration"),
"inner wt should not warn about shell integration being uninstalled, got: {stderr}",
);
}
// ============================================================================
// Pre-* Pipeline Concurrent Execution Tests
// ============================================================================
/// Pipeline blocks in pre-* hooks run their concurrent commands concurrently.
/// The second block has two commands — both should produce output with prefixed
/// labels (the concurrent execution style).
#[rstest]
fn test_pre_merge_pipeline_concurrent_block(repo: TestRepo) {
repo.write_project_config(
r#"[[pre-merge]]
setup = "echo SETUP"
[[pre-merge]]
lint = "echo LINT"
test = "echo TEST"
"#,
);
repo.commit("Add pipeline pre-merge hooks");
let mut cmd = repo.wt_command();
cmd.args(["hook", "pre-merge", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"pre-merge pipeline should succeed.\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
let stderr = String::from_utf8_lossy(&output.stderr);
// All three commands should have run.
assert!(stderr.contains("SETUP"), "setup step should run: {stderr}");
assert!(stderr.contains("LINT"), "lint command should run: {stderr}");
assert!(stderr.contains("TEST"), "test command should run: {stderr}");
// Concurrent commands get prefixed labels (e.g., "lint │ LINT").
// Serial commands do not. The "│" separator confirms the concurrent path.
assert!(
stderr.contains("│ LINT") && stderr.contains("│ TEST"),
"concurrent block commands should have prefixed labels: {stderr}",
);
}
/// Table form (`[pre-merge]` with multiple keys) runs commands concurrently,
/// like every other multi-key step — `[pre-merge]` and a single `[[pre-merge]]`
/// block parse to the same `Concurrent` step.
#[rstest]
fn test_pre_merge_table_form_runs_concurrently(repo: TestRepo) {
repo.write_project_config(
r#"[pre-merge]
lint = "echo LINT"
test = "echo TEST"
"#,
);
repo.commit("Add table-form pre-merge hooks");
let mut cmd = repo.wt_command();
cmd.args(["hook", "pre-merge", "--yes"]);
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"pre-merge table form should succeed.\nstderr: {}",
String::from_utf8_lossy(&output.stderr),
);
// Concurrent commands get prefixed labels (e.g., "lint │ LINT").
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("│ LINT") && stderr.contains("│ TEST"),
"table-form commands should run concurrently (prefixed labels): {stderr}",
);
}
/// Under `-v`, hooks print a grouped table of resolved template variables —
/// see issue #2309 for why this helps users understand scope-dependent gaps.
#[rstest]
fn test_hook_verbose_prints_variable_table(mut repo: TestRepo) {
repo.add_worktree("feature");
repo.write_test_config(
r#"[pre-switch]
noop = "true"
"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd =
make_snapshot_cmd_with_global_flags(&repo, "switch", &["feature"], None, &["-v"]);
assert_cmd_snapshot!("hook_verbose_variable_table", cmd);
});
}
/// Background-path variable dump dedups per hook type when both user and
/// project configs contribute to the same hook — the table prints once, not
/// once per source. Also exercises the `PreparedStep::Single` arm of
/// `print_background_variable_tables`, which the all-named-commands case in
/// `test_post_start_verbose_shows_per_hook_output` doesn't hit.
#[rstest]
fn test_hook_verbose_background_dedups_across_sources(repo: TestRepo) {
repo.write_test_config(r#"post-start = "echo user-hook""#);
repo.write_project_config(r#"post-start = "echo project-hook""#);
repo.commit("add post-start");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo project-hook"]
"#,
);
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd_with_global_flags(
&repo,
"switch",
&["--create", "feature"],
None,
&["-v"],
);
assert_cmd_snapshot!("hook_verbose_background_dedup", cmd);
});
}
// ============================================================================
// Docs-page example snapshot
//
// See tests/integration_tests/merge.rs header comment for the docs-example
// convention — `<!-- wt hook pre-merge (docs-example) -->` in `src/cli/mod.rs`.
// ============================================================================
/// `wt hook pre-merge` example for `docs/content/hook.md` — two named
/// pre-merge hooks (test, lint) running mocked `cargo` commands.
#[rstest]
fn test_docs_hook_pre_merge(repo: TestRepo) {
repo.run_git(&["config", "worktrunk.hints.worktree-path", "true"]);
let bin_dir = repo.root_path().join(".bin");
fs::create_dir_all(&bin_dir).unwrap();
crate::common::mock_commands::create_mock_cargo(&bin_dir);
repo.write_project_config(
r#"[[pre-merge]]
test = "cargo test"
[[pre-merge]]
lint = "cargo clippy"
"#,
);
repo.run_git(&["add", ".config", ".bin"]);
repo.run_git(&["commit", "-m", "Add project config"]);
let mut paths: Vec<std::path::PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
paths.insert(0, bin_dir.clone());
let new_path = std::env::join_paths(&paths).unwrap();
let bin_dir_str = bin_dir.to_string_lossy().into_owned();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
assert_cmd_snapshot!("docs_hook_pre_merge", {
let mut cmd = make_snapshot_cmd(&repo, "hook", &["pre-merge", "--yes"], None);
cmd.env("PATH", &new_path);
cmd.env("WORKTRUNK_TEST_MOCK_CONFIG_DIR", &bin_dir_str);
cmd
});
});
}
// ============================================================================
// Git-discovery env scrubbing (issue #3373)
// ============================================================================
//
// wt forwards inherited `GIT_*` discovery vars (GIT_DIR, GIT_WORK_TREE, …)
// into the commands it spawns. For wt's own internal git plumbing that is
// intentional (#1914), but a *user hook* is meant to operate on the worktree
// wt sets as its cwd — so a hook that shells out to `git` must discover the
// repo from that cwd, not from a `GIT_DIR`/`GIT_WORK_TREE` wt happened to
// inherit (e.g. wt run as a `!wt` git alias, or nested under another tool's
// git hook). The concrete harm: with both `GIT_DIR` and `GIT_WORK_TREE`
// present, a hook that runs `git init` (common in test-harness fixtures)
// writes `core.worktree` into the inherited repo's config, silently
// redirecting every later plain git command in that repo.
//
// These tests set a git-discovery context that is *consistent* with the repo
// wt operates on (GIT_DIR = repo's gitdir, GIT_WORK_TREE = repo root), so wt
// itself runs normally, then assert the spawned hook does not see the vars.
// The hook records `$GIT_DIR`/`$GIT_WORK_TREE` — empty once scrubbed — as
// `[<git_dir>][<work_tree>]`; a fully-scrubbed environment yields `[][]`.
/// Hook body recording the `GIT_DIR`/`GIT_WORK_TREE` it sees to `marker`.
/// Unset vars expand to empty, so a scrubbed environment writes `[][]`.
fn record_git_env_cmd(marker: &str) -> String {
format!(r#"printf '[%s][%s]' \"$GIT_DIR\" \"$GIT_WORK_TREE\" > {marker}"#)
}
/// Assert a `record_git_env_cmd` marker shows both discovery vars scrubbed.
fn assert_git_env_scrubbed(marker: &std::path::Path) {
let seen = fs::read_to_string(marker).unwrap();
assert_eq!(
seen, "[][]",
"hook inherited git-discovery vars ([GIT_DIR][GIT_WORK_TREE]): {seen}"
);
}
/// Run `wt hook <args>` with an inherited (but repo-consistent) git-discovery
/// context, matching what a `!wt` git alias leaves in the environment.
fn run_hook_with_inherited_git_env(repo: &TestRepo, args: &[&str]) -> std::process::Output {
repo.wt_command()
.args(args)
.env("GIT_DIR", repo.root_path().join(".git"))
.env("GIT_WORK_TREE", repo.root_path())
.output()
.unwrap()
}
#[rstest]
fn test_foreground_hook_does_not_inherit_git_discovery_vars(repo: TestRepo) {
repo.write_test_config(&format!(
"[pre-merge]\nrecord = \"{}\"\n",
record_git_env_cmd("env_seen.txt")
));
let output = run_hook_with_inherited_git_env(&repo, &["hook", "pre-merge", "--yes"]);
assert!(
output.status.success(),
"pre-merge hook run failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let marker = repo.root_path().join("env_seen.txt");
assert!(marker.exists(), "foreground hook did not run");
assert_git_env_scrubbed(&marker);
}
#[rstest]
fn test_background_hook_does_not_inherit_git_discovery_vars(repo: TestRepo) {
repo.write_test_config(&format!(
"[post-merge]\nrecord = \"{}\"\n",
record_git_env_cmd("env_seen.txt")
));
let output = run_hook_with_inherited_git_env(&repo, &["hook", "post-merge", "--yes"]);
assert!(
output.status.success(),
"post-merge hook dispatch failed: {}",
String::from_utf8_lossy(&output.stderr)
);
// post-* hooks run detached; poll for the marker before reading it.
let marker = repo.root_path().join("env_seen.txt");
wait_for_file_content(&marker);
assert_git_env_scrubbed(&marker);
}
#[rstest]
fn test_concurrent_hook_does_not_inherit_git_discovery_vars(repo: TestRepo) {
// A multi-command hook table runs as a concurrent group (foreground for a
// pre-* hook), exercising the separate concurrent spawn path.
repo.write_test_config(&format!(
"[pre-merge]\nrecord = \"{}\"\nnoop = \"true\"\n",
record_git_env_cmd("env_seen.txt")
));
let output = run_hook_with_inherited_git_env(&repo, &["hook", "pre-merge", "--yes"]);
assert!(
output.status.success(),
"concurrent pre-merge hook run failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let marker = repo.root_path().join("env_seen.txt");
assert!(marker.exists(), "concurrent hook did not run");
assert_git_env_scrubbed(&marker);
}