use crate::common::{
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 std::time::Duration; use tempfile::TempDir;
const SLEEP_FOR_ABSENCE_CHECK: Duration = Duration::from_millis(500);
fn snapshot_switch(test_name: &str, repo: &TestRepo, args: &[&str]) {
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);
});
}
#[rstest]
fn test_post_create_no_config(repo: TestRepo) {
snapshot_switch("post_create_no_config", &repo, &["--create", "feature"]);
}
#[rstest]
fn test_post_create_single_command(repo: TestRepo) {
repo.write_project_config(r#"post-create = "echo 'Setup complete'""#);
repo.commit("Add config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'Setup complete'"]
"#,
);
snapshot_switch(
"post_create_single_command",
&repo,
&["--create", "feature"],
);
}
#[rstest]
fn test_post_create_named_commands(repo: TestRepo) {
repo.write_project_config(
r#"[post-create]
install = "echo 'Installing deps'"
setup = "echo 'Running setup'"
"#,
);
repo.commit("Add config with named commands");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Installing deps'",
"echo 'Running setup'",
]
"#,
);
snapshot_switch(
"post_create_named_commands",
&repo,
&["--create", "feature"],
);
}
#[rstest]
fn test_post_create_failing_command(repo: TestRepo) {
repo.write_project_config(r#"post-create = "exit 1""#);
repo.commit("Add config with failing command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["exit 1"]
"#,
);
snapshot_switch(
"post_create_failing_command",
&repo,
&["--create", "feature"],
);
}
#[rstest]
fn test_post_create_template_expansion(repo: TestRepo) {
repo.write_project_config(
r#"[post-create]
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");
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",
]
"#,
);
snapshot_switch(
"post_create_template_expansion",
&repo,
&["--create", "feature/test"],
);
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();
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
);
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_create_verbose_template_expansion(repo: TestRepo) {
repo.write_project_config(
r#"[post-create]
setup = "echo 'Setting up {{ branch | sanitize }} in {{ worktree_path }}'"
"#,
);
repo.commit("Add config with templates");
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 }}'",
]
"#,
);
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_create_verbose_template_expansion", cmd);
});
}
#[rstest]
fn test_post_create_default_branch_template(repo: TestRepo) {
repo.write_project_config(
r#"post-create = "echo 'Default: {{ default_branch }}' > default.txt""#,
);
repo.commit("Add config with default_branch template");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'Default: {{ default_branch }}' > default.txt"]
"#,
);
snapshot_switch(
"post_create_default_branch_template",
&repo,
&["--create", "feature", "--yes"],
);
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_create_git_variables_template(#[from(repo_with_remote)] repo: TestRepo) {
repo.git_command()
.args(["push", "-u", "origin", "main"])
.run()
.expect("failed to push");
repo.write_project_config(
r#"[post-create]
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");
snapshot_switch(
"post_create_git_variables_template",
&repo,
&["--create", "feature", "--yes"],
);
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();
assert!(
contents.contains("Commit: ")
&& contents.lines().any(|l| {
l.starts_with("Commit: ") && l.len() == 48 }),
"Should contain expanded commit SHA, got: {}",
contents
);
assert!(
contents.contains("Short: ")
&& contents.lines().any(|l| {
l.starts_with("Short: ") && l.len() == 14 }),
"Should contain expanded short_commit SHA, got: {}",
contents
);
assert!(
contents.contains("Remote: origin"),
"Should contain expanded remote name, got: {}",
contents
);
assert!(
contents.contains("Worktree Name: repo.feature"),
"Should contain expanded worktree_name, got: {}",
contents
);
}
#[rstest]
fn test_post_create_upstream_template(#[from(repo_with_remote)] repo: TestRepo) {
repo.git_command()
.args(["push", "-u", "origin", "main"])
.run()
.expect("failed to push main");
repo.write_project_config(r#"post-create = "echo 'Upstream: {{ upstream }}' > upstream.txt""#);
repo.commit("Add config with upstream template");
snapshot_switch(
"post_create_upstream_template",
&repo,
&["--create", "feature", "--yes"],
);
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_create_upstream_conditional(#[from(repo_with_remote)] repo: TestRepo) {
repo.git_command()
.args(["push", "-u", "origin", "main"])
.run()
.expect("failed to push main");
repo.write_project_config(
r#"post-create = "{% if not upstream %}echo 'no-upstream' > upstream.txt{% else %}echo '{{ upstream }}' > upstream.txt{% endif %}""#,
);
repo.commit("Add config with conditional upstream");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["{% if not upstream %}echo 'no-upstream' > upstream.txt{% else %}echo '{{ upstream }}' > upstream.txt{% endif %}"]
"#,
);
snapshot_switch(
"post_create_upstream_conditional",
&repo,
&["--create", "feature", "--yes"],
);
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_create_base_variables(repo: TestRepo) {
repo.write_project_config(
r#"[post-create]
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");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Base: {{ base }}' > base_info.txt",
"echo 'Base Path: {{ base_worktree_path }}' >> base_info.txt",
]
"#,
);
snapshot_switch(
"post_create_base_variables",
&repo,
&["--create", "feature", "--base", "main"],
);
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();
assert!(
contents.contains("Base: main"),
"Should contain expanded base branch, got: {}",
contents
);
assert!(
contents.contains("Base Path: "),
"Should have base_worktree_path line, got: {}",
contents
);
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();
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_post_create_json_stdin(repo: TestRepo) {
use crate::common::wt_command;
repo.write_project_config(r#"post-create = "cat > context.json""#);
repo.commit("Add config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["cat > context.json"]
"#,
);
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)
);
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();
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_create_script_reads_json(repo: TestRepo) {
use crate::common::wt_command;
use std::os::unix::fs::PermissionsExt;
let scripts_dir = repo.root_path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let script_content = r#"#!/usr/bin/env python3
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_path = scripts_dir.join("setup.py");
fs::write(&script_path, script_content).unwrap();
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).unwrap();
repo.write_project_config(
r#"[post-create]
setup = "./scripts/setup.py"
"#,
);
repo.commit("Add setup script and config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["./scripts/setup.py"]
"#,
);
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)
);
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;
repo.write_project_config(r#"post-start = "cat > context.json""#);
repo.commit("Add config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["cat > context.json"]
"#,
);
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)
);
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"
);
}
#[rstest]
fn test_post_start_single_background_command(repo: TestRepo) {
repo.write_project_config(
r#"post-start = "sleep 0.1 && echo 'Background task done' > background.txt""#,
);
repo.commit("Add background command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["sleep 0.1 && echo 'Background task done' > background.txt"]
"#,
);
snapshot_switch(
"post_start_single_background",
&repo,
&["--create", "feature"],
);
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());
let output_file = worktree_path.join("background.txt");
wait_for_file(output_file.as_path());
}
#[rstest]
fn test_post_start_verbose_shows_per_hook_output(repo: TestRepo) {
repo.write_project_config(
r#"[post-start]
setup = "echo 'verbose test' > verbose.txt"
"#,
);
repo.commit("Add background command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'verbose test' > verbose.txt"]
"#,
);
snapshot_switch(
"post_start_verbose_output",
&repo,
&["-v", "--create", "feature"],
);
}
#[rstest]
fn test_post_start_multiple_background_commands(repo: TestRepo) {
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");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Task 1 running' > task1.txt",
"echo 'Task 2 running' > task2.txt",
]
"#,
);
snapshot_switch(
"post_start_multiple_background",
&repo,
&["--create", "feature"],
);
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_post_create_and_post_start(repo: TestRepo) {
repo.write_project_config(
r#"post-create = "echo 'Setup done' > setup.txt"
[post-start]
server = "sleep 0.05 && echo 'Server running' > server.txt"
"#,
);
repo.commit("Add both command types");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = [
"echo 'Setup done' > setup.txt",
"sleep 0.05 && echo 'Server running' > server.txt",
]
"#,
);
snapshot_switch("both_create_and_start", &repo, &["--create", "feature"]);
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_file(worktree_path.join("server.txt").as_path());
}
#[rstest]
fn test_invalid_toml(repo: TestRepo) {
repo.write_project_config("post-create = [invalid syntax\n");
repo.commit("Add invalid config");
snapshot_switch("invalid_toml", &repo, &["--create", "feature"]);
}
#[rstest]
fn test_post_start_log_file_captures_output(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo 'stdout output' && echo 'stderr output' >&2""#);
repo.commit("Add command with stdout/stderr");
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"],
);
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", 2);
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_file_lines(&cmd_log, 2);
let log_contents = fs::read_to_string(&cmd_log).unwrap();
assert_snapshot!(log_contents, @"
stdout output
stderr output
");
}
#[rstest]
fn test_post_start_invalid_command_handling(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo 'unclosed quote""#);
repo.commit("Add invalid command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'unclosed quote"]
"#,
);
snapshot_switch(
"post_start_invalid_command",
&repo,
&["--create", "feature"],
);
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) {
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");
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"]);
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);
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) {
repo.write_project_config(r#"post-start = "echo 'Background task' > background.txt""#);
repo.commit("Add background command");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'Background task' > background.txt"]
"#,
);
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");
assert!(
worktree_path.join("execute.txt").exists(),
"Execute command should run synchronously"
);
wait_for_file(worktree_path.join("background.txt").as_path());
}
#[rstest]
fn test_post_start_complex_shell_commands(repo: TestRepo) {
repo.write_project_config(
r#"post-start = "echo 'line1\nline2\nline3' | grep line2 > filtered.txt""#,
);
repo.commit("Add complex shell 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"]);
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) {
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");
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"],
);
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_create_multiline_with_control_structures(repo: TestRepo) {
repo.write_project_config(
r#"post-create = """
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");
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_create_multiline_control_structure",
&repo,
&["--create", "feature"],
);
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");
}
#[rstest]
fn test_post_start_skipped_on_existing_worktree(repo: TestRepo) {
repo.write_project_config(r#"post-start = "echo 'POST-START-RAN' > post_start_marker.txt""#);
repo.commit("Add post-start config");
repo.write_test_approvals(
r#"[projects."../origin"]
approved-commands = ["echo 'POST-START-RAN' > post_start_marker.txt"]
"#,
);
snapshot_switch(
"post_start_create_with_command",
&repo,
&["--create", "feature"],
);
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());
fs::remove_file(&marker_file).unwrap();
snapshot_switch("post_start_skip_existing", &repo, &["feature"]);
thread::sleep(SLEEP_FOR_ABSENCE_CHECK);
assert!(
!marker_file.exists(),
"Post-start should NOT run when switching to existing worktree"
);
}
#[rstest]
fn test_post_start_project_pipeline(repo: TestRepo) {
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");
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) {
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) {
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) {
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"]
"#,
);
repo.wt_command()
.args(["switch", "--create", "feature", "--no-hooks", "--yes"])
.output()
.expect("failed to pre-create 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) {
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");
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"));
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"));
}
#[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) {
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"));
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"
);
}