#![cfg(all(unix, feature = "shell-integration-tests"))]
use crate::common::pty::{build_pty_command, exec_cmd_in_pty_prompted};
use crate::common::{TestRepo, add_pty_binary_path_filters, add_pty_filters, repo, wt_bin};
use insta::assert_snapshot;
use rstest::rstest;
use std::path::Path;
fn exec_wt_in_pty(
repo: &TestRepo,
args: &[&str],
env_vars: &[(String, String)],
input: &str,
) -> (String, i32) {
exec_wt_in_pty_cwd(repo.root_path(), args, env_vars, input)
}
fn exec_wt_in_pty_cwd(
cwd: &Path,
args: &[&str],
env_vars: &[(String, String)],
input: &str,
) -> (String, i32) {
let cmd = build_pty_command(wt_bin().to_str().unwrap(), args, cwd, env_vars, None);
exec_cmd_in_pty_prompted(cmd, &[input], "[y/N")
}
fn approval_pty_settings(repo: &TestRepo) -> insta::Settings {
let mut settings = crate::common::setup_snapshot_settings(repo);
add_pty_filters(&mut settings);
add_pty_binary_path_filters(&mut settings);
settings.add_filter(r"/var/folders/[^\s]+/test-config\.toml", "[CONFIG]");
settings
}
fn test_env_vars_with_shell(repo: &TestRepo) -> Vec<(String, String)> {
let mut env_vars = repo.test_env_vars();
env_vars.push(("SHELL".to_string(), "/bin/zsh".to_string()));
env_vars
}
#[rstest]
fn test_approval_prompt_accept(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-approve"],
&env_vars,
"y\n",
);
assert_eq!(exit_code, 0);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_accept", &output);
});
}
#[rstest]
fn test_approval_prompt_decline(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-decline"],
&env_vars,
"n\n",
);
assert_eq!(exit_code, 0);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_decline", &output);
});
}
#[rstest]
fn test_approval_prompt_multiple_commands(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"pre-start = [
{first = "echo 'First command'"},
{second = "echo 'Second command'"},
{third = "echo 'Third command'"},
]
"#,
);
repo.commit("Add config");
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-multi"],
&env_vars,
"y\n",
);
assert_eq!(exit_code, 0);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_multiple_commands", &output);
});
}
#[rstest]
fn test_approval_prompt_permission_error(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
repo.configure_shell_integration();
let approvals_dir = repo.home_path().join("readonly-approvals");
let approvals_path = approvals_dir.join("approvals.toml");
#[cfg(unix)]
{
use std::fs;
use std::os::unix::fs::PermissionsExt;
fs::create_dir_all(&approvals_dir).unwrap();
let mut perms = fs::metadata(&approvals_dir).unwrap().permissions();
perms.set_mode(0o555); fs::set_permissions(&approvals_dir, perms).unwrap();
let test_file = approvals_dir.join("test_write");
if fs::write(&test_file, "test").is_ok() {
let _ = fs::remove_file(&test_file);
let mut perms = fs::metadata(&approvals_dir).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&approvals_dir, perms).unwrap();
eprintln!("Skipping permission test - running with elevated privileges");
return;
}
}
let mut env_vars = test_env_vars_with_shell(&repo);
env_vars.push((
"WORKTRUNK_APPROVALS_PATH".to_string(),
approvals_path.display().to_string(),
));
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-permission"],
&env_vars,
"y\n",
);
assert_eq!(
exit_code, 0,
"Command should succeed even when saving approval fails"
);
assert!(
output.contains("Failed to save command approval"),
"Should show permission error warning"
);
assert!(
output.contains("Approval will be requested again next time"),
"Should show hint about approval being requested again"
);
assert!(
output.contains("test command"),
"Command should still execute despite save failure"
);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_permission_error", &output);
});
}
#[rstest]
fn test_approval_prompt_named_commands(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"pre-start = [
{install = "echo 'Installing dependencies...'"},
{build = "echo 'Building project...'"},
{test = "echo 'Running tests...'"},
]
"#,
);
repo.commit("Add config");
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-named"],
&env_vars,
"y\n",
);
assert_eq!(exit_code, 0);
assert!(
output.contains("install") && output.contains("Installing dependencies"),
"Should show command name 'install' and execute it"
);
assert!(
output.contains("build") && output.contains("Building project"),
"Should show command name 'build' and execute it"
);
assert!(
output.contains("test") && output.contains("Running tests"),
"Should show command name 'test' and execute it"
);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_named_commands", &output);
});
}
#[rstest]
fn test_approval_prompt_mixed_approved_unapproved_accept(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"pre-start = [
{first = "echo 'First command'"},
{second = "echo 'Second command'"},
{third = "echo 'Third command'"},
]
"#,
);
repo.commit("Add config");
repo.write_test_approvals(&format!(
r#"[projects.'{}']
approved-commands = ["echo 'Second command'"]
"#,
repo.project_id()
));
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-mixed-accept"],
&env_vars,
"y\n",
);
assert_eq!(exit_code, 0);
assert!(
output.contains("execute") && output.contains("2") && output.contains("command"),
"Should show 2 unapproved commands in prompt"
);
assert!(
output.contains("First command"),
"Should execute first command"
);
assert!(
output.contains("Second command"),
"Should execute pre-approved second command"
);
assert!(
output.contains("Third command"),
"Should execute third command"
);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_mixed_approved_unapproved_accept", &output);
});
}
#[rstest]
fn test_approval_prompt_mixed_approved_unapproved_decline(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(
r#"pre-start = [
{first = "echo 'First command'"},
{second = "echo 'Second command'"},
{third = "echo 'Third command'"},
]
"#,
);
repo.commit("Add config");
repo.write_test_approvals(&format!(
r#"[projects.'{}']
approved-commands = ["echo 'Second command'"]
"#,
repo.project_id()
));
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(
&repo,
&["switch", "--create", "test-mixed-decline"],
&env_vars,
"n\n",
);
assert_eq!(
exit_code, 0,
"Command should succeed even when declined (worktree still created)"
);
assert!(
output.contains("execute") && output.contains("2") && output.contains("command"),
"Should show only 2 unapproved commands in prompt (not 3)"
);
assert!(
output.contains("Commands declined"),
"Should show 'Commands declined' message"
);
assert!(
!output.contains("Running pre-start"),
"Should NOT execute any commands when declined"
);
assert!(
output.contains("Created branch") && output.contains("and worktree"),
"Should still create worktree even when commands declined"
);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_mixed_approved_unapproved_decline", &output);
});
}
#[rstest]
fn test_approval_prompt_remove_decline(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
let output = repo
.wt_command()
.args(["switch", "--create", "to-remove", "--yes"])
.output()
.unwrap();
assert!(output.status.success(), "Initial switch should succeed");
repo.write_project_config(r#"pre-remove = "echo 'pre-remove hook'""#);
repo.commit("Add pre-remove config");
repo.configure_shell_integration();
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) = exec_wt_in_pty(&repo, &["remove", "to-remove"], &env_vars, "n\n");
assert_eq!(
exit_code, 0,
"Remove should succeed even when hooks declined"
);
assert!(
output.contains("Commands declined"),
"Should show 'Commands declined' message"
);
approval_pty_settings(&repo).bind(|| {
assert_snapshot!("approval_prompt_remove_decline", &output);
});
}
#[rstest]
fn test_approval_prompt_step_commit_decline(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-commit = "echo 'pre-commit hook'""#);
repo.commit("Add pre-commit config");
let feature_wt = repo.add_worktree("feature-commit");
std::fs::write(feature_wt.join("new-file.txt"), "new content").unwrap();
repo.write_test_config(
r#"
[commit.generation]
command = "cat >/dev/null && echo 'feat: test commit message'"
"#,
);
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) =
exec_wt_in_pty_cwd(&feature_wt, &["step", "commit"], &env_vars, "n\n");
assert_eq!(
exit_code, 0,
"Commit should succeed even when hooks declined. Output:\n{output}"
);
assert!(
output.contains("Commands declined"),
"Should show 'Commands declined' message. Output:\n{output}"
);
assert!(
output.contains("committing without hooks"),
"Should indicate commit proceeds without hooks. Output:\n{output}"
);
}
#[rstest]
fn test_approval_prompt_step_squash_decline(mut repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-commit = "echo 'pre-commit hook'""#);
repo.commit("Add pre-commit config");
let feature_wt = repo.add_worktree("feature-squash");
repo.commit_in_worktree(&feature_wt, "file1.txt", "content 1", "feat: first change");
repo.commit_in_worktree(&feature_wt, "file2.txt", "content 2", "feat: second change");
repo.write_test_config(
r#"
[commit.generation]
command = "cat >/dev/null && echo 'feat: squashed commit message'"
"#,
);
let env_vars = test_env_vars_with_shell(&repo);
let (output, exit_code) =
exec_wt_in_pty_cwd(&feature_wt, &["step", "squash"], &env_vars, "n\n");
assert_eq!(
exit_code, 0,
"Squash should succeed even when hooks declined. Output:\n{output}"
);
assert!(
output.contains("Commands declined"),
"Should show 'Commands declined' message. Output:\n{output}"
);
assert!(
output.contains("squashing without hooks"),
"Should indicate squash proceeds without hooks. Output:\n{output}"
);
}
#[rstest]
fn test_config_approvals_add_accept(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
let env_vars = repo.test_env_vars();
let (output, exit_code) =
exec_wt_in_pty(&repo, &["config", "approvals", "add"], &env_vars, "y\n");
assert_eq!(exit_code, 0, "add should exit cleanly. Output:\n{output}");
assert!(
output.contains("Commands approved"),
"Should show approval success. Output:\n{output}"
);
}
#[rstest]
fn test_config_approvals_add_decline(repo: TestRepo) {
repo.run_git(&["remote", "remove", "origin"]);
repo.write_project_config(r#"pre-start = "echo 'test command'""#);
repo.commit("Add config");
let env_vars = repo.test_env_vars();
let (output, exit_code) =
exec_wt_in_pty(&repo, &["config", "approvals", "add"], &env_vars, "n\n");
assert_eq!(
exit_code, 0,
"decline should exit cleanly. Output:\n{output}"
);
assert!(
output.contains("Commands declined"),
"Should show decline message. Output:\n{output}"
);
}