Files
max-sixty__worktrunk/tests/integration_tests/post_start_commands.rs
Maximilian Roos 9be99ee21b test: consolidate absence-window sleeps behind a shared SLEEP_FOR_ABSENCE_CHECK (#3206)
Absence assertions (verifying something did NOT happen) need a fixed
window rather than polling, since there's no event to wait for. That
window was expressed three inconsistent ways across the test suite: a
per-file `const SLEEP_FOR_ABSENCE_CHECK` duplicated in `user_hooks.rs`
and `post_start_commands.rs`, and bare `Duration::from_millis(500)`
literals scattered through `switch.rs`, `step_alias.rs`, `remove.rs`,
`bare_repository.rs`, and `merge.rs` — none greppable as a group, and
`merge.rs` sitting below the documented 500ms floor at 200ms.

This promotes `SLEEP_FOR_ABSENCE_CHECK` to `src/testing/mod.rs`, next to
the presence helper `wait_for_file`, re-exported via `tests/common`, and
routes all 15 absence sleeps through it. The constant's doc comment
states the discriminator that decides the tool: a *presence* assertion
polls (and returns the instant the event lands); an *absence* assertion
has no event to wait for, so it holds a bounded window. The three sleeps
left as literals are genuinely different roles — bash startup, signal
sequencing between two SIGINTs, and PTY output sequencing — not absence
checks.

It also rewrites the `tests/CLAUDE.md` timing section around that
polarity discriminator (split into Presence / Absence subsections), with
the absence example using the constant, plus two traps: pairing one
sleep with both a presence and an absence assertion (the presence half
goes flaky), and structural absence — when the event is gated on a
condition the test never sets up, drop the window and poll the positive
precondition instead.

This is the consistency-and-guidance follow-up to the watchdog
de-flaking in #3187: the marker convention makes legitimate absence
sleeps self-labeling, which is what lets a reviewer (or the nightly
sweep) tell them apart from a flaky fixed sleep before a presence
assertion.

## Testing

No behavior change — test-infrastructure and docs only. Each of the 15
conversions was verified to be followed by a negative assertion
(adversarial review, 20/20 confirmed); the full `pre-merge` gate passes
(4170 tests). `merge.rs`'s window widened 200ms → 500ms, which only
makes its absence check more conservative.

> _This was written by Claude Code on behalf of max_

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:41:57 -07:00

1557 lines
49 KiB
Rust

use crate::common::{
SLEEP_FOR_ABSENCE_CHECK, TestRepo, make_snapshot_cmd, make_snapshot_cmd_with_global_flags,
repo, repo_with_remote, resolve_git_common_dir, set_temp_home_env, setup_snapshot_settings,
wait_for_file, wait_for_file_content, wait_for_file_count, wait_for_file_lines,
wait_for_valid_json,
};
use insta::assert_snapshot;
use insta_cmd::assert_cmd_snapshot;
use rstest::rstest;
use std::fs;
use std::thread;
use tempfile::TempDir;
/// Helper to create snapshot with normalized paths and SHAs
///
/// Tests should write to repo.test_config_path() to pre-approve commands.
/// Uses an isolated HOME to prevent tests from being affected by developer's shell config.
fn snapshot_switch(test_name: &str, repo: &TestRepo, args: &[&str]) {
// Create isolated HOME to ensure test determinism
let temp_home = TempDir::new().unwrap();
let settings = setup_snapshot_settings(repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd(repo, "switch", args, None);
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!(test_name, cmd);
});
}
// ============================================================================
// Post-Create Command Tests (sequential, blocking)
// ============================================================================
#[rstest]
fn test_post_start_no_config(repo: TestRepo) {
// Switch without project config should work normally
snapshot_switch("post_start_no_config", &repo, &["--create", "feature"]);
}
#[rstest]
fn test_post_start_single_command(repo: TestRepo) {
// Create project config with a single command (string format)
repo.write_project_config(r#"pre-start = "echo 'Setup complete'""#);
repo.commit("Add config");
// Pre-approve the command by writing to the isolated test config
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'Setup complete'"]
"#,
);
// Command should execute without prompting
snapshot_switch("post_start_single_command", &repo, &["--create", "feature"]);
}
/// A config that uses the deprecated `pre-create` hook key loads and runs: the
/// key is silently migrated to canonical `pre-start`. Phase 1 of the rename
/// emits no warning (see https://github.com/max-sixty/worktrunk/issues/2838).
#[rstest]
fn test_deprecated_create_hook_key_runs_silently(repo: TestRepo) {
repo.write_project_config(r#"pre-create = "echo ran > marker.txt""#);
repo.commit("Add deprecated pre-create hook");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo ran > marker.txt"]
"#,
);
let temp_home = TempDir::new().unwrap();
let mut cmd = repo.wt_command();
cmd.args(["switch", "--create", "feature"])
.current_dir(repo.root_path());
set_temp_home_env(&mut cmd, temp_home.path());
let output = cmd.output().unwrap();
assert!(
output.status.success(),
"switch with a deprecated pre-create hook should succeed, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
// The hook ran — the `pre-create` key was migrated to canonical `pre-start`.
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
assert!(
worktree_path.join("marker.txt").exists(),
"deprecated pre-create hook should have run"
);
// Phase 1 migrates silently: no deprecation warning, and no stray
// "run wt config show" hint (the check_and_migrate empty-warnings guard).
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!stderr.to_lowercase().contains("deprecated") && !stderr.contains("config show"),
"no deprecation warning or hint expected in phase 1, got: {stderr}"
);
}
#[rstest]
fn test_post_start_named_commands(repo: TestRepo) {
// Create project config with named commands (table format)
repo.write_project_config(
r#"[pre-start]
install = "echo 'Installing deps'"
setup = "echo 'Running setup'"
"#,
);
repo.commit("Add config with named commands");
// Pre-approve both commands in temp HOME
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Installing deps'",
"echo 'Running setup'",
]
"#,
);
// Table-form commands run concurrently with prefixed output;
// WORKTRUNK_TEST_SERIAL_CONCURRENT pins the line order for the snapshot.
let temp_home = TempDir::new().unwrap();
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");
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!("post_start_named_commands", cmd);
});
}
#[rstest]
fn test_post_start_failing_command(repo: TestRepo) {
// Create project config with a command that will fail
repo.write_project_config(r#"pre-start = "exit 1""#);
repo.commit("Add config with failing command");
// Pre-approve the command in temp HOME
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["exit 1"]
"#,
);
// A failing pre-start hook aborts with FailFast
snapshot_switch(
"post_start_failing_command",
&repo,
&["--create", "feature"],
);
}
#[rstest]
fn test_post_start_template_expansion(repo: TestRepo) {
// Create project config with template variables. Pipeline form: the
// steps append to one file, so they must run serially.
repo.write_project_config(
r#"pre-start = [
{ repo = "echo 'Repo: {{ repo }}' > info.txt" },
{ branch = "echo 'Branch: {{ branch }}' >> info.txt" },
{ hash_port = "echo 'Port: {{ branch | hash_port }}' >> info.txt" },
{ worktree = "echo 'Worktree: {{ worktree_path }}' >> info.txt" },
{ root = "echo 'Root: {{ repo_path }}' >> info.txt" },
]
"#,
);
repo.commit("Add config with templates");
// Pre-approve all commands in isolated test config
let repo_name = "repo";
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}""#);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Repo: {{ repo }}' > info.txt",
"echo 'Branch: {{ branch }}' >> info.txt",
"echo 'Port: {{ branch | hash_port }}' >> info.txt",
"echo 'Worktree: {{ worktree_path }}' >> info.txt",
"echo 'Root: {{ repo_path }}' >> info.txt",
]
"#,
);
// Commands should execute with expanded templates
snapshot_switch(
"post_start_template_expansion",
&repo,
&["--create", "feature/test"],
);
// Verify template expansion actually worked by checking the output file
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join(format!("{}.feature-test", repo_name));
let info_file = worktree_path.join("info.txt");
assert!(
info_file.exists(),
"info.txt should have been created in the worktree"
);
let contents = fs::read_to_string(&info_file).unwrap();
// Verify that template variables were actually expanded
assert!(
contents.contains(&format!("Repo: {}", repo_name)),
"Should contain expanded repo name, got: {}",
contents
);
assert!(
contents.contains("Branch: feature/test"),
"Should contain raw branch name, got: {}",
contents
);
// Verify port is a valid number in the expected range (10000-19999)
let port_line = contents
.lines()
.find(|l| l.starts_with("Port: "))
.expect("Should contain port line");
let port: u16 = port_line
.strip_prefix("Port: ")
.unwrap()
.parse()
.expect("Port should be a valid number");
assert!(
(10000..20000).contains(&port),
"Port should be in range 10000-19999, got: {}",
port
);
}
#[rstest]
fn test_post_start_verbose_template_expansion(repo: TestRepo) {
// Test that -v shows template expansion for pre-start hooks
repo.write_project_config(
r#"[pre-start]
setup = "echo 'Setting up {{ branch | sanitize }} in {{ worktree_path }}'"
"#,
);
repo.commit("Add config with templates");
// Pre-approve commands
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}""#);
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Setting up {{ branch | sanitize }} in {{ worktree_path }}'",
]
"#,
);
// Create isolated HOME to ensure test determinism
let temp_home = tempfile::TempDir::new().unwrap();
let settings = setup_snapshot_settings(&repo);
settings.bind(|| {
let mut cmd = make_snapshot_cmd_with_global_flags(
&repo,
"switch",
&["--create", "verbose-hooks"],
None,
&["-v"],
);
set_temp_home_env(&mut cmd, temp_home.path());
assert_cmd_snapshot!("post_start_verbose_template_expansion", cmd);
});
}
#[rstest]
fn test_post_start_default_branch_template(repo: TestRepo) {
// Create project config with default_branch template variable
repo.write_project_config(
r#"pre-start = "echo 'Default: {{ default_branch }}' > default.txt""#,
);
repo.commit("Add config with default_branch template");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'Default: {{ default_branch }}' > default.txt"]
"#,
);
// Create a feature branch worktree (--yes skips approval prompt)
snapshot_switch(
"post_start_default_branch_template",
&repo,
&["--create", "feature", "--yes"],
);
// Verify template expansion actually worked
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let default_file = worktree_path.join("default.txt");
assert!(
default_file.exists(),
"default.txt should have been created in the worktree"
);
let contents = fs::read_to_string(&default_file).unwrap();
assert!(
contents.contains("Default: main"),
"Should contain expanded default_branch, got: {}",
contents
);
}
#[rstest]
fn test_post_start_git_variables_template(#[from(repo_with_remote)] repo: TestRepo) {
// Set up an upstream tracking branch
repo.git_command()
.args(["push", "-u", "origin", "main"])
.run()
.expect("failed to push");
// Create project config with git-related template variables. Pipeline
// form: the steps append to one file, so they must run serially.
repo.write_project_config(
r#"pre-start = [
{ commit = "echo 'Commit: {{ commit }}' > git_vars.txt" },
{ short = "echo 'Short: {{ short_commit }}' >> git_vars.txt" },
{ remote = "echo 'Remote: {{ remote }}' >> git_vars.txt" },
{ worktree_name = "echo 'Worktree Name: {{ worktree_name }}' >> git_vars.txt" },
]
"#,
);
repo.commit("Add config with git template variables");
// Create a feature branch worktree (--yes skips approval prompt)
snapshot_switch(
"post_start_git_variables_template",
&repo,
&["--create", "feature", "--yes"],
);
// Verify template expansion actually worked
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let vars_file = worktree_path.join("git_vars.txt");
assert!(
vars_file.exists(),
"git_vars.txt should have been created in the worktree"
);
let contents = fs::read_to_string(&vars_file).unwrap();
// Verify commit variable (should be 40 char hex)
assert!(
contents.contains("Commit: ")
&& contents.lines().any(|l| {
l.starts_with("Commit: ") && l.len() == 48 // "Commit: " (8) + 40 hex chars
}),
"Should contain expanded commit SHA, got: {}",
contents
);
// Verify short_commit variable (should be 7 char hex)
assert!(
contents.contains("Short: ")
&& contents.lines().any(|l| {
l.starts_with("Short: ") && l.len() == 14 // "Short: " (7) + 7 hex chars
}),
"Should contain expanded short_commit SHA, got: {}",
contents
);
// Verify remote variable
assert!(
contents.contains("Remote: origin"),
"Should contain expanded remote name, got: {}",
contents
);
// Verify worktree_name variable (basename of worktree path)
assert!(
contents.contains("Worktree Name: repo.feature"),
"Should contain expanded worktree_name, got: {}",
contents
);
}
#[rstest]
fn test_post_start_upstream_template(#[from(repo_with_remote)] repo: TestRepo) {
// Push main to set up tracking
repo.git_command()
.args(["push", "-u", "origin", "main"])
.run()
.expect("failed to push main");
// Create project config with upstream template variable
// Note: {{ upstream }} errors when the new branch has no upstream tracking.
// The new feature branch won't have an upstream until it's pushed with -u.
// This test verifies the error case - see test_post_start_upstream_conditional for the fix.
repo.write_project_config(r#"pre-start = "echo 'Upstream: {{ upstream }}' > upstream.txt""#);
repo.commit("Add config with upstream template");
// Create a feature branch - it won't have upstream tracking configured yet
snapshot_switch(
"post_start_upstream_template",
&repo,
&["--create", "feature", "--yes"],
);
// Hook command should have errored due to undefined `upstream` variable
// (new branches don't have upstream tracking until pushed with -u)
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let upstream_file = worktree_path.join("upstream.txt");
assert!(
!upstream_file.exists(),
"upstream.txt should NOT have been created (command errored)"
);
}
#[rstest]
fn test_post_start_upstream_conditional(#[from(repo_with_remote)] repo: TestRepo) {
// Push main to set up tracking
repo.git_command()
.args(["push", "-u", "origin", "main"])
.run()
.expect("failed to push main");
// Create project config with conditional upstream check
// Using {% if not upstream %} allows safe handling of undefined variables
repo.write_project_config(
r#"pre-start = "{% if not upstream %}echo 'no-upstream' > upstream.txt{% else %}echo '{{ upstream }}' > upstream.txt{% endif %}""#,
);
repo.commit("Add config with conditional upstream");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["{% if not upstream %}echo 'no-upstream' > upstream.txt{% else %}echo '{{ upstream }}' > upstream.txt{% endif %}"]
"#,
);
// Create a feature branch - it won't have upstream tracking configured yet
snapshot_switch(
"post_start_upstream_conditional",
&repo,
&["--create", "feature", "--yes"],
);
// Verify the conditional worked - new branch has no upstream, so "no-upstream" should be written
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let upstream_file = worktree_path.join("upstream.txt");
assert!(
upstream_file.exists(),
"upstream.txt should have been created (conditional worked)"
);
let contents = fs::read_to_string(&upstream_file).unwrap();
assert_eq!(
contents.trim(),
"no-upstream",
"Should contain 'no-upstream' since feature branch has no upstream tracking"
);
}
#[rstest]
fn test_post_start_base_variables(repo: TestRepo) {
// Create project config with base template variables. Pipeline form: the
// steps append to one file, so they must run serially.
repo.write_project_config(
r#"pre-start = [
{ base = "echo 'Base: {{ base }}' > base_info.txt" },
{ base_path = "echo 'Base Path: {{ base_worktree_path }}' >> base_info.txt" },
]
"#,
);
repo.commit("Add config with base template variables");
// Pre-approve the commands
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Base: {{ base }}' > base_info.txt",
"echo 'Base Path: {{ base_worktree_path }}' >> base_info.txt",
]
"#,
);
// Create a feature branch worktree from main
snapshot_switch(
"post_start_base_variables",
&repo,
&["--create", "feature", "--base", "main"],
);
// Verify template expansion actually worked
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let base_file = worktree_path.join("base_info.txt");
assert!(
base_file.exists(),
"base_info.txt should have been created in the worktree"
);
let contents = fs::read_to_string(&base_file).unwrap();
// Verify base variable (branch name we branched from)
assert!(
contents.contains("Base: main"),
"Should contain expanded base branch, got: {}",
contents
);
// Verify base_worktree_path variable (path to main worktree)
// The path should contain the repo root (main worktree is at repo root)
assert!(
contents.contains("Base Path: "),
"Should have base_worktree_path line, got: {}",
contents
);
// The base_worktree_path should be the main worktree's path (POSIX format)
let base_path_line = contents
.lines()
.find(|l| l.starts_with("Base Path: "))
.expect("Should have Base Path line");
let base_path = base_path_line.strip_prefix("Base Path: ").unwrap();
// Convert expected path to POSIX format for comparison
let expected_base_path = worktrunk::path::to_posix_path(&repo.root_path().to_string_lossy());
assert_eq!(
base_path, expected_base_path,
"Base path should match main worktree path"
);
}
#[rstest]
fn test_pre_start_json_stdin(repo: TestRepo) {
use crate::common::wt_command;
// Create project config with a command that reads JSON from stdin
// Use cat to capture stdin to a file
repo.write_project_config(r#"pre-start = "cat > context.json""#);
repo.commit("Add config");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["cat > context.json"]
"#,
);
// Create worktree - this should pipe JSON to the hook's stdin
let temp_home = TempDir::new().unwrap();
let mut cmd = wt_command();
cmd.args(["switch", "--create", "feature-json"])
.current_dir(repo.root_path())
.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path())
.env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path());
set_temp_home_env(&mut cmd, temp_home.path());
let output = cmd.output().expect("failed to run wt switch");
assert!(
output.status.success(),
"wt switch should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Find the worktree and read the JSON
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature-json");
let json_file = worktree_path.join("context.json");
assert!(
json_file.exists(),
"context.json should have been created from stdin"
);
let contents = fs::read_to_string(&json_file).unwrap();
// Parse and verify the JSON contains expected fields
let json: serde_json::Value = serde_json::from_str(&contents)
.unwrap_or_else(|e| panic!("Should be valid JSON: {}\nContents: {}", e, contents));
assert!(
json.get("repo").is_some(),
"JSON should contain 'repo' field"
);
assert!(
json.get("branch").is_some(),
"JSON should contain 'branch' field"
);
assert_eq!(
json["branch"].as_str(),
Some("feature-json"),
"Branch should be sanitized (feature-json)"
);
assert!(
json.get("worktree").is_some(),
"JSON should contain 'worktree' field"
);
assert!(
json.get("repo_root").is_some(),
"JSON should contain 'repo_root' field"
);
assert_eq!(
json["hook_type"].as_str(),
Some("pre-start"),
"JSON should contain hook_type"
);
}
#[rstest]
#[cfg(unix)]
fn test_post_start_script_reads_json(repo: TestRepo) {
use crate::common::wt_command;
use std::os::unix::fs::PermissionsExt;
// Create a scripts directory and a Python script that reads JSON from stdin
let scripts_dir = repo.root_path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
// Resolve `python3` on PATH and bake the absolute path into the shebang.
// `#!/usr/bin/env python3` doesn't work in the nix sandbox where
// `/usr/bin/env` doesn't exist; the kernel runs the literal interpreter
// path, so shebang resolution can't piggyback on PATH directly.
let python = which::which("python3").expect("python3 in PATH for test");
let script_body = r#"
import json
import sys
ctx = json.load(sys.stdin)
with open('hook_output.txt', 'w') as f:
f.write(f"repo={ctx['repo']}\n")
f.write(f"branch={ctx['branch']}\n")
f.write(f"hook_type={ctx['hook_type']}\n")
f.write(f"hook_name={ctx.get('hook_name', 'unnamed')}\n")
"#;
let script_content = format!("#!{}{}", python.display(), script_body);
let script_path = scripts_dir.join("setup.py");
fs::write(&script_path, script_content).unwrap();
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
// Create project config that runs the script
repo.write_project_config(
r#"[pre-start]
setup = "./scripts/setup.py"
"#,
);
repo.commit("Add setup script and config");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["./scripts/setup.py"]
"#,
);
// Create worktree
let temp_home = TempDir::new().unwrap();
let mut cmd = wt_command();
cmd.args(["switch", "--create", "feature-script"])
.current_dir(repo.root_path())
.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path())
.env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path());
set_temp_home_env(&mut cmd, temp_home.path());
let output = cmd.output().expect("failed to run wt switch");
assert!(
output.status.success(),
"wt switch should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Find the worktree and verify the script wrote the expected output
let worktree_path = repo
.root_path()
.parent()
.unwrap()
.join("repo.feature-script");
let output_file = worktree_path.join("hook_output.txt");
assert!(
output_file.exists(),
"Script should have created hook_output.txt"
);
let contents = fs::read_to_string(&output_file).unwrap();
assert!(
contents.contains("repo=repo"),
"Output should contain repo name: {}",
contents
);
assert!(
contents.contains("branch=feature-script"),
"Output should contain branch: {}",
contents
);
assert!(
contents.contains("hook_type=pre-start"),
"Output should contain hook_type: {}",
contents
);
assert!(
contents.contains("hook_name=setup"),
"Output should contain hook_name: {}",
contents
);
}
#[rstest]
fn test_post_start_json_stdin(repo: TestRepo) {
use crate::common::wt_command;
// Create project config with a background command that reads JSON from stdin
repo.write_project_config(r#"post-start = "cat > context.json""#);
repo.commit("Add config");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["cat > context.json"]
"#,
);
// Create worktree
let temp_home = TempDir::new().unwrap();
let mut cmd = wt_command();
cmd.args(["switch", "--create", "bg-json"])
.current_dir(repo.root_path())
.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path())
.env("WORKTRUNK_APPROVALS_PATH", repo.test_approvals_path());
set_temp_home_env(&mut cmd, temp_home.path());
let output = cmd.output().expect("failed to run wt switch");
assert!(
output.status.success(),
"wt switch should succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
// Find the worktree and wait for valid JSON (polls until cat finishes writing)
let worktree_path = repo.root_path().parent().unwrap().join("repo.bg-json");
let json_file = worktree_path.join("context.json");
let json = wait_for_valid_json(&json_file);
assert_eq!(
json["branch"].as_str(),
Some("bg-json"),
"Background hook should receive JSON with branch"
);
assert!(
json.get("repo").is_some(),
"Background hook should receive JSON with repo"
);
assert_eq!(
json["hook_type"].as_str(),
Some("post-start"),
"Background hook should receive hook_type"
);
}
// ============================================================================
// Post-Start Command Tests (parallel, background)
// ============================================================================
#[rstest]
fn test_post_start_single_background_command(repo: TestRepo) {
// Create project config with a background command
repo.write_project_config(
r#"post-start = "sleep 0.1 && echo 'Background task done' > background.txt""#,
);
repo.commit("Add background command");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["sleep 0.1 && echo 'Background task done' > background.txt"]
"#,
);
// Command should spawn in background (wt exits immediately)
snapshot_switch(
"post_start_single_background",
&repo,
&["--create", "feature"],
);
// Verify log file was created in the common git directory
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let git_common_dir = resolve_git_common_dir(&worktree_path);
let log_dir = git_common_dir.join("wt/logs");
assert!(log_dir.exists());
// Wait for the background command to complete
let output_file = worktree_path.join("background.txt");
wait_for_file(output_file.as_path());
}
/// Test that -v shows verbose per-hook output for background hooks
#[rstest]
fn test_post_start_verbose_shows_per_hook_output(repo: TestRepo) {
// Create project config with a background command
repo.write_project_config(
r#"[post-start]
setup = "echo 'verbose test' > verbose.txt"
"#,
);
repo.commit("Add background command");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'verbose test' > verbose.txt"]
"#,
);
// With -v, should show detailed per-hook output with command in gutter
snapshot_switch(
"post_start_verbose_output",
&repo,
&["-v", "--create", "feature"],
);
}
#[rstest]
fn test_post_start_multiple_background_commands(repo: TestRepo) {
// Create project config with multiple background commands (table format)
repo.write_project_config(
r#"[post-start]
task1 = "echo 'Task 1 running' > task1.txt"
task2 = "echo 'Task 2 running' > task2.txt"
"#,
);
repo.commit("Add multiple background commands");
// Pre-approve both commands
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Task 1 running' > task1.txt",
"echo 'Task 2 running' > task2.txt",
]
"#,
);
// Commands should spawn in parallel
snapshot_switch(
"post_start_multiple_background",
&repo,
&["--create", "feature"],
);
// Wait for both background commands
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
wait_for_file(worktree_path.join("task1.txt").as_path());
wait_for_file(worktree_path.join("task2.txt").as_path());
}
#[rstest]
fn test_both_pre_start_and_post_start(repo: TestRepo) {
// Create project config with both a blocking and a background creation hook
repo.write_project_config(
r#"pre-start = "echo 'Setup done' > setup.txt"
[post-start]
server = "sleep 0.05 && echo 'Server running' > server.txt"
"#,
);
repo.commit("Add both command types");
// Pre-approve all commands
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Setup done' > setup.txt",
"sleep 0.05 && echo 'Server running' > server.txt",
]
"#,
);
// pre-start runs first (blocking), then post-start (background)
snapshot_switch("both_pre_and_post_start", &repo, &["--create", "feature"]);
// Setup file should exist immediately (pre-start is blocking)
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
assert!(
worktree_path.join("setup.txt").exists(),
"Post-create command should have completed before wt exits"
);
// Wait for background command
wait_for_file(worktree_path.join("server.txt").as_path());
}
#[rstest]
fn test_invalid_toml(repo: TestRepo) {
// Create invalid TOML
repo.write_project_config("pre-start = [invalid syntax\n");
repo.commit("Add invalid config");
// Should continue without executing commands, showing warning
snapshot_switch("invalid_toml", &repo, &["--create", "feature"]);
}
// ============================================================================
// Additional Coverage Tests
// ============================================================================
#[rstest]
fn test_post_start_log_file_captures_output(repo: TestRepo) {
// Create command that writes to both stdout and stderr
repo.write_project_config(r#"post-start = "echo 'stdout output' && echo 'stderr output' >&2""#);
repo.commit("Add command with stdout/stderr");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'stdout output' && echo 'stderr output' >&2"]
"#,
);
snapshot_switch(
"post_start_log_captures_output",
&repo,
&["--create", "feature"],
);
// Wait for log file to be created (not just the directory)
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let git_common_dir = resolve_git_common_dir(&worktree_path);
let log_dir = git_common_dir.join("wt/logs");
// 2 log files: runner log + per-command log (cmd-0, unnamed single command)
wait_for_file_count(&log_dir, "log", 2);
// Find the command log file at `{branch}/project/post-start/cmd-0-*.log`.
let post_start_dir = log_dir
.join(worktrunk::path::sanitize_for_filename("feature"))
.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())
.map(|e| e.path())
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("cmd-0"))
})
.expect("Should have a cmd-0 log file");
// Wait for both lines — `&&` sequences two writes (stdout, then stderr),
// so file size > 0 can hit after only the first landed.
wait_for_file_lines(&cmd_log, 2);
let log_contents = fs::read_to_string(&cmd_log).unwrap();
// Verify both stdout and stderr were captured
assert_snapshot!(log_contents, @"
stdout output
stderr output
");
}
#[rstest]
fn test_post_start_invalid_command_handling(repo: TestRepo) {
// Create command with syntax error (missing quote)
repo.write_project_config(r#"post-start = "echo 'unclosed quote""#);
repo.commit("Add invalid command");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'unclosed quote"]
"#,
);
// wt should still complete successfully even if background command has errors
snapshot_switch(
"post_start_invalid_command",
&repo,
&["--create", "feature"],
);
// Verify worktree was created despite command error
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
assert!(
worktree_path.exists(),
"Worktree should be created even if post-start command fails"
);
}
#[rstest]
fn test_post_start_multiple_commands_separate_logs(repo: TestRepo) {
// Create multiple background commands with distinct output
repo.write_project_config(
r#"[post-start]
task1 = "echo 'TASK1_OUTPUT'"
task2 = "echo 'TASK2_OUTPUT'"
task3 = "echo 'TASK3_OUTPUT'"
"#,
);
repo.commit("Add three background commands");
// Pre-approve all commands
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'TASK1_OUTPUT'",
"echo 'TASK2_OUTPUT'",
"echo 'TASK3_OUTPUT'",
]
"#,
);
snapshot_switch("post_start_separate_logs", &repo, &["--create", "feature"]);
// Each command gets its own log file (task1, task2, task3) plus one runner log.
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let git_common_dir = resolve_git_common_dir(&worktree_path);
let log_dir = git_common_dir.join("wt/logs");
wait_for_file_count(&log_dir, "log", 4);
// Verify each task's output is in its own log file. Hook logs live at
// `{branch}/project/post-start/{task}.log` in the nested layout.
let post_start_dir = log_dir
.join(worktrunk::path::sanitize_for_filename("feature"))
.join("project")
.join("post-start");
let log_files: Vec<_> = fs::read_dir(&post_start_dir)
.unwrap_or_else(|e| panic!("reading {post_start_dir:?}: {e}"))
.filter_map(|e| e.ok())
.collect();
for (task, expected) in [
("task1", "TASK1_OUTPUT"),
("task2", "TASK2_OUTPUT"),
("task3", "TASK3_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} in {post_start_dir:?}"));
wait_for_file_content(&log_file.path());
let contents = fs::read_to_string(log_file.path()).unwrap();
assert!(
contents.contains(expected),
"Log for {task} should contain {expected}, got: {contents}"
);
}
}
#[rstest]
fn test_execute_flag_with_post_start_commands(repo: TestRepo) {
// Create post-start command
repo.write_project_config(r#"post-start = "echo 'Background task' > background.txt""#);
repo.commit("Add background command");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'Background task' > background.txt"]
"#,
);
// Use --execute flag along with post-start command
snapshot_switch(
"execute_with_post_start",
&repo,
&[
"--create",
"feature",
"--execute",
"echo 'Execute flag' > execute.txt",
],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
// Execute flag file should exist immediately (synchronous)
assert!(
worktree_path.join("execute.txt").exists(),
"Execute command should run synchronously"
);
// Wait for background command to complete
wait_for_file(worktree_path.join("background.txt").as_path());
}
#[rstest]
fn test_post_start_complex_shell_commands(repo: TestRepo) {
// Create command with pipes and redirects
repo.write_project_config(
r#"post-start = "echo 'line1\nline2\nline3' | grep line2 > filtered.txt""#,
);
repo.commit("Add complex shell command");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'line1\nline2\nline3' | grep line2 > filtered.txt"]
"#,
);
snapshot_switch("post_start_complex_shell", &repo, &["--create", "feature"]);
// Wait for background command to create the file AND flush content
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let filtered_file = worktree_path.join("filtered.txt");
wait_for_file_content(filtered_file.as_path());
let contents = fs::read_to_string(&filtered_file).unwrap();
assert_snapshot!(contents, @"line2");
}
#[rstest]
fn test_post_start_multiline_commands_with_newlines(repo: TestRepo) {
// Create command with actual newlines (using TOML triple-quoted string)
repo.write_project_config(
r#"post-start = """
echo 'first line' > multiline.txt
echo 'second line' >> multiline.txt
echo 'third line' >> multiline.txt
"""
"#,
);
repo.commit("Add multiline command with actual newlines");
// Pre-approve the command
let multiline_cmd = "echo 'first line' > multiline.txt
echo 'second line' >> multiline.txt
echo 'third line' >> multiline.txt
";
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["""
{}"""]
"#,
multiline_cmd
));
snapshot_switch(
"post_start_multiline_with_newlines",
&repo,
&["--create", "feature"],
);
// Wait for background command to write all 3 lines (not just the first)
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let output_file = worktree_path.join("multiline.txt");
wait_for_file_lines(output_file.as_path(), 3);
let contents = fs::read_to_string(&output_file).unwrap();
assert_snapshot!(contents, @"
first line
second line
third line
");
}
#[rstest]
fn test_post_start_multiline_with_control_structures(repo: TestRepo) {
// Test multiline command with if-else control structure
repo.write_project_config(
r#"pre-start = """
if [ ! -f test.txt ]; then
echo 'File does not exist' > result.txt
else
echo 'File exists' > result.txt
fi
"""
"#,
);
repo.commit("Add multiline control structure");
// Pre-approve the command
let multiline_cmd = "if [ ! -f test.txt ]; then
echo 'File does not exist' > result.txt
else
echo 'File exists' > result.txt
fi
";
repo.write_test_config(r#"worktree-path = "../{{ repo }}.{{ branch }}""#);
repo.write_test_approvals(&format!(
r#"[projects."../origin"]
approved-commands = ["""
{}"""]
"#,
multiline_cmd
));
snapshot_switch(
"post_start_multiline_control_structure",
&repo,
&["--create", "feature"],
);
// Verify the command executed correctly
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let result_file = worktree_path.join("result.txt");
assert!(
result_file.exists(),
"Control structure command should create result file"
);
let contents = fs::read_to_string(&result_file).unwrap();
assert_snapshot!(contents, @"File does not exist");
}
// ============================================================================
// Regression Tests
// ============================================================================
///
/// This is a regression test for a bug where post-start commands were running on ALL
/// `wt switch` operations instead of only on `wt switch --create`.
#[rstest]
fn test_post_start_skipped_on_existing_worktree(repo: TestRepo) {
// Create project config with post-start command
repo.write_project_config(r#"post-start = "echo 'POST-START-RAN' > post_start_marker.txt""#);
repo.commit("Add post-start config");
// Pre-approve the command
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'POST-START-RAN' > post_start_marker.txt"]
"#,
);
// First: Create worktree - post-start SHOULD run
snapshot_switch(
"post_start_create_with_command",
&repo,
&["--create", "feature"],
);
// Wait for background post-start command to complete
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker_file = worktree_path.join("post_start_marker.txt");
wait_for_file(marker_file.as_path());
// Remove the marker file to detect if post-start runs again
fs::remove_file(&marker_file).unwrap();
// Second: Switch to EXISTING worktree - post-start should NOT run
snapshot_switch("post_start_skip_existing", &repo, &["feature"]);
// Wait to ensure no background command starts (testing absence requires fixed wait)
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
// Verify post-start did NOT run when switching to existing worktree
assert!(
!marker_file.exists(),
"Post-start should NOT run when switching to existing worktree"
);
}
// ============================================================================
// Pipeline Tests (project config, list form)
// ============================================================================
#[rstest]
fn test_post_start_project_pipeline(repo: TestRepo) {
// Project config with pipeline: serial setup, then concurrent tasks
repo.write_project_config(
r#"post-start = [
"echo SETUP > setup_marker.txt",
{ task1 = "cat setup_marker.txt > task1_saw_setup.txt", task2 = "echo TASK2 > task2.txt" }
]
"#,
);
repo.commit("Add pipeline config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo SETUP > setup_marker.txt",
"cat setup_marker.txt > task1_saw_setup.txt",
"echo TASK2 > task2.txt",
]
"#,
);
snapshot_switch(
"post_start_project_pipeline",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
// task1 reads the setup marker — verifies serial-before-concurrent ordering
let task1_file = worktree_path.join("task1_saw_setup.txt");
wait_for_file_content(&task1_file);
let content = fs::read_to_string(&task1_file).unwrap();
assert!(
content.contains("SETUP"),
"Concurrent task should see serial step's output, got: {content}"
);
}
#[rstest]
fn test_post_start_pipeline_with_template_vars(repo: TestRepo) {
// Pipeline with template variable expansion
repo.write_project_config(
r#"post-start = [
"echo {{ branch }} > branch_marker.txt",
{ check = "cat branch_marker.txt > branch_check.txt" }
]
"#,
);
repo.commit("Add pipeline with templates");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo {{ branch }} > branch_marker.txt",
"cat branch_marker.txt > branch_check.txt",
]
"#,
);
snapshot_switch(
"post_start_pipeline_template_vars",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let check_file = worktree_path.join("branch_check.txt");
wait_for_file_content(&check_file);
let content = fs::read_to_string(&check_file).unwrap();
assert!(
content.contains("feature"),
"Template variable should be expanded in pipeline, got: {content}"
);
}
#[rstest]
fn test_post_start_target_variable_expands_to_branch(repo: TestRepo) {
// `{{ target }}` in post-start should equal the bare `{{ branch }}` —
// symmetric with `pre-switch`, which already injects `target`.
repo.write_project_config(r#"post-start = "echo '{{ target }}' > target_marker.txt""#);
repo.commit("Add post-start with target var");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo '{{ target }}' > target_marker.txt"]
"#,
);
snapshot_switch(
"post_start_target_variable",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker = worktree_path.join("target_marker.txt");
wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert_eq!(
content.trim(),
"feature",
"target should resolve to the newly created branch, got: {content}"
);
}
#[rstest]
fn test_post_switch_target_variable_on_existing_switch(repo: TestRepo) {
// `{{ target }}` in post-switch must equal the destination branch for
// switches to an existing worktree — not just creates.
repo.write_project_config(r#"post-switch = "echo '{{ target }}' > target_marker.txt""#);
repo.commit("Add post-switch with target var");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo '{{ target }}' > target_marker.txt"]
"#,
);
// Create the destination worktree first, then switch back to main so the
// next invocation exercises the existing-worktree switch path (not create).
repo.wt_command()
.args(["switch", "--create", "feature", "--no-hooks", "--yes"])
.output()
.expect("failed to pre-start feature worktree");
repo.wt_command()
.args(["switch", "main", "--no-hooks", "--yes"])
.output()
.expect("failed to switch back to main");
snapshot_switch("post_switch_target_variable_existing", &repo, &["feature"]);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let marker = worktree_path.join("target_marker.txt");
wait_for_file_content(&marker);
let content = fs::read_to_string(&marker).unwrap();
assert_eq!(
content.trim(),
"feature",
"target should resolve to the switched-to branch, got: {content}"
);
}
#[rstest]
fn test_post_start_mixed_user_pipeline_project_flat(repo: TestRepo) {
// User has a pipeline, project has flat concurrent commands.
// Both should execute.
repo.write_test_config(
r#"post-start = [
"echo USER_SETUP > user_pipeline_marker.txt",
{ user_bg = "echo USER_BG > user_bg.txt" }
]
"#,
);
repo.write_project_config(
r#"[post-start]
proj = "echo PROJECT > project_marker.txt"
"#,
);
repo.commit("Add project config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo PROJECT > project_marker.txt"]
"#,
);
snapshot_switch(
"post_start_mixed_user_pipeline_project_flat",
&repo,
&["--create", "feature"],
);
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
// User pipeline should run
wait_for_file_content(&worktree_path.join("user_bg.txt"));
let user_bg = fs::read_to_string(worktree_path.join("user_bg.txt")).unwrap();
assert!(user_bg.contains("USER_BG"));
// Project flat hook should also run
wait_for_file_content(&worktree_path.join("project_marker.txt"));
let project = fs::read_to_string(worktree_path.join("project_marker.txt")).unwrap();
assert!(project.contains("PROJECT"));
}
/// `WORKTRUNK_TEST_SERIAL_CONCURRENT=1` makes the background pipeline runner's
/// concurrent group run commands one at a time in declaration order. The two
/// commands append to the same file, so a deterministic ordering proves they
/// ran serially (a true concurrent run could interleave the appends). The
/// failing-first-command variant additionally exercises the bail-on-failure
/// path inside the serial branch — second never gets to run.
#[rstest]
fn test_post_start_concurrent_serial_force(repo: TestRepo) {
repo.write_project_config(
r#"[post-start]
first = "echo FIRST >> serial_order.txt"
second = "echo SECOND >> serial_order.txt"
"#,
);
repo.commit("Add concurrent post-start");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo FIRST >> serial_order.txt",
"echo SECOND >> serial_order.txt",
]
"#,
);
let temp_home = TempDir::new().unwrap();
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "feature"], None);
cmd.env("WORKTRUNK_TEST_SERIAL_CONCURRENT", "1");
set_temp_home_env(&mut cmd, temp_home.path());
let _ = cmd.output().unwrap();
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
let order_file = worktree_path.join("serial_order.txt");
wait_for_file_lines(&order_file, 2);
let content = fs::read_to_string(&order_file).unwrap();
assert_eq!(
content, "FIRST\nSECOND\n",
"serial run should append in declaration order"
);
}
#[rstest]
fn test_post_start_concurrent_serial_bails_on_failure(repo: TestRepo) {
// First command writes a marker then fails; second writes a marker that
// would always exist if it ran. Serial mode bails after the first failure,
// so the second marker should be absent.
repo.write_project_config(
r#"[post-start]
first = "echo FIRST > first_marker.txt && false"
second = "echo SECOND > second_marker.txt"
"#,
);
repo.commit("Add failing post-start");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo FIRST > first_marker.txt && false",
"echo SECOND > second_marker.txt",
]
"#,
);
let temp_home = TempDir::new().unwrap();
let mut cmd = make_snapshot_cmd(&repo, "switch", &["--create", "feature"], None);
cmd.env("WORKTRUNK_TEST_SERIAL_CONCURRENT", "1");
set_temp_home_env(&mut cmd, temp_home.path());
let _ = cmd.output().unwrap();
let worktree_path = repo.root_path().parent().unwrap().join("repo.feature");
wait_for_file_content(&worktree_path.join("first_marker.txt"));
// Wait for any trailing background work, then assert the second never ran.
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!worktree_path.join("second_marker.txt").exists(),
"serial mode should bail on first failure — second command must not run"
);
}