From 7d3ce44da5e7e24373887566dbc4be7bcd80ee00 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 20 May 2026 10:33:02 -0700 Subject: [PATCH 01/34] fix(shell): validate command name before rendering it into shell code `wt config shell init/install/completions --cmd ` rendered the command name verbatim into eval'd shell code and shell-owned file paths. A name like `wt; touch /tmp/pwn` injected arbitrary commands into the generated integration line a user would eval from their rc file. `validate_shell_command_name` is now the single validator: it rejects empty names, leading `-`, and anything outside ASCII alphanumerics plus `.`, `_`, `-`. CLI entry points (`handle_init`, `handle_completions`, each `configure_shell` handler) validate before generating any shell code or touching rc files; `ShellInit::with_prefix`, `config_line`, and the `paths` helpers carry the check as a structural guard. Co-Authored-By: Claude --- src/commands/configure_shell.rs | 10 ++++ src/commands/init.rs | 3 ++ src/shell/mod.rs | 50 +++++++++++++++++ src/shell/paths.rs | 10 ++++ tests/integration_tests/configure_shell.rs | 37 +++++++++++++ tests/integration_tests/init.rs | 63 +++++++++++++++++++++- 6 files changed, 172 insertions(+), 1 deletion(-) diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs index 21595babc8..7eaa4b3a1a 100644 --- a/src/commands/configure_shell.rs +++ b/src/commands/configure_shell.rs @@ -183,6 +183,8 @@ pub fn handle_configure_shell( dry_run: bool, cmd: String, ) -> Result { + shell::validate_shell_command_name(&cmd)?; + // First, do a dry-run to see what would be changed let preview = scan_shell_configs(shell_filter, true, &cmd)?; @@ -330,6 +332,8 @@ pub fn scan_shell_configs( dry_run: bool, cmd: &str, ) -> Result { + shell::validate_shell_command_name(cmd)?; + // Iterate every supported shell. Shells the user doesn't have are filtered // out of the Skipped output by `is_installed()` below, matching how // bash/zsh/fish/nushell are handled. @@ -825,6 +829,8 @@ pub fn process_shell_completions( dry_run: bool, cmd: &str, ) -> Result, String> { + shell::validate_shell_command_name(cmd)?; + let mut results = Vec::new(); let fish_completion = fish_completion_content(cmd); @@ -899,6 +905,8 @@ pub fn handle_unconfigure_shell( dry_run: bool, cmd: &str, ) -> Result { + shell::validate_shell_command_name(cmd)?; + // First, do a dry-run to see what would be changed let preview = scan_for_uninstall(shell_filter, true, cmd)?; @@ -935,6 +943,8 @@ fn scan_for_uninstall( dry_run: bool, cmd: &str, ) -> Result { + shell::validate_shell_command_name(cmd)?; + // For uninstall, always include PowerShell to clean up any existing profiles let default_shells = vec![ Shell::Bash, diff --git a/src/commands/init.rs b/src/commands/init.rs index de15f6bf3f..3125a85096 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -8,6 +8,8 @@ use worktrunk::styling::println; use crate::cli::Cli; pub fn handle_init(shell: shell::Shell, cmd: String) -> Result<(), String> { + shell::validate_shell_command_name(&cmd)?; + let init = shell::ShellInit::with_prefix(shell, cmd); // Generate shell integration code (includes dynamic completion registration) @@ -39,6 +41,7 @@ pub fn handle_init(shell: shell::Shell, cmd: String) -> Result<(), String> { pub fn handle_completions(shell: shell::Shell) -> anyhow::Result<()> { let mut cmd = Cli::command(); let cmd_name = crate::binary_name(); + shell::validate_shell_command_name(&cmd_name).map_err(anyhow::Error::msg)?; let mut stdout = io::stdout(); match shell { diff --git a/src/shell/mod.rs b/src/shell/mod.rs index fe60043170..789cbc4d06 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -21,6 +21,31 @@ pub use detection::{ pub use paths::{completion_path, config_paths, legacy_fish_conf_d_path}; pub use utils::{current_shell, detect_zsh_compinit, extract_filename_from_path}; +/// Validate a command name before embedding it in shell syntax or shell-owned paths. +pub fn validate_shell_command_name(cmd: &str) -> Result<(), String> { + if cmd.is_empty() { + return Err("Invalid shell integration command name: command name cannot be empty".into()); + } + + if cmd.starts_with('-') { + return Err( + "Invalid shell integration command name: command name cannot start with '-'".into(), + ); + } + + if !cmd + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) + { + return Err( + "Invalid shell integration command name: use only ASCII letters, numbers, '.', '_', and '-'" + .into(), + ); + } + + Ok(()) +} + /// Supported shells /// /// Currently supported: bash, fish, nushell (experimental), zsh, powershell @@ -133,6 +158,8 @@ impl Shell { /// Note: The generated line does not include `--cmd` because `binary_name()` already /// detects the command name from argv\[0\] at runtime. pub fn config_line(&self, cmd: &str) -> String { + validate_shell_command_name(cmd).unwrap_or_else(|message| panic!("{message}")); + match self { Self::Bash | Self::Zsh => { format!( @@ -208,6 +235,7 @@ pub struct ShellInit { impl ShellInit { pub fn with_prefix(shell: Shell, cmd: String) -> Self { + validate_shell_command_name(&cmd).unwrap_or_else(|message| panic!("{message}")); Self { shell, cmd } } @@ -497,6 +525,28 @@ mod tests { insta::assert_snapshot!(init.generate().expect("Should generate with custom prefix")); } + #[rstest] + #[case("wt")] + #[case("git-wt")] + #[case("my.app_1")] + fn test_validate_shell_command_name_accepts_safe_names(#[case] cmd: &str) { + assert!(validate_shell_command_name(cmd).is_ok()); + } + + #[rstest] + #[case("")] + #[case("-wt")] + #[case("wt; touch")] + #[case("wt touch")] + #[case("wt/touch")] + #[case("wt\\touch")] + #[case("wt\ntouch")] + #[case("wt'touch")] + #[case("wüt")] + fn test_validate_shell_command_name_rejects_shell_syntax(#[case] cmd: &str) { + assert!(validate_shell_command_name(cmd).is_err()); + } + /// Verify that `config_line()` generates lines that /// `is_shell_integration_line()` can detect. /// diff --git a/src/shell/paths.rs b/src/shell/paths.rs index 13c147852c..ce2335e369 100644 --- a/src/shell/paths.rs +++ b/src/shell/paths.rs @@ -8,6 +8,10 @@ use std::path::PathBuf; use crate::path::home_dir; +fn invalid_command_name_error(message: String) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidInput, message) +} + /// Get the user's home directory or return an error. pub fn home_dir_required() -> Result { home_dir().ok_or_else(|| { @@ -135,6 +139,8 @@ pub fn powershell_profile_paths(home: &std::path::Path) -> Vec { /// The `cmd` parameter affects the Fish functions filename (e.g., `wt.fish` or `git-wt.fish`). /// Returns paths in order of preference. The first existing file should be used. pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std::io::Error> { + super::validate_shell_command_name(cmd).map_err(invalid_command_name_error)?; + let home = home_dir_required()?; Ok(match shell { @@ -186,6 +192,8 @@ pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std: /// `functions/{cmd}.fish` instead. This method returns the legacy path so install/uninstall /// can clean it up. pub fn legacy_fish_conf_d_path(cmd: &str) -> Result { + super::validate_shell_command_name(cmd).map_err(invalid_command_name_error)?; + let home = home_dir_required()?; Ok(home .join(".config") @@ -203,6 +211,8 @@ pub fn legacy_fish_conf_d_path(cmd: &str) -> Result { /// (installed by `wt config shell install`) that uses $WORKTRUNK_BIN to bypass /// the shell function wrapper. pub fn completion_path(shell: super::Shell, cmd: &str) -> Result { + super::validate_shell_command_name(cmd).map_err(invalid_command_name_error)?; + let home = home_dir_required()?; // Use etcetera for XDG-compliant paths when available diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index 6d0cf17410..3d1ce5276a 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -88,6 +88,43 @@ fn test_configure_shell_specific_shell(repo: TestRepo, temp_home: TempDir) { assert!(content.contains("eval \"$(command wt config shell init zsh)\"")); } +#[rstest] +fn test_configure_shell_rejects_unsafe_cmd_without_modifying_rc( + repo: TestRepo, + temp_home: TempDir, +) { + let zshrc_path = temp_home.path().join(".zshrc"); + let original = "# Existing config\n"; + fs::write(&zshrc_path, original).unwrap(); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.arg("config") + .arg("shell") + .arg("install") + .arg("zsh") + .arg("--yes") + .arg("--cmd") + .arg("wt; touch /tmp/pwn") + .current_dir(repo.root_path()); + + let output = cmd.output().unwrap(); + + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "unsafe command name must not emit shell code:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"), + "expected validation error, got:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fs::read_to_string(&zshrc_path).unwrap(), original); +} + #[rstest] fn test_configure_shell_already_exists(repo: TestRepo, temp_home: TempDir) { // Create a fake .zshrc file with the line already present diff --git a/tests/integration_tests/init.rs b/tests/integration_tests/init.rs index 5c6d408955..24604da9fb 100644 --- a/tests/integration_tests/init.rs +++ b/tests/integration_tests/init.rs @@ -5,10 +5,11 @@ //! are not the primary shell integration path on Windows (PowerShell is). #![cfg(not(windows))] -use crate::common::{TestRepo, add_standard_env_redactions, repo, wt_command}; +use crate::common::{TestRepo, add_standard_env_redactions, repo, wt_bin, wt_command}; use insta::Settings; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; +use std::process::Command; /// Helper to create snapshot for config shell init command fn snapshot_init(test_name: &str, repo: &TestRepo, shell: &str, extra_args: &[&str]) { @@ -69,3 +70,63 @@ fn test_init_invalid_shell(repo: TestRepo) { "); }); } + +#[rstest] +#[case("bash")] +#[case("fish")] +#[case("nu")] +#[case("powershell")] +fn test_init_rejects_unsafe_cmd(#[case] shell: &str, repo: TestRepo) { + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + cmd.arg("config") + .arg("shell") + .arg("init") + .arg(shell) + .arg("--cmd") + .arg("wt; touch /tmp/pwn") + .current_dir(repo.root_path()); + + let output = cmd.output().unwrap(); + + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "unsafe command name must not emit shell code:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"), + "expected validation error, got:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[rstest] +fn test_init_rejects_unsafe_argv0_command_name(repo: TestRepo) { + let temp_dir = tempfile::tempdir().unwrap(); + let bad_bin = temp_dir.path().join("wt;touch"); + std::os::unix::fs::symlink(wt_bin(), &bad_bin).unwrap(); + + let mut cmd = Command::new(&bad_bin); + repo.configure_wt_cmd(&mut cmd); + cmd.arg("config") + .arg("shell") + .arg("init") + .arg("bash") + .current_dir(repo.root_path()); + + let output = cmd.output().unwrap(); + + assert!(!output.status.success()); + assert!( + output.stdout.is_empty(), + "unsafe argv[0] command name must not emit shell code:\n{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("Invalid shell integration command name"), + "expected validation error, got:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 9162456f3cf3c3b7eb85815c4968bf554219f47b Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 20 May 2026 11:17:54 -0700 Subject: [PATCH 02/34] fix(shell): detect noncanonical integration lines on install `configure_shell_file` only recognized a byte-exact match of the current canonical integration line. A `.zshrc` carrying a manually-added or older-version line (e.g. bare `eval "$(wt config shell init zsh)"` without the `command -v` guard) was not detected as already configured, so install appended a second, duplicate invocation. Install now classifies an existing line via the shared `is_shell_integration_line` predicate, scoped to the shell being installed (`config shell init `), so semantically equivalent managed lines count as already configured. Also collapses the `is_diagnostic_file` body to the rustfmt-canonical single line (an unformatted line slipped into the preceding logs commit). Co-Authored-By: Claude --- src/commands/configure_shell.rs | 12 ++++++-- tests/integration_tests/configure_shell.rs | 33 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs index 7eaa4b3a1a..81e0718896 100644 --- a/src/commands/configure_shell.rs +++ b/src/commands/configure_shell.rs @@ -465,7 +465,7 @@ fn configure_shell_file( let reader = BufReader::new(file); - // Check for the exact conditional wrapper we would write + // Check for the canonical line and older/manual forms for this shell. for line in reader.lines() { let line = line.map_err(|e| { format!( @@ -475,8 +475,7 @@ fn configure_shell_file( ) })?; - // Canonical detection: check if the line matches exactly what we write - if line.trim() == config_line { + if is_install_shell_integration_line(&line, shell, cmd) { return Ok(Some(ConfigureResult { shell, path: path.to_path_buf(), @@ -566,6 +565,13 @@ fn configure_shell_file( } } +fn is_install_shell_integration_line(line: &str, shell: Shell, cmd: &str) -> bool { + shell::is_shell_integration_line(line, cmd) + && line + .to_ascii_lowercase() + .contains(&format!("config shell init {shell}")) +} + /// Extract non-comment, non-blank lines from fish source for comparison. /// /// This lets us detect existing installations even when comment text has changed diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index 3d1ce5276a..acb5b25214 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -165,6 +165,39 @@ fn test_configure_shell_already_exists(repo: TestRepo, temp_home: TempDir) { assert_eq!(count, 1, "Should only have one wt config shell init line"); } +#[rstest] +fn test_configure_shell_already_exists_noncanonical_line(repo: TestRepo, temp_home: TempDir) { + let zshrc_path = temp_home.path().join(".zshrc"); + fs::write( + &zshrc_path, + "# Existing config\neval \"$(wt config shell init zsh)\"\n", + ) + .unwrap(); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.env("SHELL", "/bin/zsh"); + cmd.env("WORKTRUNK_TEST_COMPINIT_CONFIGURED", "1"); + cmd.arg("config") + .arg("shell") + .arg("install") + .arg("zsh") + .arg("--yes") + .current_dir(repo.root_path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "install should treat the existing manual line as configured:\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let content = fs::read_to_string(&zshrc_path).unwrap(); + let count = content.matches("wt config shell init").count(); + assert_eq!(count, 1, "Should not append a duplicate shell init line"); +} + #[rstest] fn test_configure_shell_fish(repo: TestRepo, temp_home: TempDir) { let settings = setup_home_snapshot_settings(&temp_home); From fabbcaabd32ccd3bcefb6dbbb85a6930b78ead6f Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 20 May 2026 11:33:40 -0700 Subject: [PATCH 03/34] fix(shell): add --cmd to config shell uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wt config shell install --cmd git-wt` installs integration under an alternate command name, but `uninstall` had no `--cmd` flag and always used `binary_name()`. A custom-name install could not be removed through the CLI — the user had to edit rc files by hand. `Uninstall` now takes `--cmd` (optional, defaulting to the binary name) matching `Install`, passed through to `handle_unconfigure_shell`. Removal for bash/zsh and the fish/nushell wrapper files all honor the custom name. Co-Authored-By: Claude --- src/cli/config.rs | 11 ++ src/main.rs | 9 +- tests/integration_tests/configure_shell.rs | 182 +++++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/src/cli/config.rs b/src/cli/config.rs index 05c2760e77..faee5b8089 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -108,6 +108,11 @@ Skip confirmation prompt: $ wt config shell uninstall --yes ``` +Uninstall an alternate command name: +```console +$ wt config shell uninstall zsh --cmd=git-wt +``` + ## Version tolerance Detects various forms of the integration pattern regardless of: @@ -122,6 +127,12 @@ Detects various forms of the integration pattern regardless of: /// Show what would be changed #[arg(long)] dry_run: bool, + + /// Command name for shell integration (defaults to binary name) + /// + /// Use this to remove shell integration installed for an alternate command name. + #[arg(long)] + cmd: Option, }, /// Show output theme samples diff --git a/src/main.rs b/src/main.rs index 9425fd3f04..94ad556750 100644 --- a/src/main.rs +++ b/src/main.rs @@ -556,9 +556,14 @@ fn handle_config_shell_command(action: ConfigShellCommand, yes: bool) -> anyhow: Ok(()) }) } - ConfigShellCommand::Uninstall { shell, dry_run } => { + ConfigShellCommand::Uninstall { + shell, + dry_run, + cmd, + } => { let explicit_shell = shell.is_some(); - handle_unconfigure_shell(shell, yes, dry_run, &binary_name()) + let cmd = cmd.unwrap_or_else(binary_name); + handle_unconfigure_shell(shell, yes, dry_run, &cmd) .map_err(|e| anyhow::anyhow!("{}", e)) .map(|result| { if !dry_run { diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index acb5b25214..965d142dce 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -1167,6 +1167,188 @@ fn test_install_uninstall_roundtrip(repo: TestRepo, temp_home: TempDir) { ); } +#[rstest] +fn test_uninstall_shell_custom_cmd_removes_matching_zsh_line(repo: TestRepo, temp_home: TempDir) { + let zshrc_path = temp_home.path().join(".zshrc"); + fs::write( + &zshrc_path, + "# Existing config\nif command -v wt >/dev/null 2>&1; then eval \"$(command wt config shell init zsh)\"; fi\n", + ) + .unwrap(); + + let mut install_cmd = wt_command(); + repo.configure_wt_cmd(&mut install_cmd); + set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("SHELL", "/bin/zsh"); + install_cmd.env("WORKTRUNK_TEST_COMPINIT_CONFIGURED", "1"); + install_cmd + .args([ + "config", "shell", "install", "zsh", "--yes", "--cmd", "git-wt", + ]) + .current_dir(repo.root_path()); + + let install_output = install_cmd.output().expect("Failed to execute install"); + assert!( + install_output.status.success(), + "Install should succeed:\nstderr: {}", + String::from_utf8_lossy(&install_output.stderr) + ); + let installed = fs::read_to_string(&zshrc_path).unwrap(); + assert!(installed.contains("git-wt config shell init zsh")); + + let mut uninstall_cmd = wt_command(); + repo.configure_wt_cmd(&mut uninstall_cmd); + set_temp_home_env(&mut uninstall_cmd, temp_home.path()); + uninstall_cmd.env("SHELL", "/bin/zsh"); + uninstall_cmd + .args([ + "config", + "shell", + "uninstall", + "zsh", + "--yes", + "--cmd", + "git-wt", + ]) + .current_dir(repo.root_path()); + + let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall"); + assert!( + uninstall_output.status.success(), + "Uninstall should succeed:\nstderr: {}", + String::from_utf8_lossy(&uninstall_output.stderr) + ); + + let content = fs::read_to_string(&zshrc_path).unwrap(); + assert!( + !content.contains("git-wt config shell init zsh"), + "Custom command integration should be removed" + ); + assert!( + content.contains("wt config shell init zsh"), + "Default command integration should be preserved" + ); +} + +#[rstest] +fn test_uninstall_shell_custom_cmd_removes_fish_files(repo: TestRepo, temp_home: TempDir) { + let fish_functions = temp_home.path().join(".config/fish/functions"); + let fish_completions = temp_home.path().join(".config/fish/completions"); + fs::create_dir_all(&fish_functions).unwrap(); + fs::create_dir_all(&fish_completions).unwrap(); + let default_function = fish_functions.join("wt.fish"); + let default_completion = fish_completions.join("wt.fish"); + fs::write(&default_function, "function wt\nend\n").unwrap(); + fs::write(&default_completion, "complete --command wt\n").unwrap(); + + let mut install_cmd = wt_command(); + repo.configure_wt_cmd(&mut install_cmd); + set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("SHELL", "/bin/fish"); + install_cmd + .args([ + "config", "shell", "install", "fish", "--yes", "--cmd", "git-wt", + ]) + .current_dir(repo.root_path()); + + let install_output = install_cmd.output().expect("Failed to execute install"); + assert!( + install_output.status.success(), + "Install should succeed:\nstderr: {}", + String::from_utf8_lossy(&install_output.stderr) + ); + let custom_function = fish_functions.join("git-wt.fish"); + let custom_completion = fish_completions.join("git-wt.fish"); + assert!(custom_function.exists()); + assert!(custom_completion.exists()); + + let mut uninstall_cmd = wt_command(); + repo.configure_wt_cmd(&mut uninstall_cmd); + set_temp_home_env(&mut uninstall_cmd, temp_home.path()); + uninstall_cmd.env("SHELL", "/bin/fish"); + uninstall_cmd + .args([ + "config", + "shell", + "uninstall", + "fish", + "--yes", + "--cmd", + "git-wt", + ]) + .current_dir(repo.root_path()); + + let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall"); + assert!( + uninstall_output.status.success(), + "Uninstall should succeed:\nstderr: {}", + String::from_utf8_lossy(&uninstall_output.stderr) + ); + + assert!(!custom_function.exists()); + assert!(!custom_completion.exists()); + assert!(default_function.exists()); + assert!(default_completion.exists()); +} + +#[rstest] +fn test_uninstall_shell_custom_cmd_removes_nushell_file(repo: TestRepo, temp_home: TempDir) { + let home = std::fs::canonicalize(temp_home.path()).unwrap(); + let nu_autoload = home + .join(".config") + .join("nushell") + .join("vendor") + .join("autoload"); + fs::create_dir_all(&nu_autoload).unwrap(); + let default_config = nu_autoload.join("wt.nu"); + fs::write(&default_config, "def --env --wrapped wt [] {}\n").unwrap(); + + let mut install_cmd = wt_command(); + repo.configure_wt_cmd(&mut install_cmd); + set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("SHELL", "/bin/nu"); + install_cmd + .args([ + "config", "shell", "install", "nu", "--yes", "--cmd", "git-wt", + ]) + .current_dir(repo.root_path()); + + let install_output = install_cmd.output().expect("Failed to execute install"); + assert!( + install_output.status.success(), + "Install should succeed:\nstderr: {}", + String::from_utf8_lossy(&install_output.stderr) + ); + let custom_config = nu_autoload.join("git-wt.nu"); + assert!(custom_config.exists()); + + let mut uninstall_cmd = wt_command(); + repo.configure_wt_cmd(&mut uninstall_cmd); + set_temp_home_env(&mut uninstall_cmd, temp_home.path()); + uninstall_cmd.env("SHELL", "/bin/nu"); + uninstall_cmd + .args([ + "config", + "shell", + "uninstall", + "nu", + "--yes", + "--cmd", + "git-wt", + ]) + .current_dir(repo.root_path()); + + let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall"); + assert!( + uninstall_output.status.success(), + "Uninstall should succeed:\nstderr: {}", + String::from_utf8_lossy(&uninstall_output.stderr) + ); + + assert!(!custom_config.exists()); + assert!(default_config.exists()); +} + #[rstest] fn test_install_uninstall_no_blank_line_accumulation(repo: TestRepo, temp_home: TempDir) { // Create initial config file matching the user's real zshrc structure From 689ba0c986806aea697e0dd31de9d7b36597e4c1 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 21 May 2026 13:31:10 -0700 Subject: [PATCH 04/34] test(shell): cover invalid command-name guard and add zsh rejection case Address reviewer feedback on PR #2864: - Add a unit test in paths.rs `mod tests` exercising the three `cmd`-taking accessors (`config_paths`, `legacy_fish_conf_d_path`, `completion_path`) with an unsafe name, asserting `InvalidInput`. This covers the previously uncovered `invalid_command_name_error` helper (the `codecov/patch` gap) and documents the contract. - Add `zsh` to `test_init_rejects_unsafe_cmd` so the rejection test covers the same shells as the adjacent `test_init`. Co-Authored-By: Claude Opus 4.7 --- src/shell/paths.rs | 17 +++++++++++++++++ tests/integration_tests/init.rs | 1 + 2 files changed, 18 insertions(+) diff --git a/src/shell/paths.rs b/src/shell/paths.rs index ce2335e369..f347d59b74 100644 --- a/src/shell/paths.rs +++ b/src/shell/paths.rs @@ -362,4 +362,21 @@ mod tests { "Fallback should end with 'nushell': {result:?}" ); } + + #[test] + fn test_path_accessors_reject_invalid_command_name() { + // The three `cmd`-taking accessors validate before touching the filesystem, + // surfacing the rejection as an `InvalidInput` io::Error. Every CLI entry + // point validates first, so this structural guard documents the contract. + let bad = "wt; touch"; + + let err = config_paths(super::super::Shell::Fish, bad).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + + let err = legacy_fish_conf_d_path(bad).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + + let err = completion_path(super::super::Shell::Fish, bad).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + } } diff --git a/tests/integration_tests/init.rs b/tests/integration_tests/init.rs index 24604da9fb..c31157ed91 100644 --- a/tests/integration_tests/init.rs +++ b/tests/integration_tests/init.rs @@ -76,6 +76,7 @@ fn test_init_invalid_shell(repo: TestRepo) { #[case("fish")] #[case("nu")] #[case("powershell")] +#[case("zsh")] fn test_init_rejects_unsafe_cmd(#[case] shell: &str, repo: TestRepo) { let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); From e62283ff7fd1b3efd394522bf456c7d93b8f3f5e Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 21 May 2026 14:56:08 -0700 Subject: [PATCH 05/34] refactor(shell): drop redundant panic re-validation of command name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `config_line()` and `ShellInit::with_prefix()` re-validated `cmd` with `panic!`, but every production caller already validates at the command entry point (`handle_init`, `handle_configure_shell`, `handle_unconfigure_shell`, `handle_completions`) via the `Result`-returning `validate_shell_command_name`. The panic was a weaker second mechanism guarding an invariant the edge validation already enforces — belt-and-suspenders. Per CLAUDE.md "one mechanism per guarantee", keep the graceful `Result` at the edge and delete the panic. Docstrings now name the trust boundary so callers know `cmd` must be pre-validated. Co-Authored-By: Claude Opus 4.7 --- src/shell/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 789cbc4d06..421e64fe83 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -155,11 +155,12 @@ impl Shell { /// The `cmd` parameter specifies the command name (e.g., `wt` or `git-wt`). /// All shells use a conditional wrapper to avoid errors when the command doesn't exist. /// + /// `cmd` must already be validated by [`validate_shell_command_name`]; command + /// entry points (`handle_init`, `handle_configure_shell`) validate at the edge. + /// /// Note: The generated line does not include `--cmd` because `binary_name()` already /// detects the command name from argv\[0\] at runtime. pub fn config_line(&self, cmd: &str) -> String { - validate_shell_command_name(cmd).unwrap_or_else(|message| panic!("{message}")); - match self { Self::Bash | Self::Zsh => { format!( @@ -234,8 +235,9 @@ pub struct ShellInit { } impl ShellInit { + /// `cmd` must already be validated by [`validate_shell_command_name`]; command + /// entry points (`handle_init`, `handle_configure_shell`) validate at the edge. pub fn with_prefix(shell: Shell, cmd: String) -> Self { - validate_shell_command_name(&cmd).unwrap_or_else(|message| panic!("{message}")); Self { shell, cmd } } From 8edf5e26eee42980a890c72e4ca4e13dbb1db427 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Fri, 22 May 2026 13:25:56 -0700 Subject: [PATCH 06/34] refactor(shell): validate command names only at the CLI edge The three paths.rs accessors (config_paths, legacy_fish_conf_d_path, completion_path) re-validated the command name, but every caller is a command handler that already validates at the edge. Per CLAUDE.md's "one mechanism per guarantee", drop the redundant internal guard, its io::Error helper, and the unit test covering it. Co-Authored-By: Claude Opus 4.7 --- src/shell/paths.rs | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/src/shell/paths.rs b/src/shell/paths.rs index f347d59b74..13c147852c 100644 --- a/src/shell/paths.rs +++ b/src/shell/paths.rs @@ -8,10 +8,6 @@ use std::path::PathBuf; use crate::path::home_dir; -fn invalid_command_name_error(message: String) -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::InvalidInput, message) -} - /// Get the user's home directory or return an error. pub fn home_dir_required() -> Result { home_dir().ok_or_else(|| { @@ -139,8 +135,6 @@ pub fn powershell_profile_paths(home: &std::path::Path) -> Vec { /// The `cmd` parameter affects the Fish functions filename (e.g., `wt.fish` or `git-wt.fish`). /// Returns paths in order of preference. The first existing file should be used. pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std::io::Error> { - super::validate_shell_command_name(cmd).map_err(invalid_command_name_error)?; - let home = home_dir_required()?; Ok(match shell { @@ -192,8 +186,6 @@ pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std: /// `functions/{cmd}.fish` instead. This method returns the legacy path so install/uninstall /// can clean it up. pub fn legacy_fish_conf_d_path(cmd: &str) -> Result { - super::validate_shell_command_name(cmd).map_err(invalid_command_name_error)?; - let home = home_dir_required()?; Ok(home .join(".config") @@ -211,8 +203,6 @@ pub fn legacy_fish_conf_d_path(cmd: &str) -> Result { /// (installed by `wt config shell install`) that uses $WORKTRUNK_BIN to bypass /// the shell function wrapper. pub fn completion_path(shell: super::Shell, cmd: &str) -> Result { - super::validate_shell_command_name(cmd).map_err(invalid_command_name_error)?; - let home = home_dir_required()?; // Use etcetera for XDG-compliant paths when available @@ -362,21 +352,4 @@ mod tests { "Fallback should end with 'nushell': {result:?}" ); } - - #[test] - fn test_path_accessors_reject_invalid_command_name() { - // The three `cmd`-taking accessors validate before touching the filesystem, - // surfacing the rejection as an `InvalidInput` io::Error. Every CLI entry - // point validates first, so this structural guard documents the contract. - let bad = "wt; touch"; - - let err = config_paths(super::super::Shell::Fish, bad).unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - - let err = legacy_fish_conf_d_path(bad).unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - - let err = completion_path(super::super::Shell::Fish, bad).unwrap_err(); - assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); - } } From 7cf56fb6566bc49e075b7ff23e4f2cf570947b9d Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Wed, 20 May 2026 22:06:00 -0700 Subject: [PATCH 07/34] fix(switch): atomic no-overwrite backup for `--clobber` (#2849) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `wt switch --clobber` backs up an existing path before clobbering it. The old code checked `backup_path.exists()` and then, later, called `std::fs::rename` — which silently overwrites an existing destination on every platform. Anything that appeared at the backup path between the check and the rename (a same-second clobber, or a path that raced in) was destroyed: a time-of-check/time-of-use data-loss race on a `--clobber` operation. ## The fix The backup now moves with `renamore::rename_exclusive` — an atomic, no-overwrite rename: `renameat2(RENAME_NOREPLACE)` on Linux, `renamex_np(RENAME_EXCL)` on macOS, `MoveFileExW` on Windows. It fails closed (`AlreadyExists`) rather than overwriting. `renamore` keeps the platform FFI — and its `unsafe` — inside the crate, so worktrunk itself stays `unsafe_code = "forbid"`. `back_up_clobbered_path` moves the stale path with that primitive. If the timestamped backup name is already taken, it counts up — `…-2`, `…-3`, … — until it finds a free name, so a collision no longer fails the command and an existing backup is never overwritten. The loop terminates because a directory holds finitely many entries; genuine I/O errors still surface as errors. The timestamp and the move now both happen at execution time, which also closes a smaller stale-plan window — the backup path was previously computed during planning and renamed much later. ## Key files - `src/commands/worktree/resolve.rs` — `back_up_clobbered_path` retry loop. - `src/commands/worktree/switch.rs` — `execute_switch` uses it; the plan now carries `needs_clobber_backup: bool` instead of a resolved path. ## Notes - `renamore` is a new dependency. `rustix` / `windows-sys` are deliberately not added as direct dependencies — calling either's rename API requires an `unsafe` block, which this crate forbids; `renamore` encapsulates that. - `wt step relocate --clobber` has the identical check-then-`std::fs::rename` race. It is **not** fixed here — a separate branch handles relocate; when that lands it should adopt `renamore::rename_exclusive`. ## Testing `back_up_clobbered_path` has unit tests covering every branch — fresh move, `-N` fallback, many collisions, non-`AlreadyExists` error. Integration tests cover `wt switch --clobber` end-to-end, including the collision fallback (which runs on the Windows CI job). --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 11 ++ Cargo.toml | 4 + src/commands/worktree/resolve.rs | 135 +++++++++++++----- src/commands/worktree/switch.rs | 52 +++++-- src/commands/worktree/types.rs | 5 +- tests/integration_tests/switch.rs | 59 +++++--- ...clobber_falls_back_when_backup_taken.snap} | 24 +++- ...h__switch_clobber_path_with_extension.snap | 14 +- ...tch__switch_clobber_removes_stale_dir.snap | 14 +- ...ch__switch_clobber_removes_stale_file.snap | 14 +- 10 files changed, 255 insertions(+), 77 deletions(-) rename tests/snapshots/{integration__integration_tests__switch__switch_clobber_error_backup_exists.snap => integration__integration_tests__switch__switch_clobber_falls_back_when_backup_taken.snap} (55%) diff --git a/Cargo.lock b/Cargo.lock index fd0eeeb52e..0d90a12e12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1919,6 +1919,16 @@ version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +[[package]] +name = "renamore" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f5bced8a18df26d088a61a8f314d853ad18705b0aee59b43ccc088ca4bc3670" +dependencies = [ + "cc", + "tempfile", +] + [[package]] name = "rstest" version = "0.26.1" @@ -3211,6 +3221,7 @@ dependencies = [ "rayon", "reflink-copy", "regex", + "renamore", "rstest", "sanitize-filename", "schemars", diff --git a/Cargo.toml b/Cargo.toml index 6108d8e3c2..b858594a57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,10 @@ urlencoding = "2.1" regex = "1.12" ignore = "0.4" reflink-copy = "0.1" +# Atomic, no-overwrite rename (renameat2 / renamex_np / MoveFileExW) for +# `--clobber` backups — closes the check-then-rename TOCTOU. Keeps the FFI +# (and its `unsafe`) out of this crate, which is `unsafe_code = "forbid"`. +renamore = "0.3" dashmap = "6.2.1" fs2 = "0.4.3" sanitize-filename = "0.6.0" diff --git a/src/commands/worktree/resolve.rs b/src/commands/worktree/resolve.rs index 1b4ca127a2..95aa7f9d99 100644 --- a/src/commands/worktree/resolve.rs +++ b/src/commands/worktree/resolve.rs @@ -233,42 +233,40 @@ pub(super) fn generate_backup_path( } } -/// Compute the backup path for clobber operations. +/// Move a stale path aside so `wt switch --clobber` can take its place. /// -/// Returns `Ok(None)` if path doesn't exist. -/// Returns `Ok(Some(backup_path))` if clobber is true and path exists. -/// Returns `Err(GitError::WorktreePathExists)` if clobber is false and path exists. -pub(super) fn compute_clobber_backup( - path: &Path, - branch: &str, - clobber: bool, - create: bool, -) -> anyhow::Result> { - if !path.exists() { - return Ok(None); - } - - if clobber { - let timestamp = worktrunk::utils::epoch_now() as i64; - let datetime = - chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_else(chrono::Utc::now); - let suffix = datetime.format("%Y%m%d-%H%M%S").to_string(); - let backup_path = generate_backup_path(path, &suffix)?; - - if backup_path.exists() { - anyhow::bail!( - "Backup path already exists: {}", - worktrunk::path::format_path_for_display(&backup_path) - ); - } - Ok(Some(backup_path)) - } else { - Err(GitError::WorktreePathExists { - branch: branch.to_string(), - path: path.to_path_buf(), - create, +/// Renames `worktree_path` to a `.bak.` sibling. If that name is +/// already taken — a same-second clobber, or a path that raced in after +/// planning — it counts up (`…-2`, `…-3`, …) until it finds a free name. +/// Every attempt is an atomic no-overwrite rename ([`renamore::rename_exclusive`]), +/// so an existing backup is never overwritten; the move just lands on the next +/// free name. Returns the path the stale directory was moved to. +pub(super) fn back_up_clobbered_path( + worktree_path: &Path, + base_suffix: &str, +) -> anyhow::Result { + // Count up until a free name is found. This cannot spin forever: a + // directory holds finitely many entries, so some `-N` is always unused. + let mut n: u64 = 1; + loop { + // First attempt uses the bare suffix; later ones disambiguate with -N. + let suffix = if n == 1 { + base_suffix.to_string() + } else { + format!("{base_suffix}-{n}") + }; + let candidate = generate_backup_path(worktree_path, &suffix)?; + match renamore::rename_exclusive(worktree_path, &candidate) { + Ok(()) => return Ok(candidate), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => n += 1, + Err(err) => { + return Err(anyhow::Error::new(err).context(format!( + "Failed to move {} to {}", + format_path_for_display(worktree_path), + format_path_for_display(&candidate), + ))); + } } - .into()) } } @@ -466,6 +464,75 @@ mod tests { assert!(generate_backup_path(&path, "20250101-000000").is_err()); } + #[test] + fn test_back_up_clobbered_path_moves_to_fresh_suffix() { + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + std::fs::write(stale.join("file"), "content").unwrap(); + + let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap(); + + assert_eq!(used, temp.path().join("feature.bak.20250101-000000")); + assert!(!stale.exists(), "stale path should be moved away"); + assert_eq!( + std::fs::read_to_string(used.join("file")).unwrap(), + "content" + ); + } + + #[test] + fn test_back_up_clobbered_path_falls_back_when_suffix_taken() { + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + + // The preferred backup name and its first -N variant are both taken. + let taken = temp.path().join("feature.bak.20250101-000000"); + std::fs::create_dir(&taken).unwrap(); + std::fs::write(taken.join("keep"), "pre-existing").unwrap(); + std::fs::create_dir(temp.path().join("feature.bak.20250101-000000-2")).unwrap(); + + let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap(); + + // Lands on -3; neither pre-existing backup is overwritten. + assert_eq!(used, temp.path().join("feature.bak.20250101-000000-3")); + assert!(!stale.exists()); + assert_eq!( + std::fs::read_to_string(taken.join("keep")).unwrap(), + "pre-existing" + ); + } + + #[test] + fn test_back_up_clobbered_path_errors_when_source_missing() { + // A missing source fails the rename with a non-AlreadyExists error, + // which surfaces rather than being retried. + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("does-not-exist"); + assert!(back_up_clobbered_path(&missing, "20250101-000000").is_err()); + } + + #[test] + fn test_back_up_clobbered_path_keeps_incrementing_past_many_collisions() { + // There is no attempt cap: the move keeps counting up until a free + // name is found, however many backups already exist. + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + + // Occupy the preferred name and the first 49 -N fallbacks (suffix "S"). + std::fs::create_dir(temp.path().join("feature.bak.S")).unwrap(); + for n in 2..=50 { + std::fs::create_dir(temp.path().join(format!("feature.bak.S-{n}"))).unwrap(); + } + + let used = back_up_clobbered_path(&stale, "S").unwrap(); + + assert_eq!(used, temp.path().join("feature.bak.S-51")); + assert!(!stale.exists(), "stale path should be moved away"); + } + #[test] fn test_template_references_repo_name_default() { // Default template uses {{ repo }} diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 49ae2b9659..0586f3f065 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -31,7 +31,7 @@ use worktrunk::styling::{ }; use super::resolve::{ - compute_clobber_backup, compute_worktree_path, offer_bare_repo_worktree_path_fix, path_mismatch, + back_up_clobbered_path, compute_worktree_path, offer_bare_repo_worktree_path_fix, path_mismatch, }; use super::types::{CreationMethod, SwitchBranchInfo, SwitchPlan, SwitchResult}; use crate::cli::SwitchFormat; @@ -652,7 +652,7 @@ fn validate_worktree_creation( path: &Path, clobber: bool, method: &CreationMethod, -) -> anyhow::Result> { +) -> anyhow::Result { // For regular switches without --create, validate branch exists if let CreationMethod::Regular { create_branch: false, @@ -686,7 +686,17 @@ fn validate_worktree_creation( .into()); } - // Handle clobber for stale directories + // Handle clobber for stale directories. Returns whether `execute_switch` + // must back up a path occupying `worktree_path` before creating the + // worktree; the backup itself happens at execution time so a path that + // races in after planning is still moved atomically (see + // `back_up_clobbered_path`). + if !path.exists() { + return Ok(false); + } + if clobber { + return Ok(true); + } let is_create = matches!( method, CreationMethod::Regular { @@ -694,7 +704,12 @@ fn validate_worktree_creation( .. } ); - compute_clobber_backup(path, branch, clobber, is_create) + Err(GitError::WorktreePathExists { + branch: branch.to_string(), + path: path.to_path_buf(), + create: is_create, + } + .into()) } /// Set up a local branch for a fork PR or MR. @@ -831,7 +846,7 @@ pub fn plan_switch( let expected_path = compute_worktree_path(repo, &target.branch, config)?; // Phase 4: Validate we can create at this path - let clobber_backup = validate_worktree_creation( + let needs_clobber_backup = validate_worktree_creation( repo, &target.branch, &expected_path, @@ -844,7 +859,7 @@ pub fn plan_switch( branch: target.branch, worktree_path: expected_path, method: target.method, - clobber_backup, + needs_clobber_backup, new_previous, }) } @@ -906,23 +921,32 @@ pub fn execute_switch( branch, worktree_path, method, - clobber_backup, + needs_clobber_backup, new_previous, } => { // Handle --clobber backup if needed (shared for all creation methods) - if let Some(backup_path) = &clobber_backup { + if needs_clobber_backup { + // Timestamped backup name, computed here at move time so the + // suffix reflects when the path is actually set aside. + let timestamp = worktrunk::utils::epoch_now() as i64; + let datetime = + chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_else(chrono::Utc::now); + let base_suffix = datetime.format("%Y%m%d-%H%M%S").to_string(); + + // Atomically move the stale path aside. A backup name that is + // already taken (a same-second clobber, or one that raced in + // after planning) is never overwritten — the move falls back + // to the next free `-N` name. + let backup_path = back_up_clobbered_path(&worktree_path, &base_suffix)?; + let path_display = worktrunk::path::format_path_for_display(&worktree_path); - let backup_display = worktrunk::path::format_path_for_display(backup_path); + let backup_display = worktrunk::path::format_path_for_display(&backup_path); eprintln!( "{}", warning_message(cformat!( - "Moving {path_display} to {backup_display} (--clobber)" + "Moved {path_display} to {backup_display} (--clobber)" )) ); - - std::fs::rename(&worktree_path, backup_path).with_context(|| { - format!("Failed to move {path_display} to {backup_display}") - })?; } // Execute based on creation method diff --git a/src/commands/worktree/types.rs b/src/commands/worktree/types.rs index fac5558bbb..ef0cf75e13 100644 --- a/src/commands/worktree/types.rs +++ b/src/commands/worktree/types.rs @@ -122,8 +122,9 @@ pub enum SwitchPlan { worktree_path: PathBuf, /// How to create the worktree method: CreationMethod, - /// If path exists and --clobber, this is the backup path to move it to - clobber_backup: Option, + /// True when a stale path occupies `worktree_path` and `--clobber` was + /// given — `execute_switch` backs it up before creating the worktree. + needs_clobber_backup: bool, /// Branch to record as "previous" for `wt switch -` new_previous: Option, }, diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 86b0e60a2b..b76662581d 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -1864,37 +1864,56 @@ fn test_switch_clobber_backs_up_stale_file(repo: TestRepo) { ); } -#[rstest] -fn test_switch_clobber_error_backup_exists(repo: TestRepo) { - // Calculate where the worktree would be created +/// When the computed backup path is already taken, `wt switch --clobber` does +/// not fail — it moves the stale path to the next free `-N` variant via an +/// atomic no-overwrite rename, leaving the pre-existing backup untouched. This +/// is the time-of-check/time-of-use safe path: a colliding backup name (a +/// same-second clobber, or one that raced in after planning) is never +/// overwritten the way `std::fs::rename` would. +#[rstest] +fn test_switch_clobber_falls_back_when_backup_taken(repo: TestRepo) { let repo_name = repo.root_path().file_name().unwrap().to_str().unwrap(); - let expected_path = repo - .root_path() - .parent() - .unwrap() - .join(format!("{}.clobber-backup-exists", repo_name)); + let parent = repo.root_path().parent().unwrap(); + let expected_path = parent.join(format!("{}.clobber-backup-taken", repo_name)); - // Create a stale directory at the target path + // Stale directory at the target path, with content that must survive. std::fs::create_dir_all(&expected_path).unwrap(); + std::fs::write(expected_path.join("stale.txt"), "stale content").unwrap(); - // Also create the backup path that would be generated - // TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC - let backup_path = repo.root_path().parent().unwrap().join(format!( - "{}.clobber-backup-exists.bak.20250102-000000", + // Pre-create the backup path the timestamp would produce, so the move has + // to fall back. TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC + let taken = parent.join(format!( + "{}.clobber-backup-taken.bak.20250102-000000", repo_name )); - std::fs::create_dir_all(&backup_path).unwrap(); + std::fs::create_dir_all(&taken).unwrap(); + std::fs::write(taken.join("pre-existing.txt"), "do not touch").unwrap(); - // With --clobber, should error because backup path exists snapshot_switch( - "switch_clobber_error_backup_exists", + "switch_clobber_falls_back_when_backup_taken", &repo, - &["--create", "--clobber", "clobber-backup-exists"], + &["--create", "--clobber", "clobber-backup-taken"], ); - // Both paths should still exist (nothing was moved) - assert!(expected_path.exists()); - assert!(backup_path.exists()); + // The worktree was created. + assert!(expected_path.is_dir()); + + // The pre-existing backup is untouched. + assert_eq!( + std::fs::read_to_string(taken.join("pre-existing.txt")).unwrap(), + "do not touch" + ); + + // The stale content was moved to the -2 fallback name. + let fallback = parent.join(format!( + "{}.clobber-backup-taken.bak.20250102-000000-2", + repo_name + )); + assert!(fallback.is_dir()); + assert_eq!( + std::fs::read_to_string(fallback.join("stale.txt")).unwrap(), + "stale content" + ); } /// diff --git a/tests/snapshots/integration__integration_tests__switch__switch_clobber_error_backup_exists.snap b/tests/snapshots/integration__integration_tests__switch__switch_clobber_falls_back_when_backup_taken.snap similarity index 55% rename from tests/snapshots/integration__integration_tests__switch__switch_clobber_error_backup_exists.snap rename to tests/snapshots/integration__integration_tests__switch__switch_clobber_falls_back_when_backup_taken.snap index a1e517a594..f207025d07 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_clobber_error_backup_exists.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_clobber_falls_back_when_backup_taken.snap @@ -6,20 +6,26 @@ info: - switch - "--create" - "--clobber" - - clobber-backup-exists + - clobber-backup-taken env: APPDATA: "[TEST_CONFIG_HOME]" CLICOLOR_FORCE: "1" COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,18 +38,28 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- -success: false -exit_code: 1 +success: true +exit_code: 0 ----- stdout ----- ----- stderr ----- -✗ Backup path already exists: _REPO_.clobber-backup-exists.bak.20250102-000000 +▲ Moved _REPO_.clobber-backup-taken to _REPO_.clobber-backup-taken.bak.20250102-000000-2 (--clobber) +✓ Created branch clobber-backup-taken from main and worktree @ _REPO_.clobber-backup-taken +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__switch__switch_clobber_path_with_extension.snap b/tests/snapshots/integration__integration_tests__switch__switch_clobber_path_with_extension.snap index a590574bb4..10bdfadbbe 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_clobber_path_with_extension.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_clobber_path_with_extension.snap @@ -13,13 +13,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,13 +38,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -46,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Moving _REPO_.clobber-ext.txt to _REPO_.clobber-ext.txt.bak.20250102-000000 (--clobber) +▲ Moved _REPO_.clobber-ext.txt to _REPO_.clobber-ext.txt.bak.20250102-000000 (--clobber) ✓ Created branch clobber-ext.txt from main and worktree @ _REPO_.clobber-ext.txt ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_dir.snap b/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_dir.snap index b4fb9bd09e..8c722e2982 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_dir.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_dir.snap @@ -13,13 +13,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,13 +38,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -46,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Moving _REPO_.clobber-dir-test to _REPO_.clobber-dir-test.bak.20250102-000000 (--clobber) +▲ Moved _REPO_.clobber-dir-test to _REPO_.clobber-dir-test.bak.20250102-000000 (--clobber) ✓ Created branch clobber-dir-test from main and worktree @ _REPO_.clobber-dir-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_file.snap b/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_file.snap index b02f40b483..27707ff091 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_file.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_clobber_removes_stale_file.snap @@ -13,13 +13,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,13 +38,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -46,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Moving _REPO_.clobber-file-test to _REPO_.clobber-file-test.bak.20250102-000000 (--clobber) +▲ Moved _REPO_.clobber-file-test to _REPO_.clobber-file-test.bak.20250102-000000 (--clobber) ✓ Created branch clobber-file-test from main and worktree @ _REPO_.clobber-file-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed From a95ceee284ce3ef932fd8175823e3770f2c3dd87 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Wed, 20 May 2026 22:28:36 -0700 Subject: [PATCH 08/34] docs: make the Network Access inventory activity-based (#2850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command-keyed list of wire-reaching commands missed entry points. The interactive `wt switch` picker generates LLM branch summaries (`preview_orchestrator.rs` `spawn_summary`), and `wt merge` runs an intermediate commit through the same `commit.generation` path as `wt step commit`. This replaces the two command-attributed LLM rows ("`wt list` — LLM summaries", "`wt step commit`") with activity-based entries: "generating a branch summary" and "generating a commit message" with a `commit.generation` command. Activities cover every entry point without enumerating commands. The header becomes "What currently reaches the wire" since two entries are now activities rather than commands. --- CLAUDE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 605c9c0ef7..d733186a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,10 +97,11 @@ Why: silent "lookup" paths that walk to the wire (alias dispatch, hook context b Before adding an accessor that could reach the wire (`gh`, `glab`, `git fetch`, `git ls-remote`, HTTP), confirm the command that calls it is not intended to be fast. A foreground command the user runs and waits on absorbs the latency; a command in a synchronous hot path like a shell prompt cannot, and must not reach the wire. `wt list statusline` is not a fast command despite running on every prompt: Claude Code consumes its output asynchronously. -Commands that currently reach the wire: +What currently reaches the wire: -- `wt list --full`, `wt list statusline` — CI status, LLM summaries -- `wt step commit` — commit message via a configured LLM command +- `wt list --full`, `wt list statusline` — CI status +- generating a branch summary with a `commit.generation` command +- generating a commit message with a `commit.generation` command - `wt switch pr:`, `wt switch mr:` — host API to resolve the PR/MR, then `git fetch` of its branch - `wt config show --full` — version check against GitHub - the first `Repository::default_branch()` per repo — `git ls-remote` (above) From 865b8d96440efc8d8e9861addb7eff5d1033ef3d Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Wed, 20 May 2026 22:53:37 -0700 Subject: [PATCH 09/34] test(hook-plan): isolate approval test from the real user config dir (#2853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `nix-flake` CI job was red on `main`: the unit test `commands::hook_plan::tests::approve_readonly_drops_unapproved_project_keeps_user` panicked with `Failed to create config directory: Permission denied`. The test called `approve_command(.., None)`. The `None` for `approvals_path` makes `Approvals` resolve the path through `approvals_path()`, which — in the lib crate compiled in non-test mode — points at the real `~/.config/worktrunk/` directory. `approve_command` then `create_dir_all`s it. Outside a sandbox `$HOME` is writable, so the test silently passed while writing a stray `[projects.proj]` entry into the user's `approvals.toml`. The nix build sandbox has no writable config dir, so it panicked instead. This was latent on `main` since #2806 (which added the test). It only started tripping the gate now because the `nix-flake` job runs when a PR touches `Cargo.{toml,lock}`, and #2849 was the first to do so — so `nix-flake` failed there for a reason unrelated to that PR's change. The fix points `approve_command` at a `tempfile::tempdir()`-backed approvals path via the `Some(&path)` parameter it already accepts — no process-env mutation (`tests/CLAUDE.md` bans it), matching the in-file pattern in `src/config/approvals.rs`'s own tests. Same class of fix as #2615 and #2624. The two sibling tests in `hook_plan.rs` only *read* via `Approvals::load()`, which returns the default when no file exists — never creating a directory or writing — so they pass in the sandbox unchanged and were left as-is. Verified by reproducing the sandbox condition locally (test binary run with `HOME` / `XDG_CONFIG_HOME` pointed at a non-writable dir): the target test fails before the fix and passes after, and all three `hook_plan` tests still pass under a plain `cargo test`. Co-authored-by: Claude Opus 4.7 (1M context) --- src/commands/hook_plan.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/commands/hook_plan.rs b/src/commands/hook_plan.rs index 084eca3a16..67080ec084 100644 --- a/src/commands/hook_plan.rs +++ b/src/commands/hook_plan.rs @@ -453,9 +453,17 @@ mod tests { ); // With the project command approved, it survives the read-only gate. + // A tempdir-backed approvals path keeps the write off the real user + // config dir, which the nix build sandbox makes unwritable. + let temp_dir = tempfile::tempdir().unwrap(); + let approvals_path = temp_dir.path().join("approvals.toml"); let mut approvals = Approvals::default(); approvals - .approve_command("proj".to_string(), "echo project-hook".to_string(), None) + .approve_command( + "proj".to_string(), + "echo project-hook".to_string(), + Some(&approvals_path), + ) .unwrap(); let mut builder = HookPlanBuilder::new(); builder.add( From 4350f0de5b5b0cb9e0b6bc7accf18bb21ec378a4 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Wed, 20 May 2026 23:18:16 -0700 Subject: [PATCH 10/34] fix(config): deprecation-migration data-safety and log-layout fixes (#2851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness and data-safety fixes for worktrunk's config deprecation/migration layer and hook-log layout — surfaced by an automated `clawpatch` review and re-reviewed against the `reviewing-code` checklist. ## Fixes - **Deprecated config sections are no longer dropped when the parent is an inline table.** With `commit = { stage = "tracked" }` alongside a deprecated `[commit-generation]`, migration removed the old section but silently failed to write `[commit.generation]` — `as_table_mut()` returns nothing for an inline table, so the nested insert was skipped after the source had already been removed. Migration now converts an inline-table parent to a standard table before inserting. Real data loss without this. - **Deprecated template variables are rewritten only inside template tags.** The old `\bword\b` regex rewrote any occurrence of a deprecated name — literal command text (`echo repo_root`), `{% set %}` locals, member access — not just genuine `{{ … }}` references. That could corrupt a command on migration, and could collapse two distinct commands to the same normalized string so an un-approved command matched an approved one. The rewrite is now gated on `minijinja`'s parser (`undeclared_variables`) plus a delimiter-aware scan, so only real deprecated references inside `{{ }}`/`{% %}` are rewritten. This is the fix #2841 deliberately deferred to a sibling branch. - **Repo-wide internal logs are written as top-level files.** Branch-agnostic internal-operation logs were written into a top-level `internal/` directory, violating the layout invariant that top-level directories are per-branch trees — `wt config state` would miscategorize `internal/` as a branch. They now write to `internal-{op}.log` files alongside the other shared logs. - **System config is routed through the deprecation gate.** System config used a silent migrate path; it now goes through `check_and_migrate` like user config, so a deprecation in system config surfaces warnings consistently. ## Scope Pure code — the clawpatch review tooling and its state files are excluded. ## Testing `cargo run -- hook pre-merge --yes` — 3825 tests pass, lints clean. > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: Claude --- src/commands/config/state.rs | 13 +- src/commands/process.rs | 27 +- src/config/approvals.rs | 16 + src/config/deprecation.rs | 541 ++++++++++++++++++++---- src/config/user/mod.rs | 29 +- tests/integration_tests/config_show.rs | 75 ++++ tests/integration_tests/config_state.rs | 37 ++ 7 files changed, 636 insertions(+), 102 deletions(-) diff --git a/src/commands/config/state.rs b/src/commands/config/state.rs index 7aaa52808f..51f8167042 100644 --- a/src/commands/config/state.rs +++ b/src/commands/config/state.rs @@ -33,8 +33,9 @@ //! # Log layout invariant //! //! Inside `wt_logs_dir()`, top-level *files* are shared logs (`commands.jsonl*`, -//! `trace.log`, `output.log`, `diagnostic.md`) and top-level *directories* are -//! per-branch log trees (`{branch}/{source|internal}/{hook-type}/{name}.log`). +//! `internal-*.log`, `trace.log`, `output.log`, `diagnostic.md`) and top-level +//! *directories* are per-branch log trees +//! (`{branch}/{source|internal}/{hook-type}/{name}.log`). //! Categorization //! relies on this file-vs-directory distinction: new top-level shared entries //! must remain files. If a future category needs multiple files, it should live @@ -110,8 +111,14 @@ pub fn require_user_config_path() -> anyhow::Result { /// Top-level files created by `-vv` under `wt_logs_dir()`. const DIAGNOSTIC_FILES: &[&str] = &["trace.log", "output.log", "diagnostic.md"]; +/// Whether a top-level file is a diagnostic log. +/// +/// Covers the fixed `-vv` files and repo-wide internal-operation logs +/// (`internal-{op}.log`, e.g. `internal-trash-sweep.log`) — both are +/// branch-agnostic shared files, distinct from the per-branch hook-output +/// subtrees and the `commands.jsonl` audit log. fn is_diagnostic_file(name: &str) -> bool { - DIAGNOSTIC_FILES.contains(&name) + DIAGNOSTIC_FILES.contains(&name) || (name.starts_with("internal-") && name.ends_with(".log")) } /// Truncate a string for a display cell, counting by Unicode scalars. diff --git a/src/commands/process.rs b/src/commands/process.rs index 1ce544233e..3f4b194583 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -45,10 +45,10 @@ pub enum InternalOp { /// Per-branch internal operations produce logs at: `{branch}/internal/{op}.log` /// - Example: `feature/internal/remove.log` /// -/// Repo-wide (branch-agnostic) internal operations produce logs at -/// `internal/{op}.log` directly under the log directory, alongside the other -/// shared logs (`commands.jsonl`, `trace.log`). -/// - Example: `internal/trash-sweep.log` +/// Repo-wide (branch-agnostic) internal operations produce top-level logs at +/// `internal-{op}.log`, alongside the other shared logs (`commands.jsonl`, +/// `trace.log`). +/// - Example: `internal-trash-sweep.log` /// /// Branch and hook names are sanitized for filesystem safety via /// `sanitize_for_filename`. Already-safe names pass through unchanged; names @@ -64,7 +64,7 @@ pub enum HookLog { }, /// Per-branch internal operation log: `{branch}/internal/{op}.log` Internal(InternalOp), - /// Repo-wide internal operation log: `internal/{op}.log` (no branch segment). + /// Repo-wide internal operation log: `internal-{op}.log` (no branch segment). Shared(InternalOp), } @@ -92,7 +92,7 @@ impl HookLog { /// /// Builds the nested path under `{log_dir}/{sanitized-branch}/...` for /// per-branch variants. The `Shared` variant ignores `branch` and writes - /// directly under `{log_dir}/internal/`. + /// directly under `{log_dir}` as `internal-{op}.log`. /// Parent directories must be created by the caller (see `create_detach_log`). pub fn path(&self, log_dir: &Path, branch: &str) -> PathBuf { match self { @@ -109,7 +109,7 @@ impl HookLog { .join(sanitize_for_filename(branch)) .join("internal") .join(format!("{op}.log")), - HookLog::Shared(op) => log_dir.join("internal").join(format!("{op}.log")), + HookLog::Shared(op) => log_dir.join(format!("internal-{op}.log")), } } } @@ -528,10 +528,9 @@ pub fn sweep_stale_trash(repo: &Repository) { let command = build_trash_sweep_command(&stale); - // The sweep is repo-wide (not branch-scoped), so it logs under - // `internal/trash-sweep.log` alongside the other shared logs - // (`commands.jsonl`, `trace.log`). The branch argument is ignored for the - // `Shared` variant. + // The sweep is repo-wide (not branch-scoped), so it logs to a top-level + // shared file alongside `commands.jsonl` and `trace.log`. The branch + // argument is ignored for the `Shared` variant. if let Err(e) = spawn_detached( repo, &repo.wt_dir(), @@ -1010,14 +1009,14 @@ mod tests { ); // Repo-wide (branch-agnostic) internal operation path: - // {log_dir}/internal/{op}.log — the branch argument is ignored. + // {log_dir}/internal-{op}.log — the branch argument is ignored. assert_snapshot!( HookLog::shared(InternalOp::TrashSweep).path(log_dir, "anything").to_slash_lossy(), - @"/repo/.git/wt/logs/internal/trash-sweep.log" + @"/repo/.git/wt/logs/internal-trash-sweep.log" ); assert_snapshot!( HookLog::shared(InternalOp::TrashSweep).path(log_dir, "").to_slash_lossy(), - @"/repo/.git/wt/logs/internal/trash-sweep.log" + @"/repo/.git/wt/logs/internal-trash-sweep.log" ); } diff --git a/src/config/approvals.rs b/src/config/approvals.rs index 0a90bd6d6a..1410098f22 100644 --- a/src/config/approvals.rs +++ b/src/config/approvals.rs @@ -673,6 +673,22 @@ mod tests { assert!(approvals.is_command_approved("project", "echo {{ repo_path }}")); } + #[test] + fn test_literal_command_text_is_not_normalized_for_approval_matching() { + let (_temp_dir, path) = test_dir(); + + let mut approvals = Approvals::default(); + approvals + .approve_command( + "project".to_string(), + "echo repo_root".to_string(), + Some(&path), + ) + .unwrap(); + + assert!(!approvals.is_command_approved("project", "echo repo_path")); + } + #[test] fn test_concurrent_approve_preserves_all() { use std::sync::{Arc, Barrier}; diff --git a/src/config/deprecation.rs b/src/config/deprecation.rs index 3302fbe7e6..9bcb6e20d9 100644 --- a/src/config/deprecation.rs +++ b/src/config/deprecation.rs @@ -25,7 +25,6 @@ use std::sync::{LazyLock, Mutex, OnceLock}; use anyhow::Context; use color_print::cformat; use minijinja::Environment; -use regex::Regex; use shell_escape::unix::escape; use crate::config::WorktrunkConfig; @@ -57,18 +56,6 @@ fn warnings_suppressed() -> bool { SUPPRESS_WARNINGS.get().is_some() } -/// Pre-compiled regexes for deprecated variable word-boundary matching. -/// Compiled once on first use, shared across all calls to normalize/replace. -static DEPRECATED_VAR_REGEXES: LazyLock> = LazyLock::new(|| { - DEPRECATED_VARS - .iter() - .map(|&(old, new)| { - let re = Regex::new(&format!(r"\b{}\b", regex::escape(old))).unwrap(); - (re, new) - }) - .collect() -}); - /// Tracks which config paths have already shown unknown field warnings this process. /// Prevents repeated warnings when config is loaded multiple times. static WARNED_UNKNOWN_PATHS: LazyLock>> = @@ -146,11 +133,197 @@ pub fn normalize_template_vars(template: &str) -> Cow<'_, str> { return Cow::Borrowed(template); } - let mut result = template.to_string(); - for (re, new) in DEPRECATED_VAR_REGEXES.iter() { - result = re.replace_all(&result, *new).into_owned(); + let env = Environment::new(); + let Ok(parsed) = env.template_from_str(template) else { + return Cow::Borrowed(template); + }; + let used_vars = parsed.undeclared_variables(false); + let replacements: Vec<_> = DEPRECATED_VARS + .iter() + .copied() + .filter(|(old, _)| used_vars.contains(*old)) + .collect(); + if replacements.is_empty() { + return Cow::Borrowed(template); + } + + rewrite_template_var_identifiers(template, &replacements) + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed(template)) +} + +fn rewrite_template_var_identifiers( + template: &str, + replacements: &[(&str, &'static str)], +) -> Option { + let mut out = String::with_capacity(template.len()); + let mut cursor = 0; + let mut changed = false; + let mut in_raw = false; + + while let Some((tag_start, tag_kind)) = find_next_template_tag(template, cursor) { + out.push_str(&template[cursor..tag_start]); + + let (body_start, close_delim) = match tag_kind { + TemplateTagKind::Variable => (tag_start + 2, "}}"), + TemplateTagKind::Block => (tag_start + 2, "%}"), + TemplateTagKind::Comment => { + let end = template[tag_start + 2..].find("#}")? + tag_start + 4; + out.push_str(&template[tag_start..end]); + cursor = end; + continue; + } + }; + let tag_end = template[body_start..].find(close_delim)? + body_start; + let full_tag_end = tag_end + close_delim.len(); + + if tag_kind == TemplateTagKind::Block + && matches!( + template_block_name(&template[body_start..tag_end]), + Some("raw") + ) + { + in_raw = true; + } + + if in_raw { + out.push_str(&template[tag_start..full_tag_end]); + if tag_kind == TemplateTagKind::Block + && matches!( + template_block_name(&template[body_start..tag_end]), + Some("endraw") + ) + { + in_raw = false; + } + } else { + let body_start = + body_start + usize::from(template[body_start..tag_end].starts_with('-')); + let body_end = tag_end - usize::from(template[body_start..tag_end].ends_with('-')); + let (rewritten_body, body_changed) = + rewrite_template_tag_body(&template[body_start..body_end], replacements); + out.push_str(&template[tag_start..body_start]); + out.push_str(&rewritten_body); + out.push_str(&template[body_end..full_tag_end]); + changed |= body_changed; + } + + cursor = full_tag_end; + } + + out.push_str(&template[cursor..]); + changed.then_some(out) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum TemplateTagKind { + Variable, + Block, + Comment, +} + +fn find_next_template_tag(template: &str, from: usize) -> Option<(usize, TemplateTagKind)> { + let mut search_from = from; + loop { + let rel = template[search_from..].find('{')?; + let idx = search_from + rel; + let rest = &template[idx..]; + let kind = if rest.starts_with("{{") { + TemplateTagKind::Variable + } else if rest.starts_with("{%") { + TemplateTagKind::Block + } else if rest.starts_with("{#") { + TemplateTagKind::Comment + } else { + search_from = idx + 1; + continue; + }; + return Some((idx, kind)); + } +} + +fn template_block_name(body: &str) -> Option<&str> { + let body = body.strip_prefix('-').unwrap_or(body).trim_start(); + let end = body + .find(|c: char| !is_template_identifier_char(c)) + .unwrap_or(body.len()); + (end > 0).then_some(&body[..end]) +} + +fn rewrite_template_tag_body(body: &str, replacements: &[(&str, &'static str)]) -> (String, bool) { + let mut out = String::with_capacity(body.len()); + let mut cursor = 0; + let mut changed = false; + + while let Some(ch) = body.get(cursor..).and_then(|s| s.chars().next()) { + if ch == '"' || ch == '\'' { + let end = quoted_template_string_end(body, cursor, ch); + out.push_str(&body[cursor..end]); + cursor = end; + } else if is_template_identifier_start(ch) { + let end = identifier_end(body, cursor); + let ident = &body[cursor..end]; + if !is_template_attribute_or_assignment(body, cursor, end) + && let Some((_, new)) = replacements.iter().find(|(old, _)| *old == ident) + { + out.push_str(new); + changed = true; + } else { + out.push_str(ident); + } + cursor = end; + } else { + out.push(ch); + cursor += ch.len_utf8(); + } } - Cow::Owned(result) + + (out, changed) +} + +fn quoted_template_string_end(body: &str, start: usize, quote: char) -> usize { + let mut escaped = false; + let mut cursor = start + quote.len_utf8(); + while let Some(ch) = body.get(cursor..).and_then(|s| s.chars().next()) { + cursor += ch.len_utf8(); + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == quote { + break; + } + } + cursor +} + +fn identifier_end(body: &str, start: usize) -> usize { + let mut cursor = start; + while let Some(ch) = body.get(cursor..).and_then(|s| s.chars().next()) { + if !is_template_identifier_char(ch) { + break; + } + cursor += ch.len_utf8(); + } + cursor +} + +fn is_template_attribute_or_assignment(body: &str, start: usize, end: usize) -> bool { + let previous = body[..start].chars().rev().find(|c| !c.is_whitespace()); + if previous == Some('.') { + return true; + } + + let next = body[end..].trim_start(); + next.starts_with('=') && !next.starts_with("==") +} + +fn is_template_identifier_start(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphabetic() +} + +fn is_template_identifier_char(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphanumeric() } /// Core logic for deprecated var detection, operating on pre-extracted template strings @@ -222,11 +395,10 @@ fn collect_strings_from_edit_value(value: &toml_edit::Value, strings: &mut Vec Option { - let mut modified = original.to_string(); - for (re, new) in DEPRECATED_VAR_REGEXES.iter() { - modified = re.replace_all(&modified, *new).into_owned(); + match normalize_template_vars(original) { + Cow::Borrowed(_) => None, + Cow::Owned(modified) => Some(modified), } - (modified != original).then_some(modified) } /// Replace deprecated template vars in every string value of the document, @@ -404,11 +576,7 @@ fn find_commit_generation_from_doc(doc: &toml_edit::DocumentMut) -> CommitGenera // Check if new [commit.generation] already exists as a valid table // (skip deprecation warning if so) - let has_new_section = doc - .get("commit") - .and_then(|c| c.as_table()) - .and_then(|t| t.get("generation")) - .is_some_and(|g| g.is_table() || g.is_inline_table()); + let has_new_section = has_table_like_child(doc.get("commit"), "generation"); // Check top-level [commit-generation] - only flag if non-empty and new section doesn't exist // Handle both regular tables and inline tables @@ -429,11 +597,8 @@ fn find_commit_generation_from_doc(doc: &toml_edit::DocumentMut) -> CommitGenera for (project_key, project_value) in projects.iter() { if let Some(project_table) = project_value.as_table() { // Check if this project has new section as a valid table - let has_new_project_section = project_table - .get("commit") - .and_then(|c| c.as_table()) - .and_then(|t| t.get("generation")) - .is_some_and(|g| g.is_table() || g.is_inline_table()); + let has_new_project_section = + has_table_like_child(project_table.get("commit"), "generation"); // Only flag if old section exists, is non-empty, and new doesn't exist // Handle both regular tables and inline tables @@ -469,6 +634,38 @@ fn can_host_subtable(item: Option<&toml_edit::Item>) -> bool { item.is_none_or(is_table_like) } +fn has_table_like_child(item: Option<&toml_edit::Item>, key: &str) -> bool { + match item { + Some(toml_edit::Item::Table(t)) => t.get(key).is_some_and(is_table_like), + Some(toml_edit::Item::Value(toml_edit::Value::InlineTable(t))) => t + .get(key) + .is_some_and(|v| matches!(v, toml_edit::Value::InlineTable(_))), + _ => false, + } +} + +/// Ensure a table-like parent is writable as a standard table. +/// +/// Inline tables can deserialize like tables, but TOML forbids extending them +/// with later subtables. Convert before inserting migrated nested sections so +/// existing inline parent fields survive alongside the new child table. +fn ensure_standard_table_parent<'a>( + table: &'a mut toml_edit::Table, + key: &str, +) -> Option<&'a mut toml_edit::Table> { + if !table.contains_key(key) { + let mut parent = toml_edit::Table::new(); + parent.set_implicit(true); + table.insert(key, toml_edit::Item::Table(parent)); + } + + let item = table.get_mut(key)?; + if let Some(inline) = item.as_inline_table().cloned() { + *item = toml_edit::Item::Table(inline.into_table()); + } + item.as_table_mut() +} + /// Convert a table-like TOML item into a `Table`. Returns `None` for other shapes. fn into_table(item: toml_edit::Item) -> Option { match item { @@ -483,11 +680,7 @@ fn migrate_commit_generation_doc(doc: &mut toml_edit::DocumentMut) -> bool { // Check if new [commit.generation] already exists as a valid table - if so, skip migration // (new format takes precedence, don't overwrite it) - let has_new_section = doc - .get("commit") - .and_then(|c| c.as_table()) - .and_then(|t| t.get("generation")) - .is_some_and(|g| g.is_table() || g.is_inline_table()); + let has_new_section = has_table_like_child(doc.get("commit"), "generation"); // Migrate top-level [commit-generation] → [commit.generation] // Only if new section doesn't already exist @@ -510,14 +703,8 @@ fn migrate_commit_generation_doc(doc: &mut toml_edit::DocumentMut) -> bool { // Ensure [commit] section exists. // Mark as implicit so it doesn't render a separate [commit] header // (only [commit.generation] will render) - if !doc.contains_key("commit") { - let mut commit_table = toml_edit::Table::new(); - commit_table.set_implicit(true); - doc.insert("commit", toml_edit::Item::Table(commit_table)); - } - // Move to [commit.generation] - if let Some(commit_table) = doc["commit"].as_table_mut() { + if let Some(commit_table) = ensure_standard_table_parent(doc.as_table_mut(), "commit") { commit_table.insert("generation", toml_edit::Item::Table(table)); } @@ -529,11 +716,8 @@ fn migrate_commit_generation_doc(doc: &mut toml_edit::DocumentMut) -> bool { for (_project_key, project_value) in projects.iter_mut() { if let Some(project_table) = project_value.as_table_mut() { // Check if new section already exists as a valid table for this project - let has_new_project_section = project_table - .get("commit") - .and_then(|c| c.as_table()) - .and_then(|t| t.get("generation")) - .is_some_and(|g| g.is_table() || g.is_inline_table()); + let has_new_project_section = + has_table_like_child(project_table.get("commit"), "generation"); // Peek before removing so a malformed value is preserved. // Same scalar-parent guard as the top-level case above. @@ -551,14 +735,10 @@ fn migrate_commit_generation_doc(doc: &mut toml_edit::DocumentMut) -> bool { // Ensure [projects."...".commit] section exists. // Mark as implicit so it doesn't render a separate header - if !project_table.contains_key("commit") { - let mut commit_table = toml_edit::Table::new(); - commit_table.set_implicit(true); - project_table.insert("commit", toml_edit::Item::Table(commit_table)); - } - // Move to [projects."...".commit.generation] - if let Some(commit_table) = project_table["commit"].as_table_mut() { + if let Some(commit_table) = + ensure_standard_table_parent(project_table, "commit") + { commit_table.insert("generation", toml_edit::Item::Table(table)); } @@ -644,11 +824,7 @@ fn find_select_from_doc(doc: &toml_edit::DocumentMut) -> bool { /// Check if a table has a non-empty `select` section without `switch.picker`. fn has_select_without_picker(table: &toml_edit::Table) -> bool { - let has_new_section = table - .get("switch") - .and_then(|s| s.as_table()) - .and_then(|t| t.get("picker")) - .is_some_and(|p| p.is_table() || p.is_inline_table()); + let has_new_section = has_table_like_child(table.get("switch"), "picker"); if has_new_section { return false; @@ -778,11 +954,7 @@ fn migrate_select_doc(doc: &mut toml_edit::DocumentMut) -> bool { /// it — silently dropping it would lose user config when a sibling migration /// also rewrites the document. fn migrate_select_table(table: &mut toml_edit::Table, modified: &mut bool) { - let has_new_section = table - .get("switch") - .and_then(|s| s.as_table()) - .and_then(|t| t.get("picker")) - .is_some_and(|p| p.is_table() || p.is_inline_table()); + let has_new_section = has_table_like_child(table.get("switch"), "picker"); if has_new_section { return; @@ -801,13 +973,7 @@ fn migrate_select_table(table: &mut toml_edit::Table, modified: &mut bool) { let select_table = into_table(table.remove("select").unwrap()).expect("checked is_table_like above"); - if !table.contains_key("switch") { - let mut switch_table = toml_edit::Table::new(); - switch_table.set_implicit(true); - table.insert("switch", toml_edit::Item::Table(switch_table)); - } - - if let Some(switch_table) = table["switch"].as_table_mut() { + if let Some(switch_table) = ensure_standard_table_parent(table, "switch") { switch_table.insert("picker", toml_edit::Item::Table(select_table)); } @@ -831,7 +997,7 @@ fn collect_pre_hook_table_form_keys( ) { for &key in PRE_HOOK_KEYS { if let Some(item) = table.get(key) - && item.as_table().is_some_and(|t| t.len() >= 2) + && table_like_len(item).is_some_and(|len| len >= 2) { if prefix.is_empty() { found.push(key.to_string()); @@ -866,6 +1032,14 @@ fn find_pre_hook_table_form_from_doc(doc: &toml_edit::DocumentMut) -> Vec Option { + match item { + toml_edit::Item::Table(t) => Some(t.len()), + toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => Some(t.len()), + _ => None, + } +} + fn find_ci_section_from_doc(doc: &toml_edit::DocumentMut) -> bool { // Skip if [forge] already exists if doc @@ -1027,20 +1201,19 @@ fn migrate_pre_hook_table_in(table: &mut toml_edit::Table, modified: &mut bool) .iter() .filter(|(k, v)| { PRE_HOOK_KEYS.contains(k) - && v.as_table() - .is_some_and(|t| t.len() >= 2 && t.iter().all(|(_, v)| v.as_str().is_some())) + && pre_hook_pipeline_entries(v).is_some_and(|entries| entries.len() >= 2) }) .map(|(k, _)| k.to_string()) .collect(); for key in keys_to_migrate { let item = table.get_mut(&key).unwrap(); - let entries = item.as_table().unwrap(); + let entries = pre_hook_pipeline_entries(item).unwrap(); let mut arr = toml_edit::ArrayOfTables::new(); for (name, value) in entries.iter() { let mut block = toml_edit::Table::new(); - block.insert(name, toml_edit::value(value.as_str().unwrap())); + block.insert(name, toml_edit::value(value.as_str())); arr.push(block); } @@ -1049,6 +1222,26 @@ fn migrate_pre_hook_table_in(table: &mut toml_edit::Table, modified: &mut bool) } } +fn pre_hook_pipeline_entries(item: &toml_edit::Item) -> Option> { + match item { + toml_edit::Item::Table(t) => { + let entries = t + .iter() + .map(|(name, value)| Some((name.to_string(), value.as_str()?.to_string()))) + .collect::>>()?; + Some(entries) + } + toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => { + let entries = t + .iter() + .map(|(name, value)| Some((name.to_string(), value.as_str()?.to_string()))) + .collect::>>()?; + Some(entries) + } + _ => None, + } +} + /// Migrate multi-entry pre-* hook table sections to pipeline arrays. /// /// Hooks are flattened into the top level of user config, project config, and @@ -2238,6 +2431,17 @@ timeout = 30 assert_eq!(compute_migrated_content(content), content); } + #[test] + fn test_compute_migrated_content_does_not_rewrite_literal_text_when_other_template_uses_deprecated_var() + { + let content = "pre-merge = \"echo repo_root\"\npost-merge = \"echo {{ repo_root }}\"\n"; + let migrated = compute_migrated_content(content); + assert_eq!( + migrated, + "pre-merge = \"echo repo_root\"\npost-merge = \"echo {{ repo_path }}\"\n" + ); + } + /// The `replace_deprecated_vars` helper must return the input untouched /// when it cannot be parsed as TOML, rather than panicking. #[test] @@ -2259,7 +2463,6 @@ timeout = 30 #[test] fn test_replace_in_statement_blocks() { - // Word boundary replacement handles {% %} blocks too let content = r#"cmd = "{% if repo_root %}echo {{ repo_root }}{% endif %}""#; let result = replace_deprecated_vars(content); assert_eq!( @@ -2278,6 +2481,89 @@ timeout = 30 assert_eq!(result, template); } + #[test] + fn test_normalize_does_not_rewrite_literal_text() { + let template = "echo repo_root"; + let result = normalize_template_vars(template); + assert!(matches!(result, Cow::Borrowed(_)), "Should not allocate"); + assert_eq!(result, template); + } + + #[test] + fn test_normalize_only_rewrites_template_identifiers() { + let template = "echo repo_root && echo {{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!(result, "echo repo_root && echo {{ repo_path }}"); + } + + /// When `repo_root` is bound as a `{% set %}` local it is no longer the + /// deprecated global, so minijinja reports no undeclared `repo_root` and + /// the template is left untouched — the local name is not silently renamed. + #[test] + fn test_normalize_skips_set_assignment_target() { + let template = "{% set repo_root = \"x\" %}{{ repo_root }}"; + let result = normalize_template_vars(template); + assert!(matches!(result, Cow::Borrowed(_)), "Should not allocate"); + assert_eq!(result, template); + } + + /// Identifiers inside `{# #}` comments must not be rewritten. + #[test] + fn test_normalize_skips_comment_tags() { + let template = "{# repo_root #}{{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!(result, "{# repo_root #}{{ repo_path }}"); + } + + /// Identifiers inside `{% raw %}…{% endraw %}` blocks are verbatim text, + /// not template references, so they must be left alone — only a genuine + /// reference outside the raw block is rewritten. + #[test] + fn test_normalize_skips_raw_blocks() { + let template = "{% raw %}{{ repo_root }}{% endraw %}{{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!( + result, + "{% raw %}{{ repo_root }}{% endraw %}{{ repo_path }}" + ); + } + + /// A deprecated name that appears as a quoted string literal inside a tag + /// is not an identifier and must not be rewritten. + #[test] + fn test_normalize_skips_string_literals_in_tags() { + let template = "{{ \"repo_root\" }} {{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!(result, "{{ \"repo_root\" }} {{ repo_path }}"); + } + + /// A deprecated name used as an attribute (`obj.repo_root`) is a member of + /// another value, not the deprecated global, so it must not be rewritten. + #[test] + fn test_normalize_skips_attribute_access() { + let template = "{{ obj.repo_root }} {{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!(result, "{{ obj.repo_root }} {{ repo_path }}"); + } + + /// A bare `{` that does not open a tag is literal text; the scan steps past + /// it and still rewrites a genuine reference later in the string. + #[test] + fn test_normalize_skips_bare_brace() { + let template = "{ literal {{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!(result, "{ literal {{ repo_path }}"); + } + + /// A backslash-escaped quote inside an in-tag string literal does not end + /// the literal early, so its contents are preserved verbatim. + #[test] + fn test_normalize_handles_escaped_quote_in_tag_string() { + let template = "{{ \"a\\\"repo_root\" }} {{ repo_root }}"; + let result = normalize_template_vars(template); + assert_eq!(result, "{{ \"a\\\"repo_root\" }} {{ repo_path }}"); + } + #[test] fn test_normalize_repo_root() { let template = "ln -sf {{ repo_root }}/node_modules"; @@ -2876,6 +3162,61 @@ no-ff = true ); } + #[test] + fn test_commit_generation_migrates_when_commit_parent_is_inline_table() { + let content = r#"commit = { stage = "tracked" } + +[commit-generation] +command = "llm" +"#; + let result = migrate_content(content); + let doc: toml_edit::DocumentMut = result.parse().unwrap(); + let commit = doc["commit"].as_table().expect("commit table"); + assert_eq!( + commit["stage"].as_str(), + Some("tracked"), + "inline parent fields must survive: {result}" + ); + assert_eq!( + commit["generation"]["command"].as_str(), + Some("llm"), + "deprecated section should move under commit.generation: {result}" + ); + assert!( + doc.get("commit-generation").is_none(), + "old section should be removed after migration: {result}" + ); + } + + #[test] + fn test_project_commit_generation_migrates_when_commit_parent_is_inline_table() { + let content = r#" +[projects."github.com/user/repo"] +commit = { stage = "tracked" } +commit-generation = { command = "llm" } +"#; + let result = migrate_content(content); + let doc: toml_edit::DocumentMut = result.parse().unwrap(); + let project = doc["projects"]["github.com/user/repo"] + .as_table() + .expect("project table"); + let commit = project["commit"].as_table().expect("project commit table"); + assert_eq!( + commit["stage"].as_str(), + Some("tracked"), + "inline project parent fields must survive: {result}" + ); + assert_eq!( + commit["generation"]["command"].as_str(), + Some("llm"), + "project deprecated section should move under commit.generation: {result}" + ); + assert!( + project.get("commit-generation").is_none(), + "old project section should be removed after migration: {result}" + ); + } + /// Same shape for `[select]` when `switch = "x"` is scalar. #[test] fn test_select_preserved_when_switch_is_scalar() { @@ -3495,6 +3836,32 @@ pager = "delta --paging=never" ); } + #[test] + fn test_migrate_select_when_switch_parent_is_inline_table() { + let content = r#"switch = { cd = false } + +[select] +pager = "delta" +"#; + let result = migrate_select_to_switch_picker(content); + let doc: toml_edit::DocumentMut = result.parse().unwrap(); + let switch = doc["switch"].as_table().expect("switch table"); + assert_eq!( + switch["cd"].as_bool(), + Some(false), + "inline switch fields must survive: {result}" + ); + assert_eq!( + switch["picker"]["pager"].as_str(), + Some("delta"), + "select should move under switch.picker: {result}" + ); + assert!( + doc.get("select").is_none(), + "old select section should be removed after migration: {result}" + ); + } + #[test] fn test_migrate_select_skips_when_new_exists() { let content = r#" @@ -4117,6 +4484,10 @@ ff = true let found = find_pre_hook_table_form("pre-merge = \"cargo test\"\n"); assert!(found.is_empty()); + // Inline table form → detected like section table form + let found = find_pre_hook_table_form("pre-merge = { test = \"t\", lint = \"l\" }\n"); + assert_eq!(found, vec!["pre-merge"]); + // Array/pipeline form → not detected let found = find_pre_hook_table_form("pre-merge = [{test = \"t\"}, {lint = \"l\"}]\n"); assert!(found.is_empty()); @@ -4200,6 +4571,20 @@ lint = "cargo clippy" ); } + #[test] + fn test_migrate_pre_hook_inline_table_form_converts_to_pipeline() { + let content = r#"pre-merge = { test = "cargo test", lint = "cargo clippy" } +"#; + let result = migrate_pre_hook_table_form(content); + let doc: toml_edit::DocumentMut = result.parse().unwrap(); + let arr = doc["pre-merge"] + .as_array_of_tables() + .expect("should be array of tables"); + assert_eq!(arr.len(), 2); + assert_eq!(arr.get(0).unwrap()["test"].as_str(), Some("cargo test")); + assert_eq!(arr.get(1).unwrap()["lint"].as_str(), Some("cargo clippy")); + } + #[test] fn test_migrate_pre_hook_table_form_preserves_order() { let content = r#" diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index ffe7bdb83f..2a015984f3 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -388,15 +388,30 @@ impl UserConfig { if let Some(system_path) = path::system_config_path() && let Ok(content) = std::fs::read_to_string(&system_path) { - super::deprecation::warn_unknown_fields::( - &content, + match super::deprecation::check_and_migrate( &system_path, + &content, + true, "System config", - ); - let migrated = super::deprecation::migrate_content(&content); - match load_config_file(&system_path, &migrated, "System config") { - Ok(table) => deep_merge_table(&mut merged_table, table), - Err(e) => warnings.push(e), + None, + true, + ) { + Ok(result) => { + super::deprecation::warn_unknown_fields::( + &content, + &system_path, + "System config", + ); + + match load_config_file(&system_path, &result.migrated_content, "System config") + { + Ok(table) => deep_merge_table(&mut merged_table, table), + Err(e) => warnings.push(e), + } + } + Err(err) => { + warnings.push(LoadError::Validation(err.to_string())); + } } } diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index e8cdfb6302..445d35fd6e 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -609,6 +609,81 @@ fn test_system_config_unknown_keys_warning_during_load(repo: TestRepo) { ); } +/// System config should use the same deprecation warning gate as user config. +#[rstest] +fn test_system_config_deprecation_warning_during_load(repo: TestRepo) { + let system_config_dir = tempfile::tempdir().unwrap(); + let system_config_path = system_config_dir.path().join("config.toml"); + fs::write( + &system_config_path, + r#"[select] +pager = "delta --paging=never" +"#, + ) + .unwrap(); + + let mut cmd = repo.wt_command(); + cmd.env("WORKTRUNK_SYSTEM_CONFIG_PATH", &system_config_path); + cmd.arg("list").current_dir(repo.root_path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "Command should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("System config") && stderr.contains("[select]"), + "Expected deprecation warning from system config load, got: {stderr}" + ); + assert!( + stderr.contains("[switch.picker]"), + "Expected replacement section in warning, got: {stderr}" + ); +} + +/// System config is routed through the same deprecation gate as user config: +/// a non-fatal deprecation surfaces a warning, and a deprecated hook key is +/// migrated to its canonical name and stays active. +#[rstest] +fn test_system_config_deprecations_pass_through_gate(repo: TestRepo) { + let system_config_dir = tempfile::tempdir().unwrap(); + let system_config_path = system_config_dir.path().join("config.toml"); + fs::write( + &system_config_path, + r#"post-start = "npm install" + +[select] +pager = "delta --paging=never" +"#, + ) + .unwrap(); + + let mut cmd = repo.wt_command(); + cmd.env("WORKTRUNK_SYSTEM_CONFIG_PATH", &system_config_path) + .env("NO_COLOR", "1"); + cmd.args(["hook", "show", "post-create"]) + .current_dir(repo.root_path()); + + let output = cmd.output().unwrap(); + assert!( + output.status.success(), + "Command should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("System config") && stderr.contains("[select]"), + "non-fatal deprecation in system config should warn, got: {stderr}" + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("npm install"), + "post-start in system config should migrate to post-create and stay active, got: {stdout}" + ); +} + #[rstest] fn test_config_show_outside_git_repo(mut repo: TestRepo, temp_home: TempDir) { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/tests/integration_tests/config_state.rs b/tests/integration_tests/config_state.rs index 2c1a9e8df0..b11750a08d 100644 --- a/tests/integration_tests/config_state.rs +++ b/tests/integration_tests/config_state.rs @@ -28,6 +28,11 @@ fn internal_log_rel_path(branch: &str, op: &str) -> PathBuf { .join(format!("{op}.log")) } +/// Relative path of a repo-wide internal-operation log under the wt logs directory. +fn shared_internal_log_rel_path(op: &str) -> PathBuf { + PathBuf::from(format!("internal-{op}.log")) +} + /// Write a log file at `log_dir / relative`, creating parent directories. fn write_log_at(log_dir: &Path, relative: &Path, contents: &str) { let full = log_dir.join(relative); @@ -2150,6 +2155,38 @@ fn test_logs_get_json_internal_op_structure(repo: TestRepo) { assert!(hook["branch"].as_str().unwrap().starts_with("feature")); } +/// Repo-wide internal logs are top-level shared files, not branch subtrees. +/// They must surface under `diagnostic` rather than being silently dropped. +#[rstest] +fn test_logs_get_json_shared_internal_log_is_not_hook_output(repo: TestRepo) { + let log_dir = repo.root_path().join(".git/wt/logs"); + let relative = shared_internal_log_rel_path("trash-sweep"); + write_log_at(&log_dir, &relative, "sweep output"); + + let output = wt_state_cmd(&repo, "logs", "get", &["--format=json"]) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let parsed: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + parsed["hook_output"].as_array().unwrap().is_empty(), + "shared internal log must not be reported as branch hook output: {stdout}" + ); + assert!( + !log_dir.join("internal").exists(), + "shared internal log must not create a branch-like internal directory" + ); + let diagnostic = parsed["diagnostic"].as_array().unwrap(); + assert_eq!( + diagnostic.len(), + 1, + "shared internal log must surface as a diagnostic: {stdout}" + ); + assert_eq!(diagnostic[0]["file"], "internal-trash-sweep.log"); +} + /// Log files that don't match the expected branch subtree layout (`{branch}/{source}/{hook_type}/{name}.log` /// or `{branch}/internal/{op}.log`) still appear in the JSON listing — just /// without structured filter fields. Guards the defensive `_ => None` arm in From 2890ee0282e52abd79e719700d0016e0e3d22d50 Mon Sep 17 00:00:00 2001 From: Worktrunk Bot Date: Thu, 21 May 2026 01:20:33 -0700 Subject: [PATCH 11/34] docs(CLAUDE): rename pre-start/post-start to pre-create/post-create (#2854) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d733186a3a..9e70be71e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Why: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signal **Why:** the gate is the only thing between `git clone && wt switch` and a `post-switch` hook running `curl … | sh`. A "we already validated the operation, so run the hooks too" shortcut turns every command that touches project config into remote code execution. -**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook `, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec. +**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-create`, `post-create`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook `, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec. ## Hook Output Logs From 52f86bfdab29d736a1647fe56d2455267e976633 Mon Sep 17 00:00:00 2001 From: Worktrunk Bot Date: Thu, 21 May 2026 09:03:03 -0700 Subject: [PATCH 12/34] revert(hooks): keep docs on pre-start/post-start; code accepts both (#2857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @max-sixty's [direction in #2838](https://github.com/max-sixty/worktrunk/issues/2838#issuecomment-4509447593): revert the docs portion of #2840 and keep the code. Docs continue to recommend `pre-start`/`post-start`; both names work in code so anyone who already followed the briefly-changed docs (e.g. @EcksDy) isn't stranded once a release ships these aliases. ## User-visible — back to `pre-start`/`post-start` - README, docs site, skill mirrors, `dev/*.example.toml`, `plugins/worktrunk/README.md`, `flake.nix`, `.config/wt.toml` - `src/cli/mod.rs` / `src/cli/config.rs` / `src/cli/step.rs` / `src/help.rs` after_long_help and example snippets — and the auto-synced `docs/content/` and `skills/worktrunk/reference/` mirrors - `wt hook --help` canonical subcommand names; completion advertises `-start` only - `HookType` Display via strum, serde `rename`, and clap `ValueEnum` name — all `pre-start`/`post-start`. The Rust variant identifiers stay `PreCreate`/`PostCreate` (internal; we already paid for that rename in #2840, and now the eventual flip is a Display-only change) - `HooksConfig` serde canonical fields ## `*-create` still works (kept code) - `wt hook pre-create` / `post-create` — CLI alias on the canonical subcommand - `pre-create` / `post-create` in config: top-level, `[hooks.*]`, and per-project, in string, `[table]`, and `[[array-of-tables]]` form. Mechanism: serde `alias = ...` on the field, plus a silent in-memory rename in `migrate_content()` so the round-trip in `unknown_tree` doesn't flag table forms as schema-unknown. - The pre-0.32.0 `post-create` fatal-load-error machinery stays removed — the name is reclaimed, and both forms load without error. ## Smaller bits - `valid_user_config_keys()` / `valid_project_config_keys()` append `pre-create` / `post-create` so the unknown-field round-trip skips them. `test_valid_*_keys_all_deserialize` skips both aliases (they can't sit alongside the canonical without a duplicate-field error). - `DEPRECATED_SECTION_KEYS` drops the `pre-start`/`post-start` entries #2840 added — `pre-start`/`post-start` are canonical again. - `find_pre_start_from_doc` / `find_post_start_from_doc` / `find_renamed_hook_key` / `is_non_empty_item` / `migrate_start_hooks_doc` and their tests are removed; the migration direction flips via a new `migrate_create_hooks_doc` (silent, mirrors the prior shape). - Test files `e2e_shell_post_create.rs` and `post_create_commands.rs` rename back to `_post_start_` (via `git mv`, so the rename shows as a rename). ## Testing `cargo run -- hook pre-merge --yes` — 3806 tests pass; the 10 failures are all `case_4` of `shell_wrapper::unix_tests::*` (nu-shell case; `nu` isn't installed in this runner; same failures occur on `main`). Also manually verified that a fresh `wt switch --create` against a project config with `[post-create]` loads cleanly with no unknown-field warning and the hook fires as `post-start`. ## Follow-up Per @max-sixty: in a couple of weeks, once a release with both-names-work is out and users have had a chance to upgrade, the docs flip is straightforward (most of it is in `src/cli/mod.rs`'s `after_long_help` and the doc-sync test propagates). Re #2838. Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 --- .claude/skills/writing-user-outputs/SKILL.md | 2 +- .config/wt.toml | 2 - CHANGELOG.md | 2 +- CLAUDE.md | 2 +- README.md | 2 +- dev/config.example.toml | 12 +- dev/wt.example.toml | 4 +- docs/CLAUDE.md | 2 +- docs/content/claude-code.md | 2 +- docs/content/config.md | 28 +- docs/content/extending.md | 8 +- docs/content/faq.md | 6 +- docs/content/hook.md | 62 ++- docs/content/step.md | 10 +- docs/content/switch.md | 4 +- docs/content/tips-patterns.md | 30 +- docs/content/worktrunk.md | 2 +- docs/demos/snapshots/wt-core.snap | 2 +- docs/demos/tapes/wt-core.tape | 2 +- docs/demos/tapes/wt-devserver.tape | 2 +- docs/demos/tapes/wt-hooks.tape | 4 +- docs/demos/tapes/wt-switch.tape | 2 +- docs/demos/tapes/wt-zellij-omnibus.tape | 4 +- .../.well-known/agent-skills/index.json | 4 +- flake.nix | 2 +- plugins/worktrunk/README.md | 4 +- skills/worktrunk/SKILL.md | 26 +- skills/worktrunk/reference/claude-code.md | 2 +- skills/worktrunk/reference/config.md | 28 +- skills/worktrunk/reference/extending.md | 8 +- skills/worktrunk/reference/faq.md | 6 +- skills/worktrunk/reference/hook.md | 60 ++- skills/worktrunk/reference/step.md | 10 +- skills/worktrunk/reference/switch.md | 4 +- skills/worktrunk/reference/tips-patterns.md | 30 +- skills/worktrunk/reference/troubleshooting.md | 6 +- skills/worktrunk/reference/worktrunk.md | 2 +- src/cli/config.rs | 16 +- src/cli/hook.rs | 24 +- src/cli/mod.rs | 80 ++-- src/cli/step.rs | 10 +- src/command_log.rs | 2 +- src/commands/config/state.rs | 2 +- src/commands/hook_announcement.rs | 4 +- src/commands/hook_commands.rs | 4 +- src/commands/hook_plan.rs | 6 +- src/commands/hooks.rs | 4 +- src/commands/mod.rs | 8 +- src/commands/process.rs | 8 +- src/commands/project_config.rs | 4 +- src/commands/step/tether.rs | 2 +- src/commands/worktree/hooks.rs | 6 +- src/commands/worktree/switch.rs | 30 +- src/completion.rs | 14 +- src/config/commands.rs | 8 +- src/config/deprecation.rs | 357 ++++-------------- src/config/expansion.rs | 8 +- src/config/hooks.rs | 6 +- src/config/mod.rs | 26 +- src/config/project.rs | 17 +- ..._snapshot_migrate_pre_hook_table_form.snap | 2 +- ...sts__snapshot_migrate_start_to_create.snap | 10 - src/config/user/schema.rs | 13 +- src/config/user/tests.rs | 55 +-- src/git/error.rs | 4 +- src/git/mod.rs | 10 + src/git/repository/config.rs | 4 +- src/help.rs | 12 +- src/styling/mod.rs | 2 +- src/testing/mod.rs | 2 +- tests/common/mod.rs | 6 +- tests/integration_tests/approval_pty.rs | 22 +- tests/integration_tests/approval_ui.rs | 20 +- tests/integration_tests/approvals.rs | 16 +- tests/integration_tests/bare_repository.rs | 6 +- tests/integration_tests/completion.rs | 6 +- tests/integration_tests/config_init.rs | 2 +- tests/integration_tests/config_show.rs | 50 +-- tests/integration_tests/config_state.rs | 26 +- tests/integration_tests/config_update_pty.rs | 4 +- ...post_create.rs => e2e_shell_post_start.rs} | 26 +- tests/integration_tests/help.rs | 6 +- tests/integration_tests/hook_show.rs | 22 +- tests/integration_tests/list.rs | 2 +- tests/integration_tests/merge.rs | 2 +- tests/integration_tests/mod.rs | 4 +- ...ate_commands.rs => post_start_commands.rs} | 276 +++++++------- tests/integration_tests/readme_sync.rs | 10 +- tests/integration_tests/shell_wrapper.rs | 66 ++-- ...lay__hook_command_failed_without_name.snap | 4 +- tests/integration_tests/switch.rs | 78 ++-- tests/integration_tests/switch_picker.rs | 12 +- tests/integration_tests/user_hooks.rs | 355 +++++++++-------- ..._approval_pty__approval_prompt_accept.snap | 4 +- ...approval_pty__approval_prompt_decline.snap | 2 +- ...ompt_mixed_approved_unapproved_accept.snap | 10 +- ...mpt_mixed_approved_unapproved_decline.snap | 4 +- ...ty__approval_prompt_multiple_commands.snap | 12 +- ...l_pty__approval_prompt_named_commands.snap | 12 +- ...pty__approval_prompt_permission_error.snap | 4 +- ...oval_ui__already_approved_skip_prompt.snap | 2 +- ...pproval_ui__approval_fails_in_non_tty.snap | 2 +- ...i__approval_mixed_approved_unapproved.snap | 6 +- ...proval_ui__approval_multiple_commands.snap | 10 +- ..._approval_ui__approval_named_commands.snap | 8 +- ..._approval_ui__approval_single_command.snap | 2 +- ...ecline_approval_skips_only_unapproved.snap | 6 +- ...__approval_ui__yes_bypasses_tty_check.snap | 2 +- ...yes_does_not_save_approvals_first_run.snap | 2 +- ...es_does_not_save_approvals_second_run.snap | 2 +- ...vals__add_approvals_all_none_approved.snap | 2 +- ..._bare_repo_config_in_primary_worktree.snap | 2 +- ...fig_show_displays_deprecation_details.snap | 6 +- ...plays_pre_hook_table_form_deprecation.snap | 10 +- ...ig_show_displays_start_hook_migration.snap | 10 +- ...show__config_show_with_project_config.snap | 4 +- ...update_applies_template_var_migration.snap | 6 +- ...onfig_state__logs_get_json_with_files.snap | 6 +- ...config_state__state_get_comprehensive.snap | 8 +- ...date_pty__config_update_prompt_accept.snap | 6 +- ...ate_pty__config_update_prompt_decline.snap | 6 +- ...ation_tests__help__help_config_create.snap | 16 +- ...gration_tests__help__help_config_long.snap | 18 +- ...n_tests__help__help_config_state_logs.snap | 6 +- ...gration_tests__help__help_switch_long.snap | 4 +- ...lp__nested_subcommand_hook_pre_start.snap} | 6 +- ...__hook_show_merges_user_project_hooks.snap | 4 +- ...ook_show__hook_show_with_both_configs.snap | 2 +- ...eate_mixed_user_pipeline_project_flat.snap | 64 ---- ...ands__post_create_multiple_background.snap | 64 ---- ...s__post_create_pipeline_template_vars.snap | 64 ---- ...ommands__post_create_project_pipeline.snap | 64 ---- ...e_commands__post_create_separate_logs.snap | 64 ---- ...mmands__post_create_single_background.snap | 64 ---- ...commands__post_create_target_variable.snap | 64 ---- ...rt_commands__both_pre_and_post_start.snap} | 6 +- ...rt_commands__execute_with_post_start.snap} | 4 +- ...s__post_start_commands__invalid_toml.snap} | 8 +- ..._commands__post_start_base_variables.snap} | 8 +- ...t_commands__post_start_complex_shell.snap} | 4 +- ...ands__post_start_create_with_command.snap} | 4 +- ...__post_start_default_branch_template.snap} | 4 +- ...commands__post_start_failing_command.snap} | 8 +- ...s__post_start_git_variables_template.snap} | 16 +- ..._commands__post_start_invalid_command.snap | 64 ++++ ...mands__post_start_log_captures_output.snap | 64 ++++ ...tart_mixed_user_pipeline_project_flat.snap | 64 ++++ ...st_start_multiline_control_structure.snap} | 4 +- ...__post_start_multiline_with_newlines.snap} | 4 +- ...ands__post_start_multiple_background.snap} | 4 +- ..._commands__post_start_named_commands.snap} | 8 +- ...start_commands__post_start_no_config.snap} | 2 +- ...s__post_start_pipeline_template_vars.snap} | 4 +- ...commands__post_start_project_pipeline.snap | 64 ++++ ...rt_commands__post_start_separate_logs.snap | 64 ++++ ...ommands__post_start_single_background.snap | 64 ++++ ..._commands__post_start_single_command.snap} | 4 +- ...t_commands__post_start_skip_existing.snap} | 2 +- ..._commands__post_start_target_variable.snap | 64 ++++ ...mands__post_start_template_expansion.snap} | 14 +- ...nds__post_start_upstream_conditional.snap} | 4 +- ...mmands__post_start_upstream_template.snap} | 4 +- ..._commands__post_start_verbose_output.snap} | 10 +- ...ost_start_verbose_template_expansion.snap} | 10 +- ...post_switch_target_variable_existing.snap} | 2 +- ...h_wrapper_preserves_progress_messages.snap | 2 +- ...tests__readme_example_approval_prompt.snap | 6 +- ...ests__readme_example_hooks_pre_start.snap} | 4 +- ...er__unix_tests__switch_with_hooks_zsh.snap | 6 +- ...post_start_command_no_directive_leak.snap} | 2 +- ...__wrapper_preserves_progress_messages.snap | 2 +- ..._tests__switch__switch_combined_hooks.snap | 2 +- ...ch__switch_no_hooks_skips_post_start.snap} | 4 +- ...ooks__background_hook_env_var_visible.snap | 6 +- ..._hooks__hook_verbose_background_dedup.snap | 12 +- ...lone_hook_name_filtered_lazy_template.snap | 4 +- ...er_hooks__user_and_project_post_start.snap | 64 ++++ ..._user_and_project_unnamed_post_start.snap} | 2 +- ...__user_hooks__user_hook_template_vars.snap | 2 +- ...user_hooks__user_hooks_before_project.snap | 4 +- ...s__user_hooks__user_hooks_no_approval.snap | 2 +- ...user_hooks__user_hooks_preserve_order.snap | 10 +- ...st_create_pipeline_concurrent_failure.snap | 64 ---- ...st_create_pipeline_hook_name_per_step.snap | 64 ---- ...s__user_post_create_pipeline_ordering.snap | 64 ---- ...user_hooks__user_post_start_executes.snap} | 2 +- ..._user_hooks__user_post_start_failure.snap} | 6 +- ...r_post_start_pipeline_concurrent_all.snap} | 2 +- ...st_start_pipeline_concurrent_failure.snap} | 2 +- ...ks__user_post_start_pipeline_failure.snap} | 2 +- ...ost_start_pipeline_hook_name_per_step.snap | 64 ++++ ...ser_post_start_pipeline_lazy_vars_bg.snap} | 2 +- ...ks__user_post_start_pipeline_ordering.snap | 64 ++++ ...ks__user_post_start_skipped_no_hooks.snap} | 0 ..._user_hooks__user_pre_start_executes.snap} | 2 +- 195 files changed, 1835 insertions(+), 2052 deletions(-) delete mode 100644 src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_start_to_create.snap rename tests/integration_tests/{e2e_shell_post_create.rs => e2e_shell_post_start.rs} (90%) rename tests/integration_tests/{post_create_commands.rs => post_start_commands.rs} (84%) rename tests/snapshots/{integration__integration_tests__help__nested_subcommand_hook_pre_create.snap => integration__integration_tests__help__nested_subcommand_hook_pre_start.snap} (89%) delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_mixed_user_pipeline_project_flat.snap delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiple_background.snap delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_pipeline_template_vars.snap delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_project_pipeline.snap delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_separate_logs.snap delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_background.snap delete mode 100644 tests/snapshots/integration__integration_tests__post_create_commands__post_create_target_variable.snap rename tests/snapshots/{integration__integration_tests__post_create_commands__both_pre_and_post_create.snap => integration__integration_tests__post_start_commands__both_pre_and_post_start.snap} (89%) rename tests/snapshots/{integration__integration_tests__post_create_commands__execute_with_post_create.snap => integration__integration_tests__post_start_commands__execute_with_post_start.snap} (94%) rename tests/snapshots/{integration__integration_tests__post_create_commands__invalid_toml.snap => integration__integration_tests__post_start_commands__invalid_toml.snap} (90%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_base_variables.snap => integration__integration_tests__post_start_commands__post_start_base_variables.snap} (80%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_create_with_command.snap => integration__integration_tests__post_start_commands__post_start_complex_shell.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_complex_shell.snap => integration__integration_tests__post_start_commands__post_start_create_with_command.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_default_branch_template.snap => integration__integration_tests__post_start_commands__post_start_default_branch_template.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_failing_command.snap => integration__integration_tests__post_start_commands__post_start_failing_command.snap} (85%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_git_variables_template.snap => integration__integration_tests__post_start_commands__post_start_git_variables_template.snap} (72%) create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_invalid_command.snap create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_log_captures_output.snap create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_mixed_user_pipeline_project_flat.snap rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_multiline_control_structure.snap => integration__integration_tests__post_start_commands__post_start_multiline_control_structure.snap} (94%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_multiline_with_newlines.snap => integration__integration_tests__post_start_commands__post_start_multiline_with_newlines.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_log_captures_output.snap => integration__integration_tests__post_start_commands__post_start_multiple_background.snap} (92%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_named_commands.snap => integration__integration_tests__post_start_commands__post_start_named_commands.snap} (79%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_no_config.snap => integration__integration_tests__post_start_commands__post_start_no_config.snap} (97%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_invalid_command.snap => integration__integration_tests__post_start_commands__post_start_pipeline_template_vars.snap} (92%) create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_project_pipeline.snap create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_separate_logs.snap create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_background.snap rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_single_command.snap => integration__integration_tests__post_start_commands__post_start_single_command.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_skip_existing.snap => integration__integration_tests__post_start_commands__post_start_skip_existing.snap} (97%) create mode 100644 tests/snapshots/integration__integration_tests__post_start_commands__post_start_target_variable.snap rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_template_expansion.snap => integration__integration_tests__post_start_commands__post_start_template_expansion.snap} (75%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_upstream_conditional.snap => integration__integration_tests__post_start_commands__post_start_upstream_conditional.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_upstream_template.snap => integration__integration_tests__post_start_commands__post_start_upstream_template.snap} (93%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_verbose_output.snap => integration__integration_tests__post_start_commands__post_start_verbose_output.snap} (91%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_create_verbose_template_expansion.snap => integration__integration_tests__post_start_commands__post_start_verbose_template_expansion.snap} (91%) rename tests/snapshots/{integration__integration_tests__post_create_commands__post_switch_target_variable_existing.snap => integration__integration_tests__post_start_commands__post_switch_target_variable_existing.snap} (97%) rename tests/snapshots/{integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_create.snap => integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_start.snap} (74%) rename tests/snapshots/{integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_create_command_no_directive_leak.snap => integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_start_command_no_directive_leak.snap} (85%) rename tests/snapshots/{integration__integration_tests__switch__switch_no_hooks_skips_post_create.snap => integration__integration_tests__switch__switch_no_hooks_skips_post_start.snap} (92%) create mode 100644 tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_start.snap rename tests/snapshots/{integration__integration_tests__user_hooks__user_and_project_unnamed_post_create.snap => integration__integration_tests__user_hooks__user_and_project_unnamed_post_start.snap} (95%) delete mode 100644 tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_failure.snap delete mode 100644 tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_hook_name_per_step.snap delete mode 100644 tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_ordering.snap rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_create_executes.snap => integration__integration_tests__user_hooks__user_post_start_executes.snap} (95%) rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_create_failure.snap => integration__integration_tests__user_hooks__user_post_start_failure.snap} (86%) rename tests/snapshots/{integration__integration_tests__user_hooks__user_and_project_post_create.snap => integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_all.snap} (95%) rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_all.snap => integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_failure.snap} (94%) rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_create_pipeline_lazy_vars_bg.snap => integration__integration_tests__user_hooks__user_post_start_pipeline_failure.snap} (95%) create mode 100644 tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_hook_name_per_step.snap rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_create_pipeline_failure.snap => integration__integration_tests__user_hooks__user_post_start_pipeline_lazy_vars_bg.snap} (95%) create mode 100644 tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_ordering.snap rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_create_skipped_no_hooks.snap => integration__integration_tests__user_hooks__user_post_start_skipped_no_hooks.snap} (100%) rename tests/snapshots/{integration__integration_tests__user_hooks__user_pre_create_executes.snap => integration__integration_tests__user_hooks__user_pre_start_executes.snap} (96%) diff --git a/.claude/skills/writing-user-outputs/SKILL.md b/.claude/skills/writing-user-outputs/SKILL.md index a1f25871ba..623404b998 100644 --- a/.claude/skills/writing-user-outputs/SKILL.md +++ b/.claude/skills/writing-user-outputs/SKILL.md @@ -214,7 +214,7 @@ Use the appropriate helper function: 2. **Post-hooks** — User will cd to destination if shell integration is active. Use `output::post_hook_display_path(destination)`. - Examples: pre-create, post-switch, post-create, post-merge (after removal). + Examples: pre-start, post-switch, post-start, post-merge (after removal). ```rust // Pre-hooks: user is at cwd, no cd happens diff --git a/.config/wt.toml b/.config/wt.toml index ed77b0f575..03f4cb9415 100644 --- a/.config/wt.toml +++ b/.config/wt.toml @@ -1,5 +1,3 @@ -# Kept on `post-start` (not `post-create`): CI bootstraps with a pinned older -# `wt` that predates the rename. Migrate once a release ships it (#2838). [post-start] # Copy target/ from main worktree using CoW (~3s vs ~68s full rebuild) deps = "wt step copy-ignored" diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c596fcc3..cf5ab40e11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Improved -- **Worktree-creation hooks renamed to `pre-create`/`post-create`**: `pre-start` and `post-start` are renamed so the name matches when the hooks fire (worktree creation). Old configs keep working: the old keys migrate to the new names silently on load, `wt config update` rewrites them on disk, and `wt hook pre-start`/`post-start` still run. No deprecation warning yet. The semantic flip to watch: before v0.32.0 the key `post-create` named a _blocking_ creation hook, and it now names the _background_ one. A pre-0.32.0 `post-create` config has been a fatal load error since v0.44.0, so any such config was forced to migrate well before the name was reclaimed. Full plan: [#2838](https://github.com/max-sixty/worktrunk/issues/2838). +- **`pre-create`/`post-create` accepted as aliases for the worktree-creation hooks**: `pre-create` and `post-create` now load as silent aliases for the canonical `pre-start`/`post-start` hooks, in config (top-level, `[hooks.*]`, and per-project sections, in string, table, and array-of-tables form) and on the `wt hook` command line. The fatal load error for pre-0.32.0 `post-create` configs is removed — `post-create` is now valid TOML and loads as the background creation hook (formerly named `post-start`). The semantic flip to watch: before v0.32.0 `post-create` named a _blocking_ creation hook; it now names the _background_ one. A pre-0.32.0 `post-create` config has been a fatal load error since v0.44.0, so any such config was forced to migrate well before the name was reclaimed. Docs continue to recommend `pre-start`/`post-start` while users update; the canonical form may switch in a later release. Full plan: [#2838](https://github.com/max-sixty/worktrunk/issues/2838). ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 9e70be71e6..d733186a3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Why: wt installs a `signal_hook` SIGINT/SIGTERM handler so it can forward signal **Why:** the gate is the only thing between `git clone && wt switch` and a `post-switch` hook running `curl … | sh`. A "we already validated the operation, so run the hooks too" shortcut turns every command that touches project config into remote code execution. -**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-create`, `post-create`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook `, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec. +**Implementation:** the operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`) are gated *before* a state mutation and run *after* it, so a second config read could select an unapproved command. `src/commands/hook_plan.rs` closes this structurally: each gate (`wt remove` / `wt merge` / `wt step prune` / `wt switch`) selects the command set once into an immutable `ApprovedHookPlan` (`HookPlan::approve`); the executor consumes only that value via `execute_planned_hook` / `register_planned` and holds no `ProjectConfig` to re-derive from, so re-selection is a compile error, not a review check. An empty plan (`--no-hooks`, declined, or no project config) runs nothing. The adjacent hooks with no gate→exec mutation (`pre-commit`, `post-commit`, `pre-switch`, `wt hook `, aliases) still resolve config at invocation via `execute_hook` / `HookAnnouncer::register`. See `src/commands/hook_plan.rs` and the `commands::hooks` module spec. ## Hook Output Logs diff --git a/README.md b/README.md index 50a5a728b7..bf46ed70af 100644 --- a/README.md +++ b/README.md @@ -206,7 +206,7 @@ wt switch -x claude -c feature-b -- 'Fix the pagination bug' wt switch -x claude -c feature-c -- 'Write tests for the API' ``` -The `-x` flag runs a command after switching; arguments after `--` are passed to it. Configure [post-create hooks](https://worktrunk.dev/hook/#hook-types) to automate setup (install deps, start dev servers). +The `-x` flag runs a command after switching; arguments after `--` are passed to it. Configure [post-start hooks](https://worktrunk.dev/hook/#hook-types) to automate setup (install deps, start dev servers). ## Next steps diff --git a/dev/config.example.toml b/dev/config.example.toml index 3522833a20..6ca84620ac 100644 --- a/dev/config.example.toml +++ b/dev/config.example.toml @@ -168,26 +168,26 @@ # list.full = true # merge.squash = false # remove.delete-branch = false -# pre-create.env = "cp .env.example .env" +# pre-start.env = "cp .env.example .env" # step.copy-ignored.exclude = [".repo-local-cache/"] # aliases.deploy = "make deploy BRANCH={{ branch }}" # -# Hooks support all three hook forms (https://worktrunk.dev/hook/#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-create.server = "..."` and a `[projects."github.com/user/repo".post-create]` table the same way: +# Hooks support all three hook forms (https://worktrunk.dev/hook/#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-start.server = "..."` and a `[projects."github.com/user/repo".post-start]` table the same way: # # # Single command # [projects."github.com/user/repo"] -# post-create = "mise trust" +# post-start = "mise trust" # # # Multiple commands, running concurrently -# [projects."github.com/user/repo".post-create] +# [projects."github.com/user/repo".post-start] # mise = "mise trust" # server = "npm run dev" # # # Pipeline: steps run in sequence -# [[projects."github.com/user/repo".post-create]] +# [[projects."github.com/user/repo".post-start]] # install = "npm ci" # -# [[projects."github.com/user/repo".post-create]] +# [[projects."github.com/user/repo".post-start]] # build = "npm run build" # server = "npm run dev" # diff --git a/dev/wt.example.toml b/dev/wt.example.toml index 43a3cecc63..4091239b7b 100644 --- a/dev/wt.example.toml +++ b/dev/wt.example.toml @@ -8,8 +8,8 @@ # # Project hooks apply to this repository only. See `wt hook` (https://worktrunk.dev/hook/) for hook types, execution order, and examples. # -# pre-create = "npm ci" -# post-create = "npm run dev" +# pre-start = "npm ci" +# post-start = "npm run dev" # pre-merge = "npm test" # # ## Dev server URL diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index ada3062b3a..bbafffe886 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -4,7 +4,7 @@ This is the Zola-based documentation site for Worktrunk, published at worktrunk. ## Development workflow -The docs dev server starts automatically via the post-create hook. Find the port with `wt list statusline`. +The docs dev server starts automatically via the post-start hook. Find the port with `wt list statusline`. For static builds with local URLs (e.g., testing with a simple HTTP server): diff --git a/docs/content/claude-code.md b/docs/content/claude-code.md index 8f3011213d..26a91666cb 100644 --- a/docs/content/claude-code.md +++ b/docs/content/claude-code.md @@ -55,7 +55,7 @@ Gemini loads the extension natively from the repository, so there is no `wt` wra The plugin includes a skill — documentation the agent can read — covering Worktrunk's configuration system. After installation, the agent can help with: - Setting up LLM-generated commit messages -- Adding project hooks (pre-create, pre-merge, pre-commit) +- Adding project hooks (pre-start, pre-merge, pre-commit) - Configuring worktree path templates - Fixing shell integration issues diff --git a/docs/content/config.md b/docs/content/config.md index 33e6d62545..0d769e9d7c 100644 --- a/docs/content/config.md +++ b/docs/content/config.md @@ -52,7 +52,7 @@ command = "CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence ```toml # .config/wt.toml -[pre-create] +[pre-start] deps = "npm ci" [pre-merge] @@ -269,28 +269,28 @@ worktree-path = ".worktrees/{{ branch | sanitize }}" list.full = true merge.squash = false remove.delete-branch = false -pre-create.env = "cp .env.example .env" +pre-start.env = "cp .env.example .env" step.copy-ignored.exclude = [".repo-local-cache/"] aliases.deploy = "make deploy BRANCH={{ branch }}" ``` -Hooks support all three [hook forms](@/hook.md#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-create.server = "..."` and a `[projects."github.com/user/repo".post-create]` table the same way: +Hooks support all three [hook forms](@/hook.md#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-start.server = "..."` and a `[projects."github.com/user/repo".post-start]` table the same way: ```toml # Single command [projects."github.com/user/repo"] -post-create = "mise trust" +post-start = "mise trust" # Multiple commands, running concurrently -[projects."github.com/user/repo".post-create] +[projects."github.com/user/repo".post-start] mise = "mise trust" server = "npm run dev" # Pipeline: steps run in sequence -[[projects."github.com/user/repo".post-create]] +[[projects."github.com/user/repo".post-start]] install = "npm ci" -[[projects."github.com/user/repo".post-create]] +[[projects."github.com/user/repo".post-start]] build = "npm run build" server = "npm run dev" ``` @@ -437,8 +437,8 @@ To create a starter file with commented-out examples, run `wt config create --pr Project hooks apply to this repository only. See [`wt hook`](@/hook.md) for hook types, execution order, and examples. ```toml -pre-create = "npm ci" -post-create = "npm run dev" +pre-start = "npm ci" +post-start = "npm run dev" pre-merge = "npm test" ``` @@ -921,7 +921,7 @@ Hook output lives in per-branch subtrees under `.git/wt/logs/{branch}/`: | Background hooks | `{branch}/{source}/{hook-type}/{name}.log` | | Background removal | `{branch}/internal/remove.log` | -All `post-*` hooks (post-create, post-switch, post-commit, post-merge) run in the background and produce log files. Source is `user` or `project`. Branch and hook names are sanitized for filesystem safety (invalid characters → `-`; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with `wt config state logs clear`. +All `post-*` hooks (post-start, post-switch, post-commit, post-merge) run in the background and produce log files. Source is `user` or `project`. Branch and hook names are sanitized for filesystem safety (invalid characters → `-`; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with `wt config state logs clear`. #### Diagnostic files @@ -949,8 +949,8 @@ List all log files: Query the command log: {{ terminal(cmd="tail -5 .git/wt/logs/commands.jsonl | jq .") }} -Path to one hook log (e.g. the `post-create` `server` hook for the current branch): -{{ terminal(cmd="wt config state logs --format=json | jq -r '.hook_output[] | select(.source == __WT_QUOT__user__WT_QUOT__ and .hook_type == __WT_QUOT__post-create__WT_QUOT__ and (.name | startswith(__WT_QUOT__server__WT_QUOT__))) | .path'") }} +Path to one hook log (e.g. the `post-start` `server` hook for the current branch): +{{ terminal(cmd="wt config state logs --format=json | jq -r '.hook_output[] | select(.source == __WT_QUOT__user__WT_QUOT__ and .hook_type == __WT_QUOT__post-start__WT_QUOT__ and (.name | startswith(__WT_QUOT__server__WT_QUOT__))) | .path'") }} Logs for a specific branch: {{ terminal(cmd="wt config state logs --format=json | jq '.hook_output[] | select(.branch | startswith(__WT_QUOT__feature__WT_QUOT__))'") }} @@ -1149,7 +1149,7 @@ Operate on a different branch: Variables are available in [hook templates](@/hook.md#template-variables) as `{{ vars. }}`. Use the `default` filter for keys that may not be set: ```toml -[post-create] +[post-start] dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.port | default('3000') }}" ``` @@ -1157,7 +1157,7 @@ JSON object and array values support dot access: {{ terminal(cmd="wt config state vars set config='{__WT_QUOT__port__WT_QUOT__: 3000, __WT_QUOT__debug__WT_QUOT__: true}'") }} ```toml -[post-create] +[post-start] dev = "npm start -- --port {{ vars.config.port }}" ``` diff --git a/docs/content/extending.md b/docs/content/extending.md index 53889a3a6f..2f13ede53d 100644 --- a/docs/content/extending.md +++ b/docs/content/extending.md @@ -32,7 +32,7 @@ Hooks are shell commands that run at key points in the worktree lifecycle. Ten h | Event | `pre-` (blocking) | `post-` (background) | |-------|-------------------|---------------------| | **switch** | `pre-switch` | `post-switch` | -| **start** | `pre-create` | `post-create` | +| **start** | `pre-start` | `post-start` | | **commit** | `pre-commit` | `post-commit` | | **merge** | `pre-merge` | `post-merge` | | **remove** | `pre-remove` | `post-remove` | @@ -40,10 +40,10 @@ Hooks are shell commands that run at key points in the worktree lifecycle. Ten h `pre-*` hooks block — failure aborts the operation. `post-*` hooks run in the background. ```toml -[pre-create] +[pre-start] deps = "npm ci" -[post-create] +[post-start] server = "npm run dev -- --port {{ branch | hash_port }}" [pre-merge] @@ -179,7 +179,7 @@ tail -f "$(wt config state logs --format=json | jq -r --arg name "{{ name | sani ''' ``` -Run with `wt hook-log --kind=post-create --name=server` to tail the log for the `server` hook on the current branch. `--kind` picks the hook type; the branch is pulled from the current worktree via `{{ branch }}`. `sanitize_hash` rewrites `branch` and `name` to filesystem-safe forms with a hash suffix that keeps distinct originals unique — the same transformation Worktrunk applies on disk — so the alias resolves the right log even when either contains characters like `/`. +Run with `wt hook-log --kind=post-start --name=server` to tail the log for the `server` hook on the current branch. `--kind` picks the hook type; the branch is pulled from the current worktree via `{{ branch }}`. `sanitize_hash` rewrites `branch` and `name` to filesystem-safe forms with a hash suffix that keeps distinct originals unique — the same transformation Worktrunk applies on disk — so the alias resolves the right log even when either contains characters like `/`. ## Custom subcommands diff --git a/docs/content/faq.md b/docs/content/faq.md index 905e6d15d3..289ae98d5e 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -185,11 +185,11 @@ User hooks and user aliases don't require approval (you defined them). Commands {% terminal() %} repo needs approval to execute 3 commands: - pre-create install: + pre-start install: npm ci - pre-create build: + pre-start build: cargo build --release - pre-create env: + pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local Allow and remember? [y/N] diff --git a/docs/content/hook.md b/docs/content/hook.md index 990643d2f1..0f23aef7ce 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -18,23 +18,21 @@ Hooks are shell commands that run at key points in the worktree lifecycle — au | Event | `pre-` — blocking | `post-` — background | |-------|-------------------|---------------------| | **switch** | `pre-switch` | `post-switch` | -| **create** | `pre-create` | `post-create` | +| **create** | `pre-start` | `post-start` | | **commit** | `pre-commit` | `post-commit` | | **merge** | `pre-merge` | `post-merge` | | **remove** | `pre-remove` | `post-remove` | `pre-*` hooks block — failure aborts the operation. `post-*` hooks run in the background with output logged (use [`wt config state logs`](@/config.md#wt-config-state-logs) to find and manage log files). Use `-v` to see expanded command details for background hooks. -The most common creation hook is `post-create` — it runs background tasks (dev servers, file copying, builds) without blocking worktree creation. Prefer `post-create` over `pre-create` unless a later step needs the work completed first. - -`pre-start`/`post-start` are accepted as deprecated aliases for the renamed `pre-create`/`post-create` hooks, in config and on the command line. The [hook rename plan](https://github.com/max-sixty/worktrunk/issues/2838) tracks the deprecation. +The most common creation hook is `post-start` — it runs background tasks (dev servers, file copying, builds) without blocking worktree creation. Prefer `post-start` over `pre-start` unless a later step needs the work completed first. | Hook | Purpose | |------|---------| | `pre-switch` | Runs before branch resolution or worktree creation. `{{ branch }}` is the destination as typed (before resolution) | | `post-switch` | Triggers on all switch results: creating, switching to existing, or staying on current | -| `pre-create` | Runs once when a new worktree is created, blocking `post-create`/`--execute` until complete: dependency install, env file generation | -| `post-create` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches | +| `pre-start` | Runs once when a new worktree is created, blocking `post-start`/`--execute` until complete: dependency install, env file generation | +| `post-start` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches | | `pre-commit` | Formatters, linters, type checking — runs during `wt merge` before the squash commit | | `post-commit` | CI triggers, notifications, background linting | | `pre-merge` | Tests, security scans, build verification — runs after rebase, before merge to target | @@ -51,11 +49,11 @@ Project commands require approval on first run: {% terminal() %} repo needs approval to execute 3 commands: - pre-create install: + pre-start install: npm ci - pre-create build: + pre-start build: cargo build --release - pre-create env: + pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local Allow and remember? [y/N] @@ -80,13 +78,13 @@ Hooks take one of three forms, determined by their TOML shape. A string is a single command: ```toml -pre-create = "npm install" +pre-start = "npm install" ``` A table is multiple commands that run concurrently: ```toml -[post-create] +[post-start] server = "npm run dev" watch = "npm run watch" ``` @@ -94,10 +92,10 @@ watch = "npm run watch" A pipeline is a sequence of `[[hook]]` blocks run in order. Each block is one step; multiple keys within a block run concurrently. A failing step aborts the rest of the pipeline: ```toml -[[post-create]] +[[post-start]] install = "npm ci" -[[post-create]] +[[post-start]] build = "npm run build" server = "npm run dev" ``` @@ -145,7 +143,7 @@ Hooks can use template variables that expand at runtime: | | `{{ remote }}` | Primary remote name | | | `{{ remote_url }}` | Remote URL | | exec | `{{ cwd }}` | Directory where the hook command runs | -| | `{{ hook_type }}` | Hook type being run (e.g. `pre-create`, `pre-merge`) | +| | `{{ hook_type }}` | Hook type being run (e.g. `pre-start`, `pre-merge`) | | | `{{ hook_name }}` | Hook command name (if named) | | | `{{ args }}` | Tokens forwarded from the CLI — see [Running Hooks Manually](#running-hooks-manually) | | user | `{{ vars. }}` | Per-branch variables from [`wt config state vars`](@/config.md#wt-config-state-vars) | @@ -159,12 +157,12 @@ Bare variables (`branch`, `worktree_path`, `commit`) refer to the branch the ope | merge | feature being merged | = bare vars | merge target | | remove | branch being removed | = bare vars | where you end up | -Pre and post hooks share the same perspective — `{{ branch | hash_port }}` produces the same port in `post-create` and `post-remove`. `cwd` is the worktree root where the hook command runs. It differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target. +Pre and post hooks share the same perspective — `{{ branch | hash_port }}` produces the same port in `post-start` and `post-remove`. `cwd` is the worktree root where the hook command runs. It differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target. -Some variables are conditional: `upstream` requires remote tracking; `base` only appears in switch/create hooks; `target_worktree_path` requires the target to have a worktree; `pr_number`/`pr_url` are populated for `post-switch`, `pre-create`, and `post-create` hooks when creating via `pr:N` or `mr:N`; `vars` keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior: +Some variables are conditional: `upstream` requires remote tracking; `base` only appears in switch/create hooks; `target_worktree_path` requires the target to have a worktree; `pr_number`/`pr_url` are populated for `post-switch`, `pre-start`, and `post-start` hooks when creating via `pr:N` or `mr:N`; `vars` keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior: ```toml -[pre-create] +[pre-start] # Rebase onto upstream if tracking a remote branch (e.g., wt switch --create feature origin/feature) sync = "{% if upstream %}git fetch && git rebase {{ upstream }}{% endif %}" ``` @@ -174,7 +172,7 @@ Run any hook-firing command with `-v` to see the resolved variables for the actu Variables use dot access and the `default` filter for missing keys. JSON object/array values are parsed automatically, so `{{ vars.config.port }}` works when the value is `{"port": 3000}`: ```toml -[post-create] +[post-start] dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.config.port | default('3000') }}" ``` @@ -223,7 +221,7 @@ worktree-path = "{{ repo_path }}/../{{ repo_path | dirname | basename }}.{{ bran The `hash_port` filter is useful for running dev servers on unique ports per worktree: ```toml -[post-create] +[post-start] dev = "npm run dev -- --host {{ branch }}.localhost --port {{ branch | hash_port }}" ``` @@ -247,7 +245,7 @@ Templates also support functions for dynamic lookups: The `worktree_path_of_branch` function returns the filesystem path of a worktree given a branch name, or an empty string if no worktree exists for that branch. This is useful for referencing files in other worktrees: ```toml -[pre-create] +[pre-start] # Copy config from main worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` @@ -257,8 +255,8 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: ```toml -[pre-create] -setup = "python3 scripts/pre-create-setup.py" +[pre-start] +setup = "python3 scripts/pre-start-setup.py" ``` ```python @@ -273,7 +271,7 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: One specific command worth calling out: [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: ```toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` @@ -281,7 +279,7 @@ copy = "wt step copy-ignored" `wt hook ` runs hooks on demand — useful for testing during development, running in CI pipelines, or re-running after a failure. -{{ terminal(cmd="wt hook pre-merge # Run all pre-merge hooks|||wt hook pre-merge test # Run hooks named __WT_QUOT__test__WT_QUOT__ from both sources|||wt hook pre-merge test build # Run hooks named __WT_QUOT__test__WT_QUOT__ and __WT_QUOT__build__WT_QUOT__|||wt hook pre-merge user: # Run all user hooks|||wt hook pre-merge project: # Run all project hooks|||wt hook pre-merge user:test # Run only user's __WT_QUOT__test__WT_QUOT__ hook|||wt hook pre-merge --yes # Skip approval prompts (for CI)|||wt hook pre-create --branch=feature/test # Override a template variable|||wt hook pre-merge -- --extra args # Forward tokens into __WT_OPEN__ args __WT_CLOSE__") }} +{{ terminal(cmd="wt hook pre-merge # Run all pre-merge hooks|||wt hook pre-merge test # Run hooks named __WT_QUOT__test__WT_QUOT__ from both sources|||wt hook pre-merge test build # Run hooks named __WT_QUOT__test__WT_QUOT__ and __WT_QUOT__build__WT_QUOT__|||wt hook pre-merge user: # Run all user hooks|||wt hook pre-merge project: # Run all project hooks|||wt hook pre-merge user:test # Run only user's __WT_QUOT__test__WT_QUOT__ hook|||wt hook pre-merge --yes # Skip approval prompts (for CI)|||wt hook pre-start --branch=feature/test # Override a template variable|||wt hook pre-merge -- --extra args # Forward tokens into __WT_OPEN__ args __WT_CLOSE__") }} The `user:` and `project:` prefixes filter by source. Use `user:` or `project:` alone to run all hooks from that source, or `user:name` / `project:name` to run a specific hook. @@ -304,8 +302,8 @@ test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fin Finished dev [unoptimized + debuginfo] target(s) in 1.23s {% end %} -{% terminal(cmd="wt hook post-create") %} -◎ Running post-create: project @ ~/acme +{% terminal(cmd="wt hook post-start") %} +◎ Running post-start: project @ ~/acme {% end %} ## Passing values @@ -318,16 +316,16 @@ The long form `--var KEY=VALUE` is deprecated but still supported. It force-bind # Recipes -- [Eliminate cold starts](@/tips-patterns.md#eliminate-cold-starts): `wt step copy-ignored` in `post-create` shares build caches and dependencies; use a `[[post-create]]` pipeline when a later hook depends on the copy -- [Dev server per worktree](@/tips-patterns.md#dev-server-per-worktree): `wt step tether` in `post-create` runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing -- [Database per worktree](@/tips-patterns.md#database-per-worktree): a `post-create` pipeline stores container name, port, and connection string as [per-branch vars](@/config.md#wt-config-state-vars) that later hooks reference +- [Eliminate cold starts](@/tips-patterns.md#eliminate-cold-starts): `wt step copy-ignored` in `post-start` shares build caches and dependencies; use a `[[post-start]]` pipeline when a later hook depends on the copy +- [Dev server per worktree](@/tips-patterns.md#dev-server-per-worktree): `wt step tether` in `post-start` runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing +- [Database per worktree](@/tips-patterns.md#database-per-worktree): a `post-start` pipeline stores container name, port, and connection string as [per-branch vars](@/config.md#wt-config-state-vars) that later hooks reference - [Progressive validation](@/tips-patterns.md#progressive-validation): quick lint/typecheck in `pre-commit`, expensive tests and builds in `pre-merge` - [Target-specific hooks](@/tips-patterns.md#target-specific-hooks): branch on `{{ target }}` in `post-merge` for per-environment deploys ## See also - [`wt merge`](@/merge.md) — Runs hooks automatically during merge -- [`wt switch`](@/switch.md) — Runs pre-create/post-create hooks on `--create` +- [`wt switch`](@/switch.md) — Runs pre-start/post-start hooks on `--create` - [`wt config approvals`](@/config.md#wt-config-approvals) — Manage approvals - [`wt config state logs`](@/config.md#wt-config-state-logs) — Access background hook logs @@ -342,8 +340,8 @@ Usage: wt hook [OPTIONS] show Show configured hooks pre-switch Run pre-switch hooks post-switch Run post-switch hooks - pre-create Run pre-create hooks - post-create Run post-create hooks + pre-start Run pre-start hooks + post-start Run post-start hooks pre-commit Run pre-commit hooks post-commit Run post-commit hooks pre-merge Run pre-merge hooks diff --git a/docs/content/step.md b/docs/content/step.md index 1e657a8a72..e7567dee09 100644 --- a/docs/content/step.md +++ b/docs/content/step.md @@ -348,7 +348,7 @@ Add to the project config: ```toml # .config/wt.toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` @@ -400,7 +400,7 @@ Reflink copies share disk blocks until modified — no data is actually copied. Uses per-file reflink (like `cp -Rc`) — copy time scales with file count. -Use the `post-create` hook so the copy runs in the background. Use `pre-create` instead if subsequent hooks or `--execute` command need the copied files immediately. +Use the `post-start` hook so the copy runs in the background. Use `pre-start` instead if subsequent hooks or `--execute` command need the copied files immediately. ### Background-hook priority (experimental) @@ -419,7 +419,7 @@ The `target/` directory is huge (often 1-10GB). Copying with reflink cuts first `node_modules/` is large but mostly static. If the project has no native dependencies, symlinks are even faster: ```toml -[pre-create] +[pre-start] deps = "ln -sf {{ primary_worktree_path }}/node_modules ." ``` @@ -906,7 +906,7 @@ Run a command; kill its whole process tree when its worktree is removed. Teardow ### Why -A `post-create` hook to start a long-lived process and a `pre-remove` hook to +A `post-start` hook to start a long-lived process and a `pre-remove` hook to stop it is usually enough. But `pre-remove` only runs when worktrunk removes the worktree, so a `git worktree remove`, an `rm -rf`, or a crashed hook skips it. Across enough worktree churn some process is bound to outlive its worktree, @@ -931,7 +931,7 @@ Run a dev server, torn down automatically when the worktree goes away: ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" ``` diff --git a/docs/content/switch.md b/docs/content/switch.md index ca0a3ce083..129987b786 100644 --- a/docs/content/switch.md +++ b/docs/content/switch.md @@ -35,8 +35,8 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth 1. Runs [pre-switch hooks](@/hook.md#hook-types), blocking until complete 2. Creates worktree at configured path 3. Switches to new directory -4. Runs [pre-create hooks](@/hook.md#hook-types), blocking until complete -5. Spawns [post-create](@/hook.md#hook-types) and [post-switch hooks](@/hook.md#hook-types) in the background +4. Runs [pre-start hooks](@/hook.md#hook-types), blocking until complete +5. Spawns [post-start](@/hook.md#hook-types) and [post-switch hooks](@/hook.md#hook-types) in the background {{ terminal(cmd="wt switch feature # Existing branch → creates worktree|||wt switch --create feature # New branch and worktree|||wt switch --create fix --base release # New branch from release|||wt switch --create temp --no-hooks # Skip hooks") }} diff --git a/docs/content/tips-patterns.md b/docs/content/tips-patterns.md index 0949d058c1..6ce19f0770 100644 --- a/docs/content/tips-patterns.md +++ b/docs/content/tips-patterns.md @@ -50,7 +50,7 @@ Each worktree runs its own dev server on a deterministic port. The `hash_port` f ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" [list] @@ -83,7 +83,7 @@ Ports are deterministic: `fix-auth` always gets port 16460, regardless of which Each worktree can have its own isolated database. A pipeline sets up names and ports as [vars](@/config.md#wt-config-state-vars), then later steps and hooks reference them: ```toml -[[post-create]] +[[post-start]] set-vars = """ wt config state vars set \ container='{{ repo }}-{{ branch | sanitize }}-postgres' \ @@ -91,7 +91,7 @@ wt config state vars set \ db_url='postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ branch | sanitize_db }}' """ -[[post-create]] +[[post-start]] db = """ docker run -d --rm \ --name {{ vars.container }} \ @@ -118,21 +118,21 @@ The connection string is accessible anywhere — not just in hooks: Use [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) to copy gitignored files (caches, dependencies, `.env`) between worktrees: ```toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` -When another hook depends on the copy — for example, copying `node_modules/` before `pnpm install` so the install reuses cached packages — sequence them with a `[[post-create]]` pipeline: +When another hook depends on the copy — for example, copying `node_modules/` before `pnpm install` so the install reuses cached packages — sequence them with a `[[post-start]]` pipeline: ```toml -[[post-create]] +[[post-start]] copy = "wt step copy-ignored" -[[post-create]] +[[post-start]] install = "pnpm install" ``` -Use `pre-create` instead when an `--execute` command needs the copied files immediately. +Use `pre-start` instead when an `--execute` command needs the copied files immediately. All gitignored files are copied by default. To limit what gets copied, create `.worktreeinclude` with patterns — files must be both gitignored and listed. See [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) for details. @@ -211,7 +211,7 @@ Worktrunk maintains useful state. Default branch [detection](@/config.md#wt-conf Reference Taskfile/Justfile/Makefile in hooks: ```toml -[pre-create] +[pre-start] "setup" = "task install" [pre-merge] @@ -287,7 +287,7 @@ Each worktree gets its own tmux session with a multi-pane layout. ```toml # .config/wt.toml -[pre-create] +[pre-start] tmux = """ S={{ branch | sanitize }} W={{ worktree_path }} @@ -330,7 +330,7 @@ Each worktree gets its own [cmux](https://cmux.com) workspace. Switching worktre [switch] cd = false -[pre-create] +[pre-start] cmux = "cmux new-workspace --name {{ repo | sanitize }}/{{ branch | sanitize }} --cwd {{ worktree_path }} --focus true" [pre-switch] @@ -381,7 +381,7 @@ Clean URLs like `http://feature-auth.myproject.localhost` without port numbers. ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" proxy = """ curl -sf --max-time 0.5 http://localhost:2019/config/ || caddy start @@ -402,7 +402,7 @@ url = "http://{{ branch | sanitize }}.{{ repo }}.localhost:8080" **How it works:** -1. `wt switch --create feature-auth` runs the `post-create` hook, starting the dev server on a deterministic port (`{{ branch | hash_port }}` → 16460) +1. `wt switch --create feature-auth` runs the `post-start` hook, starting the dev server on a deterministic port (`{{ branch | hash_port }}` → 16460) 2. The hook starts Caddy if needed and registers a route using the same port: `feature-auth.myproject` → `localhost:16460` 3. `*.localhost` resolves to `127.0.0.1` via the OS 4. Visiting `http://feature-auth.myproject.localhost:8080`: Caddy matches the subdomain and proxies to the dev server @@ -411,9 +411,9 @@ url = "http://{{ branch | sanitize }}.{{ repo }}.localhost:8080" Follow background hook output in real-time: -{{ terminal(cmd="tail -f __WT_QUOT__$(wt config state logs get --hook=user:post-create:server)__WT_QUOT__") }} +{{ terminal(cmd="tail -f __WT_QUOT__$(wt config state logs get --hook=user:post-start:server)__WT_QUOT__") }} -The `--hook` format is `source:hook-type:name` — e.g., `project:post-create:build` for project-defined hooks. Use `wt config state logs get` to list all available logs. +The `--hook` format is `source:hook-type:name` — e.g., `project:post-start:build` for project-defined hooks. Use `wt config state logs get` to list all available logs. Create an alias for frequent use: diff --git a/docs/content/worktrunk.md b/docs/content/worktrunk.md index afe332496b..3ddd4134c3 100644 --- a/docs/content/worktrunk.md +++ b/docs/content/worktrunk.md @@ -198,7 +198,7 @@ For parallel agents, create multiple worktrees and launch an agent in each: {{ terminal(cmd="wt switch -x claude -c feature-a -- 'Add user authentication'|||wt switch -x claude -c feature-b -- 'Fix the pagination bug'|||wt switch -x claude -c feature-c -- 'Write tests for the API'") }} -The `-x` flag runs a command after switching; arguments after `--` are passed to it. Configure [post-create hooks](@/hook.md#hook-types) to automate setup (install deps, start dev servers). +The `-x` flag runs a command after switching; arguments after `--` are passed to it. Configure [post-start hooks](@/hook.md#hook-types) to automate setup (install deps, start dev servers). ## Next steps diff --git a/docs/demos/snapshots/wt-core.snap b/docs/demos/snapshots/wt-core.snap index 6df1b3e199..6b8410665d 100644 --- a/docs/demos/snapshots/wt-core.snap +++ b/docs/demos/snapshots/wt-core.snap @@ -13,7 +13,7 @@ $ wt switch alpha  $ wt switch --create api ✓ Created branch api from main and worktree @ /.demo-wt-core/w/acme.api -◎ Running post-create: project:dev +◎ Running post-start: project:dev  $ wt list --full Branch Status HEAD± main↕ main…± Remote⇅ CI Commit Age diff --git a/docs/demos/tapes/wt-core.tape b/docs/demos/tapes/wt-core.tape index b4271a12b5..b21bd876cd 100644 --- a/docs/demos/tapes/wt-core.tape +++ b/docs/demos/tapes/wt-core.tape @@ -20,7 +20,7 @@ Type "wt switch alpha" Enter Sleep 1s -# 3. Create new worktree with post-create +# 3. Create new worktree with post-start Type "wt switch --create api" Enter Sleep 2.5s diff --git a/docs/demos/tapes/wt-devserver.tape b/docs/demos/tapes/wt-devserver.tape index 35b73abe5a..11e4abd6de 100644 --- a/docs/demos/tapes/wt-devserver.tape +++ b/docs/demos/tapes/wt-devserver.tape @@ -10,7 +10,7 @@ Sleep 1s Show Sleep 500ms -# Show the config: post-create hooks + URL template +# Show the config: post-start hooks + URL template Type "cat .config/wt.toml" Enter Sleep 2s diff --git a/docs/demos/tapes/wt-hooks.tape b/docs/demos/tapes/wt-hooks.tape index 51990e270f..8caa88bdab 100644 --- a/docs/demos/tapes/wt-hooks.tape +++ b/docs/demos/tapes/wt-hooks.tape @@ -10,12 +10,12 @@ Sleep 1s Show Sleep 500ms -# Show the hooks config (3 hooks: post-create deps+dev, pre-remove cleanup) +# Show the hooks config (3 hooks: post-start deps+dev, pre-remove cleanup) Type "cat .config/wt.toml" Enter Sleep 3s -# Create a new worktree - shows post-create hook running +# Create a new worktree - shows post-start hook running Type "wt switch --create feature" Enter Sleep 3s diff --git a/docs/demos/tapes/wt-switch.tape b/docs/demos/tapes/wt-switch.tape index fb1eb9ca8a..07108aa60d 100644 --- a/docs/demos/tapes/wt-switch.tape +++ b/docs/demos/tapes/wt-switch.tape @@ -21,7 +21,7 @@ Type "wt switch alpha" Enter Sleep 1.5s -# Create new worktree (runs post-create hooks) +# Create new worktree (runs post-start hooks) Type "wt switch --create api" Enter Sleep 1.5s diff --git a/docs/demos/tapes/wt-zellij-omnibus.tape b/docs/demos/tapes/wt-zellij-omnibus.tape index 986b0ac30a..76951c3245 100644 --- a/docs/demos/tapes/wt-zellij-omnibus.tape +++ b/docs/demos/tapes/wt-zellij-omnibus.tape @@ -22,7 +22,7 @@ Output "{{OUTPUT_GIF}}" # - wt list / wt list --full (URLs, CI status, diff stats) # - wt switch (interactive picker) # - wsl abbreviation (wt switch --execute=claude --create) -# - wt switch --create (with post-create hooks) +# - wt switch --create (with post-start hooks) # - wt step commit (LLM commit message, standalone) # - wt merge (on uncommitted work - commits, rebases, merges, cleans up) # - wt remove (cleanup) @@ -101,7 +101,7 @@ Sleep 200ms Type "tn" Sleep 2s -# wt switch --create - shows post-create hooks running +# wt switch --create - shows post-start hooks running Type "wt switch --create feature" Enter Sleep 4.5s diff --git a/docs/static/.well-known/agent-skills/index.json b/docs/static/.well-known/agent-skills/index.json index 3185c35782..1c88051894 100644 --- a/docs/static/.well-known/agent-skills/index.json +++ b/docs/static/.well-known/agent-skills/index.json @@ -4,9 +4,9 @@ { "name": "worktrunk", "type": "skill-md", - "description": "Guidance for Worktrunk (the `wt` CLI) — git worktree management, hooks, and config. Load when editing .config/wt.toml or ~/.config/worktrunk/config.toml; adding, modifying, or debugging hooks (post-merge, post-create, pre-commit, pre-merge, post-switch, etc.); configuring commit message generation or command aliases; or troubleshooting wt behavior. Also answers general worktrunk/wt questions.", + "description": "Guidance for Worktrunk (the `wt` CLI) — git worktree management, hooks, and config. Load when editing .config/wt.toml or ~/.config/worktrunk/config.toml; adding, modifying, or debugging hooks (post-merge, post-start, pre-commit, pre-merge, post-switch, etc.); configuring commit message generation or command aliases; or troubleshooting wt behavior. Also answers general worktrunk/wt questions.", "url": "./worktrunk/SKILL.md", - "digest": "sha256:b929448a3425ceb32eae20b433e512e8498f54cf04a1f571c8c1ae79b17412db" + "digest": "sha256:1723b48a5a3a8a0a1b314dfadc96eed03aa5382ce7b572b62fd8a6248a543e82" } ] } diff --git a/flake.nix b/flake.nix index 3ec28ed281..4c7d7c380a 100644 --- a/flake.nix +++ b/flake.nix @@ -184,7 +184,7 @@ inherit cargoArtifacts; src = pkgs.lib.cleanSource ./.; # Tests shell out to a few host tools — `git` for the harness, - # `python3` for argv-quoting and post-create fixtures, `ps` + # `python3` for argv-quoting and post-start fixtures, `ps` # (procps) for the pgid invariant test. Without these on PATH # the sandbox surfaces them as `No such file or directory`. nativeBuildInputs = commonArgs.nativeBuildInputs ++ [ diff --git a/plugins/worktrunk/README.md b/plugins/worktrunk/README.md index ff8ad9ebbc..98937bbeba 100644 --- a/plugins/worktrunk/README.md +++ b/plugins/worktrunk/README.md @@ -4,7 +4,7 @@ Git worktree management CLI integration with activity tracking. ## Features -1. **Configuration skill** — Guides LLM-powered commit message setup, project hooks (pre-create, pre-merge), and worktree path customization +1. **Configuration skill** — Guides LLM-powered commit message setup, project hooks (pre-start, pre-merge), and worktree path customization 2. **Activity tracking** — Shows which branches have active Claude sessions via indicators in `wt list` 3. **`/wt-switch-create` command** — Creates a worktrunk worktree and moves the current Claude session into it @@ -20,7 +20,7 @@ These markers appear in `wt list` output, making it easy to see which worktrees The configuration skill guides through configuring an AI tool (Claude Code, Codex, llm, or aichat) and adding `[commit.generation]` to the user config so `wt merge` can auto-generate commit messages. -**Add pre-create hooks to run npm install automatically** +**Add pre-start hooks to run npm install automatically** The skill configures `.config/wt.toml` with project hooks. Pre-start hooks run when creating worktrees, pre-merge hooks validate before merging. diff --git a/skills/worktrunk/SKILL.md b/skills/worktrunk/SKILL.md index 4be55ab7ce..78a37ee7d0 100644 --- a/skills/worktrunk/SKILL.md +++ b/skills/worktrunk/SKILL.md @@ -1,6 +1,6 @@ --- name: worktrunk -description: Guidance for Worktrunk (the `wt` CLI) — git worktree management, hooks, and config. Load when editing .config/wt.toml or ~/.config/worktrunk/config.toml; adding, modifying, or debugging hooks (post-merge, post-create, pre-commit, pre-merge, post-switch, etc.); configuring commit message generation or command aliases; or troubleshooting wt behavior. Also answers general worktrunk/wt questions. +description: Guidance for Worktrunk (the `wt` CLI) — git worktree management, hooks, and config. Load when editing .config/wt.toml or ~/.config/worktrunk/config.toml; adding, modifying, or debugging hooks (post-merge, post-start, pre-commit, pre-merge, post-switch, etc.); configuring commit message generation or command aliases; or troubleshooting wt behavior. Also answers general worktrunk/wt questions. license: MIT OR Apache-2.0 compatibility: Requires the `wt` CLI (https://worktrunk.dev) --- @@ -37,7 +37,7 @@ Worktrunk uses two separate config files with different scopes and behaviors: ### Project Config (`.config/wt.toml`) - **Scope**: Team-wide automation shared by all developers - **Location**: `/.config/wt.toml` (checked into git) -- **Contains**: Hooks for worktree lifecycle (pre-create, pre-merge, etc.) +- **Contains**: Hooks for worktree lifecycle (pre-start, pre-merge, etc.) - **Permission model**: Proactive (create directly, changes are reversible via git) - **See**: `reference/hook.md` for detailed guidance @@ -105,9 +105,9 @@ Common request for workflow automation. Follow discovery process: - For Python: Check pyproject.toml 3. **Design appropriate hooks** (7 hook types available) - - Dependencies (fast, must complete) → `pre-create` + - Dependencies (fast, must complete) → `pre-start` - Tests/linting (must pass) → `pre-commit` or `pre-merge` - - Long builds, dev servers → `post-create` + - Long builds, dev servers → `post-start` - Terminal/IDE updates → `post-switch` - Deployment → `post-merge` - Cleanup tasks → `pre-remove` @@ -121,7 +121,7 @@ Common request for workflow automation. Follow discovery process: 5. **Create `.config/wt.toml`** ```toml # Install dependencies when creating worktrees - pre-create = "npm install" + pre-start = "npm install" # Validate code quality before committing [pre-commit] @@ -148,8 +148,8 @@ When users want to add automation to an existing project: 1. **Read existing config**: `cat .config/wt.toml` 2. **Determine hook type** - When should this run? - - Creating worktree (blocking) → `pre-create` - - Creating worktree (background) → `post-create` + - Creating worktree (blocking) → `pre-start` + - Creating worktree (background) → `post-start` - Every switch → `post-switch` - Before committing → `pre-commit` - Before merging → `pre-merge` @@ -161,10 +161,10 @@ When users want to add automation to an existing project: Single command to named table: ```toml # Before - pre-create = "npm install" + pre-start = "npm install" # After (adding db:migrate) - [pre-create] + [pre-start] install = "npm install" migrate = "npm run db:migrate" ``` @@ -241,7 +241,7 @@ Load **reference files** for detailed configuration, hook specifications, and tr Find specific sections with grep: ```bash grep -A 20 "## Setup" reference/llm-commits.md -grep -A 30 "### pre-create" reference/hook.md +grep -A 30 "### pre-start" reference/hook.md grep -A 20 "## Warning Messages" reference/shell-integration.md ``` @@ -299,7 +299,7 @@ zellij run -- wt switch --create fix-auth-bug -x 'opencode run' -- \ ### Parallel sub-Agents (single Claude Code session) -To spawn multiple sub-Agents that each work in their own worktree from one Claude Code session — no terminal multiplexer, no human in the other pane — pre-create each worktree from the parent and pass the path into the sub-Agent prompt: +To spawn multiple sub-Agents that each work in their own worktree from one Claude Code session — no terminal multiplexer, no human in the other pane — pre-start each worktree from the parent and pass the path into the sub-Agent prompt: ```bash wt switch --create --no-cd --no-hooks @@ -312,6 +312,6 @@ You are working in `/abs/path/to/worktrunk.` on branch ``. All edits must stay in that worktree. ``` -`--no-cd` skips the shell-integration cd script the parent can't consume; `--no-hooks` is appropriate when each sub-Agent will run its own build/test step (e.g. `cargo run -- hook pre-merge --yes`) and you don't need post-create setup repeated per worktree. +`--no-cd` skips the shell-integration cd script the parent can't consume; `--no-hooks` is appropriate when each sub-Agent will run its own build/test step (e.g. `cargo run -- hook pre-merge --yes`) and you don't need post-start setup repeated per worktree. -**Do not** use `Agent { isolation: "worktree" }` for this. Claude Code passes its internal agent ID as `name` to the `WorktreeCreate` hook, so `wt` creates the worktree as `worktrunk.agent-` on a throwaway branch. If the sub-Agent then creates a feature branch on top, you end up with non-canonical paths, orphan branches, and post-create hooks fired against the wrong branch. Pre-creating with `wt switch --create` keeps path, branch, and hook target aligned. +**Do not** use `Agent { isolation: "worktree" }` for this. Claude Code passes its internal agent ID as `name` to the `WorktreeCreate` hook, so `wt` creates the worktree as `worktrunk.agent-` on a throwaway branch. If the sub-Agent then creates a feature branch on top, you end up with non-canonical paths, orphan branches, and post-start hooks fired against the wrong branch. Pre-creating with `wt switch --create` keeps path, branch, and hook target aligned. diff --git a/skills/worktrunk/reference/claude-code.md b/skills/worktrunk/reference/claude-code.md index 1b0f681776..a8fe8cb15b 100644 --- a/skills/worktrunk/reference/claude-code.md +++ b/skills/worktrunk/reference/claude-code.md @@ -61,7 +61,7 @@ Gemini loads the extension natively from the repository, so there is no `wt` wra The plugin includes a skill — documentation the agent can read — covering Worktrunk's configuration system. After installation, the agent can help with: - Setting up LLM-generated commit messages -- Adding project hooks (pre-create, pre-merge, pre-commit) +- Adding project hooks (pre-start, pre-merge, pre-commit) - Configuring worktree path templates - Fixing shell integration issues diff --git a/skills/worktrunk/reference/config.md b/skills/worktrunk/reference/config.md index af52e70965..74eaf8cda2 100644 --- a/skills/worktrunk/reference/config.md +++ b/skills/worktrunk/reference/config.md @@ -51,7 +51,7 @@ command = "CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence ```toml # .config/wt.toml -[pre-create] +[pre-start] deps = "npm ci" [pre-merge] @@ -268,28 +268,28 @@ worktree-path = ".worktrees/{{ branch | sanitize }}" list.full = true merge.squash = false remove.delete-branch = false -pre-create.env = "cp .env.example .env" +pre-start.env = "cp .env.example .env" step.copy-ignored.exclude = [".repo-local-cache/"] aliases.deploy = "make deploy BRANCH={{ branch }}" ``` -Hooks support all three [hook forms](https://worktrunk.dev/hook/#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-create.server = "..."` and a `[projects."github.com/user/repo".post-create]` table the same way: +Hooks support all three [hook forms](https://worktrunk.dev/hook/#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-start.server = "..."` and a `[projects."github.com/user/repo".post-start]` table the same way: ```toml # Single command [projects."github.com/user/repo"] -post-create = "mise trust" +post-start = "mise trust" # Multiple commands, running concurrently -[projects."github.com/user/repo".post-create] +[projects."github.com/user/repo".post-start] mise = "mise trust" server = "npm run dev" # Pipeline: steps run in sequence -[[projects."github.com/user/repo".post-create]] +[[projects."github.com/user/repo".post-start]] install = "npm ci" -[[projects."github.com/user/repo".post-create]] +[[projects."github.com/user/repo".post-start]] build = "npm run build" server = "npm run dev" ``` @@ -434,8 +434,8 @@ To create a starter file with commented-out examples, run `wt config create --pr Project hooks apply to this repository only. See [`wt hook`](https://worktrunk.dev/hook/) for hook types, execution order, and examples. ```toml -pre-create = "npm ci" -post-create = "npm run dev" +pre-start = "npm ci" +post-start = "npm run dev" pre-merge = "npm test" ``` @@ -951,7 +951,7 @@ Hook output lives in per-branch subtrees under `.git/wt/logs/{branch}/`: | Background hooks | `{branch}/{source}/{hook-type}/{name}.log` | | Background removal | `{branch}/internal/remove.log` | -All `post-*` hooks (post-create, post-switch, post-commit, post-merge) run in the background and produce log files. Source is `user` or `project`. Branch and hook names are sanitized for filesystem safety (invalid characters → `-`; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with `wt config state logs clear`. +All `post-*` hooks (post-start, post-switch, post-commit, post-merge) run in the background and produce log files. Source is `user` or `project`. Branch and hook names are sanitized for filesystem safety (invalid characters → `-`; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with `wt config state logs clear`. #### Diagnostic files @@ -983,9 +983,9 @@ Query the command log: $ tail -5 .git/wt/logs/commands.jsonl | jq . ``` -Path to one hook log (e.g. the `post-create` `server` hook for the current branch): +Path to one hook log (e.g. the `post-start` `server` hook for the current branch): ```bash -$ wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-create" and (.name | startswith("server"))) | .path' +$ wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-start" and (.name | startswith("server"))) | .path' ``` Logs for a specific branch: @@ -1201,7 +1201,7 @@ $ wt config state vars set env=production --branch=main Variables are available in [hook templates](https://worktrunk.dev/hook/#template-variables) as `{{ vars. }}`. Use the `default` filter for keys that may not be set: ```toml -[post-create] +[post-start] dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.port | default('3000') }}" ``` @@ -1211,7 +1211,7 @@ JSON object and array values support dot access: $ wt config state vars set config='{"port": 3000, "debug": true}' ``` ```toml -[post-create] +[post-start] dev = "npm start -- --port {{ vars.config.port }}" ``` diff --git a/skills/worktrunk/reference/extending.md b/skills/worktrunk/reference/extending.md index 1fce4afc6c..73ed7618fe 100644 --- a/skills/worktrunk/reference/extending.md +++ b/skills/worktrunk/reference/extending.md @@ -25,7 +25,7 @@ Hooks are shell commands that run at key points in the worktree lifecycle. Ten h | Event | `pre-` (blocking) | `post-` (background) | |-------|-------------------|---------------------| | **switch** | `pre-switch` | `post-switch` | -| **start** | `pre-create` | `post-create` | +| **start** | `pre-start` | `post-start` | | **commit** | `pre-commit` | `post-commit` | | **merge** | `pre-merge` | `post-merge` | | **remove** | `pre-remove` | `post-remove` | @@ -33,10 +33,10 @@ Hooks are shell commands that run at key points in the worktree lifecycle. Ten h `pre-*` hooks block — failure aborts the operation. `post-*` hooks run in the background. ```toml -[pre-create] +[pre-start] deps = "npm ci" -[post-create] +[post-start] server = "npm run dev -- --port {{ branch | hash_port }}" [pre-merge] @@ -183,7 +183,7 @@ tail -f "$(wt config state logs --format=json | jq -r --arg name "{{ name | sani ''' ``` -Run with `wt hook-log --kind=post-create --name=server` to tail the log for the `server` hook on the current branch. `--kind` picks the hook type; the branch is pulled from the current worktree via `{{ branch }}`. `sanitize_hash` rewrites `branch` and `name` to filesystem-safe forms with a hash suffix that keeps distinct originals unique — the same transformation Worktrunk applies on disk — so the alias resolves the right log even when either contains characters like `/`. +Run with `wt hook-log --kind=post-start --name=server` to tail the log for the `server` hook on the current branch. `--kind` picks the hook type; the branch is pulled from the current worktree via `{{ branch }}`. `sanitize_hash` rewrites `branch` and `name` to filesystem-safe forms with a hash suffix that keeps distinct originals unique — the same transformation Worktrunk applies on disk — so the alias resolves the right log even when either contains characters like `/`. ## Custom subcommands diff --git a/skills/worktrunk/reference/faq.md b/skills/worktrunk/reference/faq.md index 7037ddcc88..228856d523 100644 --- a/skills/worktrunk/reference/faq.md +++ b/skills/worktrunk/reference/faq.md @@ -179,11 +179,11 @@ User hooks and user aliases don't require approval (you defined them). Commands ▲ repo needs approval to execute 3 commands: -○ pre-create install: +○ pre-start install: npm ci -○ pre-create build: +○ pre-start build: cargo build --release -○ pre-create env: +○ pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local ❯ Allow and remember? [y/N] diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index b2fa2aec7e..cd73c98db7 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -9,23 +9,21 @@ Hooks are shell commands that run at key points in the worktree lifecycle — au | Event | `pre-` — blocking | `post-` — background | |-------|-------------------|---------------------| | **switch** | `pre-switch` | `post-switch` | -| **create** | `pre-create` | `post-create` | +| **create** | `pre-start` | `post-start` | | **commit** | `pre-commit` | `post-commit` | | **merge** | `pre-merge` | `post-merge` | | **remove** | `pre-remove` | `post-remove` | `pre-*` hooks block — failure aborts the operation. `post-*` hooks run in the background with output logged (use [`wt config state logs`](https://worktrunk.dev/config/#wt-config-state-logs) to find and manage log files). Use `-v` to see expanded command details for background hooks. -The most common creation hook is `post-create` — it runs background tasks (dev servers, file copying, builds) without blocking worktree creation. Prefer `post-create` over `pre-create` unless a later step needs the work completed first. - -`pre-start`/`post-start` are accepted as deprecated aliases for the renamed `pre-create`/`post-create` hooks, in config and on the command line. The [hook rename plan](https://github.com/max-sixty/worktrunk/issues/2838) tracks the deprecation. +The most common creation hook is `post-start` — it runs background tasks (dev servers, file copying, builds) without blocking worktree creation. Prefer `post-start` over `pre-start` unless a later step needs the work completed first. | Hook | Purpose | |------|---------| | `pre-switch` | Runs before branch resolution or worktree creation. `{{ branch }}` is the destination as typed (before resolution) | | `post-switch` | Triggers on all switch results: creating, switching to existing, or staying on current | -| `pre-create` | Runs once when a new worktree is created, blocking `post-create`/`--execute` until complete: dependency install, env file generation | -| `post-create` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches | +| `pre-start` | Runs once when a new worktree is created, blocking `post-start`/`--execute` until complete: dependency install, env file generation | +| `post-start` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches | | `pre-commit` | Formatters, linters, type checking — runs during `wt merge` before the squash commit | | `post-commit` | CI triggers, notifications, background linting | | `pre-merge` | Tests, security scans, build verification — runs after rebase, before merge to target | @@ -42,11 +40,11 @@ Project commands require approval on first run: ``` ▲ repo needs approval to execute 3 commands: -○ pre-create install: +○ pre-start install: npm ci -○ pre-create build: +○ pre-start build: cargo build --release -○ pre-create env: +○ pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local ❯ Allow and remember? [y/N] @@ -71,13 +69,13 @@ Hooks take one of three forms, determined by their TOML shape. A string is a single command: ```toml -pre-create = "npm install" +pre-start = "npm install" ``` A table is multiple commands that run concurrently: ```toml -[post-create] +[post-start] server = "npm run dev" watch = "npm run watch" ``` @@ -85,10 +83,10 @@ watch = "npm run watch" A pipeline is a sequence of `[[hook]]` blocks run in order. Each block is one step; multiple keys within a block run concurrently. A failing step aborts the rest of the pipeline: ```toml -[[post-create]] +[[post-start]] install = "npm ci" -[[post-create]] +[[post-start]] build = "npm run build" server = "npm run dev" ``` @@ -136,7 +134,7 @@ Hooks can use template variables that expand at runtime: | | `{{ remote }}` | Primary remote name | | | `{{ remote_url }}` | Remote URL | | exec | `{{ cwd }}` | Directory where the hook command runs | -| | `{{ hook_type }}` | Hook type being run (e.g. `pre-create`, `pre-merge`) | +| | `{{ hook_type }}` | Hook type being run (e.g. `pre-start`, `pre-merge`) | | | `{{ hook_name }}` | Hook command name (if named) | | | `{{ args }}` | Tokens forwarded from the CLI — see [Running Hooks Manually](#running-hooks-manually) | | user | `{{ vars. }}` | Per-branch variables from [`wt config state vars`](https://worktrunk.dev/config/#wt-config-state-vars) | @@ -150,12 +148,12 @@ Bare variables (`branch`, `worktree_path`, `commit`) refer to the branch the ope | merge | feature being merged | = bare vars | merge target | | remove | branch being removed | = bare vars | where you end up | -Pre and post hooks share the same perspective — `{{ branch | hash_port }}` produces the same port in `post-create` and `post-remove`. `cwd` is the worktree root where the hook command runs. It differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target. +Pre and post hooks share the same perspective — `{{ branch | hash_port }}` produces the same port in `post-start` and `post-remove`. `cwd` is the worktree root where the hook command runs. It differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target. -Some variables are conditional: `upstream` requires remote tracking; `base` only appears in switch/create hooks; `target_worktree_path` requires the target to have a worktree; `pr_number`/`pr_url` are populated for `post-switch`, `pre-create`, and `post-create` hooks when creating via `pr:N` or `mr:N`; `vars` keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior: +Some variables are conditional: `upstream` requires remote tracking; `base` only appears in switch/create hooks; `target_worktree_path` requires the target to have a worktree; `pr_number`/`pr_url` are populated for `post-switch`, `pre-start`, and `post-start` hooks when creating via `pr:N` or `mr:N`; `vars` keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior: ```toml -[pre-create] +[pre-start] # Rebase onto upstream if tracking a remote branch (e.g., wt switch --create feature origin/feature) sync = "{% if upstream %}git fetch && git rebase {{ upstream }}{% endif %}" ``` @@ -165,7 +163,7 @@ Run any hook-firing command with `-v` to see the resolved variables for the actu Variables use dot access and the `default` filter for missing keys. JSON object/array values are parsed automatically, so `{{ vars.config.port }}` works when the value is `{"port": 3000}`: ```toml -[post-create] +[post-start] dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.config.port | default('3000') }}" ``` @@ -214,7 +212,7 @@ worktree-path = "{{ repo_path }}/../{{ repo_path | dirname | basename }}.{{ bran The `hash_port` filter is useful for running dev servers on unique ports per worktree: ```toml -[post-create] +[post-start] dev = "npm run dev -- --host {{ branch }}.localhost --port {{ branch | hash_port }}" ``` @@ -238,7 +236,7 @@ Templates also support functions for dynamic lookups: The `worktree_path_of_branch` function returns the filesystem path of a worktree given a branch name, or an empty string if no worktree exists for that branch. This is useful for referencing files in other worktrees: ```toml -[pre-create] +[pre-start] # Copy config from main worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` @@ -248,8 +246,8 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: ```toml -[pre-create] -setup = "python3 scripts/pre-create-setup.py" +[pre-start] +setup = "python3 scripts/pre-start-setup.py" ``` ```python @@ -264,7 +262,7 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: One specific command worth calling out: [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: ```toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` @@ -280,7 +278,7 @@ $ wt hook pre-merge user: # Run all user hooks $ wt hook pre-merge project: # Run all project hooks $ wt hook pre-merge user:test # Run only user's "test" hook $ wt hook pre-merge --yes # Skip approval prompts (for CI) -$ wt hook pre-create --branch=feature/test # Override a template variable +$ wt hook pre-start --branch=feature/test # Override a template variable $ wt hook pre-merge -- --extra args # Forward tokens into {{ args }} ``` @@ -307,8 +305,8 @@ test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fin ``` ```bash -$ wt hook post-create -◎ Running post-create: project @ ~/acme +$ wt hook post-start +◎ Running post-start: project @ ~/acme ``` ## Passing values @@ -321,9 +319,9 @@ The long form `--var KEY=VALUE` is deprecated but still supported. It force-bind # Recipes -- [Eliminate cold starts](https://worktrunk.dev/tips-patterns/#eliminate-cold-starts): `wt step copy-ignored` in `post-create` shares build caches and dependencies; use a `[[post-create]]` pipeline when a later hook depends on the copy -- [Dev server per worktree](https://worktrunk.dev/tips-patterns/#dev-server-per-worktree): `wt step tether` in `post-create` runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing -- [Database per worktree](https://worktrunk.dev/tips-patterns/#database-per-worktree): a `post-create` pipeline stores container name, port, and connection string as [per-branch vars](https://worktrunk.dev/config/#wt-config-state-vars) that later hooks reference +- [Eliminate cold starts](https://worktrunk.dev/tips-patterns/#eliminate-cold-starts): `wt step copy-ignored` in `post-start` shares build caches and dependencies; use a `[[post-start]]` pipeline when a later hook depends on the copy +- [Dev server per worktree](https://worktrunk.dev/tips-patterns/#dev-server-per-worktree): `wt step tether` in `post-start` runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing +- [Database per worktree](https://worktrunk.dev/tips-patterns/#database-per-worktree): a `post-start` pipeline stores container name, port, and connection string as [per-branch vars](https://worktrunk.dev/config/#wt-config-state-vars) that later hooks reference - [Progressive validation](https://worktrunk.dev/tips-patterns/#progressive-validation): quick lint/typecheck in `pre-commit`, expensive tests and builds in `pre-merge` - [Target-specific hooks](https://worktrunk.dev/tips-patterns/#target-specific-hooks): branch on `{{ target }}` in `post-merge` for per-environment deploys @@ -338,8 +336,8 @@ Commands: show Show configured hooks pre-switch Run pre-switch hooks post-switch Run post-switch hooks - pre-create Run pre-create hooks - post-create Run post-create hooks + pre-start Run pre-start hooks + post-start Run post-start hooks pre-commit Run pre-commit hooks post-commit Run post-commit hooks pre-merge Run pre-merge hooks diff --git a/skills/worktrunk/reference/step.md b/skills/worktrunk/reference/step.md index 9b87035f75..d945e5a830 100644 --- a/skills/worktrunk/reference/step.md +++ b/skills/worktrunk/reference/step.md @@ -357,7 +357,7 @@ Add to the project config: ```toml # .config/wt.toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` @@ -409,7 +409,7 @@ Reflink copies share disk blocks until modified — no data is actually copied. Uses per-file reflink (like `cp -Rc`) — copy time scales with file count. -Use the `post-create` hook so the copy runs in the background. Use `pre-create` instead if subsequent hooks or `--execute` command need the copied files immediately. +Use the `post-start` hook so the copy runs in the background. Use `pre-start` instead if subsequent hooks or `--execute` command need the copied files immediately. ### Background-hook priority (experimental) @@ -428,7 +428,7 @@ The `target/` directory is huge (often 1-10GB). Copying with reflink cuts first `node_modules/` is large but mostly static. If the project has no native dependencies, symlinks are even faster: ```toml -[pre-create] +[pre-start] deps = "ln -sf {{ primary_worktree_path }}/node_modules ." ``` @@ -949,7 +949,7 @@ Run a command; kill its whole process tree when its worktree is removed. Teardow ### Why -A `post-create` hook to start a long-lived process and a `pre-remove` hook to +A `post-start` hook to start a long-lived process and a `pre-remove` hook to stop it is usually enough. But `pre-remove` only runs when worktrunk removes the worktree, so a `git worktree remove`, an `rm -rf`, or a crashed hook skips it. Across enough worktree churn some process is bound to outlive its worktree, @@ -978,7 +978,7 @@ Run a dev server, torn down automatically when the worktree goes away: ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" ``` diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index e89196b6b1..b8106691a6 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -25,8 +25,8 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth 1. Runs [pre-switch hooks](https://worktrunk.dev/hook/#hook-types), blocking until complete 2. Creates worktree at configured path 3. Switches to new directory -4. Runs [pre-create hooks](https://worktrunk.dev/hook/#hook-types), blocking until complete -5. Spawns [post-create](https://worktrunk.dev/hook/#hook-types) and [post-switch hooks](https://worktrunk.dev/hook/#hook-types) in the background +4. Runs [pre-start hooks](https://worktrunk.dev/hook/#hook-types), blocking until complete +5. Spawns [post-start](https://worktrunk.dev/hook/#hook-types) and [post-switch hooks](https://worktrunk.dev/hook/#hook-types) in the background ```bash $ wt switch feature # Existing branch → creates worktree diff --git a/skills/worktrunk/reference/tips-patterns.md b/skills/worktrunk/reference/tips-patterns.md index 4f942a962a..ed9433bb0f 100644 --- a/skills/worktrunk/reference/tips-patterns.md +++ b/skills/worktrunk/reference/tips-patterns.md @@ -47,7 +47,7 @@ Each worktree runs its own dev server on a deterministic port. The `hash_port` f ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" [list] @@ -76,7 +76,7 @@ Ports are deterministic: `fix-auth` always gets port 16460, regardless of which Each worktree can have its own isolated database. A pipeline sets up names and ports as [vars](https://worktrunk.dev/config/#wt-config-state-vars), then later steps and hooks reference them: ```toml -[[post-create]] +[[post-start]] set-vars = """ wt config state vars set \ container='{{ repo }}-{{ branch | sanitize }}-postgres' \ @@ -84,7 +84,7 @@ wt config state vars set \ db_url='postgres://postgres:dev@localhost:{{ ('db-' ~ branch) | hash_port }}/{{ branch | sanitize_db }}' """ -[[post-create]] +[[post-start]] db = """ docker run -d --rm \ --name {{ vars.container }} \ @@ -113,21 +113,21 @@ DATABASE_URL=$(wt config state vars get db_url) npm start Use [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored) to copy gitignored files (caches, dependencies, `.env`) between worktrees: ```toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` -When another hook depends on the copy — for example, copying `node_modules/` before `pnpm install` so the install reuses cached packages — sequence them with a `[[post-create]]` pipeline: +When another hook depends on the copy — for example, copying `node_modules/` before `pnpm install` so the install reuses cached packages — sequence them with a `[[post-start]]` pipeline: ```toml -[[post-create]] +[[post-start]] copy = "wt step copy-ignored" -[[post-create]] +[[post-start]] install = "pnpm install" ``` -Use `pre-create` instead when an `--execute` command needs the copied files immediately. +Use `pre-start` instead when an `--execute` command needs the copied files immediately. All gitignored files are copied by default. To limit what gets copied, create `.worktreeinclude` with patterns — files must be both gitignored and listed. See [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored) for details. @@ -216,7 +216,7 @@ git rebase $(wt config state default-branch) Reference Taskfile/Justfile/Makefile in hooks: ```toml -[pre-create] +[pre-start] "setup" = "task install" [pre-merge] @@ -304,7 +304,7 @@ Each worktree gets its own tmux session with a multi-pane layout. ```toml # .config/wt.toml -[pre-create] +[pre-start] tmux = """ S={{ branch | sanitize }} W={{ worktree_path }} @@ -347,7 +347,7 @@ Each worktree gets its own [cmux](https://cmux.com) workspace. Switching worktre [switch] cd = false -[pre-create] +[pre-start] cmux = "cmux new-workspace --name {{ repo | sanitize }}/{{ branch | sanitize }} --cwd {{ worktree_path }} --focus true" [pre-switch] @@ -398,7 +398,7 @@ Clean URLs like `http://feature-auth.myproject.localhost` without port numbers. ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" proxy = """ curl -sf --max-time 0.5 http://localhost:2019/config/ || caddy start @@ -419,7 +419,7 @@ url = "http://{{ branch | sanitize }}.{{ repo }}.localhost:8080" **How it works:** -1. `wt switch --create feature-auth` runs the `post-create` hook, starting the dev server on a deterministic port (`{{ branch | hash_port }}` → 16460) +1. `wt switch --create feature-auth` runs the `post-start` hook, starting the dev server on a deterministic port (`{{ branch | hash_port }}` → 16460) 2. The hook starts Caddy if needed and registers a route using the same port: `feature-auth.myproject` → `localhost:16460` 3. `*.localhost` resolves to `127.0.0.1` via the OS 4. Visiting `http://feature-auth.myproject.localhost:8080`: Caddy matches the subdomain and proxies to the dev server @@ -429,10 +429,10 @@ url = "http://{{ branch | sanitize }}.{{ repo }}.localhost:8080" Follow background hook output in real-time: ```bash -tail -f "$(wt config state logs get --hook=user:post-create:server)" +tail -f "$(wt config state logs get --hook=user:post-start:server)" ``` -The `--hook` format is `source:hook-type:name` — e.g., `project:post-create:build` for project-defined hooks. Use `wt config state logs get` to list all available logs. +The `--hook` format is `source:hook-type:name` — e.g., `project:post-start:build` for project-defined hooks. Use `wt config state logs get` to list all available logs. Create an alias for frequent use: diff --git a/skills/worktrunk/reference/troubleshooting.md b/skills/worktrunk/reference/troubleshooting.md index 325961c716..62d8f7e7d4 100644 --- a/skills/worktrunk/reference/troubleshooting.md +++ b/skills/worktrunk/reference/troubleshooting.md @@ -67,11 +67,11 @@ Move long-running commands to background: ```toml # Before — blocks for minutes -pre-create = "npm run build" +pre-start = "npm run build" # After — fast setup, build in background -pre-create = "npm install" -post-create = "npm run build" +pre-start = "npm install" +post-start = "npm run build" ``` ## List diff --git a/skills/worktrunk/reference/worktrunk.md b/skills/worktrunk/reference/worktrunk.md index fa5bc0c720..2496b08007 100644 --- a/skills/worktrunk/reference/worktrunk.md +++ b/skills/worktrunk/reference/worktrunk.md @@ -182,7 +182,7 @@ wt switch -x claude -c feature-b -- 'Fix the pagination bug' wt switch -x claude -c feature-c -- 'Write tests for the API' ``` -The `-x` flag runs a command after switching; arguments after `--` are passed to it. Configure [post-create hooks](https://worktrunk.dev/hook/#hook-types) to automate setup (install deps, start dev servers). +The `-x` flag runs a command after switching; arguments after `--` are passed to it. Configure [post-start hooks](https://worktrunk.dev/hook/#hook-types) to automate setup (install deps, start dev servers). ## Next steps diff --git a/src/cli/config.rs b/src/cli/config.rs index faee5b8089..4faa6ad2c5 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -788,7 +788,7 @@ Hook output lives in per-branch subtrees under `.git/wt/logs/{branch}/`: | Background hooks | `{branch}/{source}/{hook-type}/{name}.log` | | Background removal | `{branch}/internal/remove.log` | -All `post-*` hooks (post-create, post-switch, post-commit, post-merge) run in the background and produce log files. Source is `user` or `project`. Branch and hook names are sanitized for filesystem safety (invalid characters → `-`; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with `wt config state logs clear`. +All `post-*` hooks (post-start, post-switch, post-commit, post-merge) run in the background and produce log files. Source is `user` or `project`. Branch and hook names are sanitized for filesystem safety (invalid characters → `-`; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with `wt config state logs clear`. ### Diagnostic files @@ -820,9 +820,9 @@ Query the command log: $ tail -5 .git/wt/logs/commands.jsonl | jq . ``` -Path to one hook log (e.g. the `post-create` `server` hook for the current branch): +Path to one hook log (e.g. the `post-start` `server` hook for the current branch): ```console -$ wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-create" and (.name | startswith("server"))) | .path' +$ wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-start" and (.name | startswith("server"))) | .path' ``` Logs for a specific branch: @@ -1006,7 +1006,7 @@ $ wt config state vars set env=production --branch=main Variables are available in [hook templates](@/hook.md#template-variables) as `{{ vars. }}`. Use the `default` filter for keys that may not be set: ```toml -[post-create] +[post-start] dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.port | default('3000') }}" ``` @@ -1016,7 +1016,7 @@ JSON object and array values support dot access: $ wt config state vars set config='{"port": 3000, "debug": true}' ``` ```toml -[post-create] +[post-start] dev = "npm start -- --port {{ vars.config.port }}" ``` @@ -1235,14 +1235,14 @@ List all log files: $ wt config state logs ``` -Get the absolute path of one post-create hook log for the current branch (use `jq` to filter): +Get the absolute path of one post-start hook log for the current branch (use `jq` to filter): ```console -$ wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-create" and (.name | startswith("server"))) | .path' +$ wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-start" and (.name | startswith("server"))) | .path' ``` Stream that log with `tail -f`: ```console -$ tail -f "$(wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-create" and (.name | startswith("server"))) | .path' | head -1)" +$ tail -f "$(wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-start" and (.name | startswith("server"))) | .path' | head -1)" ``` Logs for a background worktree removal (internal op): diff --git a/src/cli/hook.rs b/src/cli/hook.rs index e41f0e55de..500a4bb482 100644 --- a/src/cli/hook.rs +++ b/src/cli/hook.rs @@ -48,15 +48,15 @@ use super::config::ApprovalsCommand; /// Canonical list of hook type names accepted after `wt hook`. Shared by /// [`parse_hook_type`], `completion::inject_hook_subcommands`, and (via /// `hook_show_possible_values`) the `wt hook show` value parser, so drift -/// is caught by tests rather than at runtime. `pre-start`/`post-start` are -/// deprecated aliases for `pre-create`/`post-create`: not listed here, so +/// is caught by tests rather than at runtime. `pre-create`/`post-create` are +/// silent aliases for `pre-start`/`post-start`: not listed here, so /// help and completion advertise only the canonical names, but accepted by /// [`parse_hook_type`] and by `wt hook show` as hidden aliases. pub const HOOK_TYPE_NAMES: &[&str] = &[ "pre-switch", "post-switch", - "pre-create", - "post-create", + "pre-start", + "post-start", "pre-commit", "post-commit", "pre-merge", @@ -66,16 +66,16 @@ pub const HOOK_TYPE_NAMES: &[&str] = &[ ]; /// `PossibleValue` set for the `wt hook show` type argument: the canonical -/// names from [`HOOK_TYPE_NAMES`], with `pre-start`/`post-start` attached as -/// hidden aliases. `wt hook show post-start` keeps working through the rename -/// without the deprecated names showing up in help or completion. +/// names from [`HOOK_TYPE_NAMES`], with `pre-create`/`post-create` attached as +/// hidden aliases. `wt hook show post-create` keeps working through the +/// transition without the alias names showing up in help or completion. fn hook_show_possible_values() -> Vec { use clap::builder::PossibleValue; HOOK_TYPE_NAMES .iter() .map(|&name| match name { - "pre-create" => PossibleValue::new(name).alias("pre-start"), - "post-create" => PossibleValue::new(name).alias("post-start"), + "pre-start" => PossibleValue::new(name).alias("pre-create"), + "post-start" => PossibleValue::new(name).alias("post-create"), _ => PossibleValue::new(name), }) .collect() @@ -152,9 +152,9 @@ pub struct HookOptions { /// Map a hook type name to its [`HookType`] variant. Emits a did-you-mean /// hint on typos (same `did_you_mean` helper used for unknown subcommands). /// -/// `pre-start`/`post-start` are deprecated aliases for `pre-create`/`post-create`, -/// accepted here so scripted invocations keep working through the rename. They -/// map silently — no warning until the deprecation cycle's next phase. +/// `pre-create`/`post-create` are silent aliases for `pre-start`/`post-start`, +/// accepted here so scripted invocations using the future canonical names +/// keep working. They map silently — no warning. pub fn parse_hook_type(name: &str) -> anyhow::Result { match name { "pre-switch" => Ok(HookType::PreSwitch), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 20b4c82d54..e26a114220 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -549,8 +549,8 @@ If the branch already has a worktree, `wt switch` changes directories to it. Oth 1. Runs [pre-switch hooks](@/hook.md#hook-types), blocking until complete 2. Creates worktree at configured path 3. Switches to new directory -4. Runs [pre-create hooks](@/hook.md#hook-types), blocking until complete -5. Spawns [post-create](@/hook.md#hook-types) and [post-switch hooks](@/hook.md#hook-types) in the background +4. Runs [pre-start hooks](@/hook.md#hook-types), blocking until complete +5. Spawns [post-start](@/hook.md#hook-types) and [post-switch hooks](@/hook.md#hook-types) in the background ```console $ wt switch feature # Existing branch → creates worktree @@ -1206,23 +1206,21 @@ $ wt step push | Event | `pre-` — blocking | `post-` — background | |-------|-------------------|---------------------| | **switch** | `pre-switch` | `post-switch` | -| **create** | `pre-create` | `post-create` | +| **create** | `pre-start` | `post-start` | | **commit** | `pre-commit` | `post-commit` | | **merge** | `pre-merge` | `post-merge` | | **remove** | `pre-remove` | `post-remove` | `pre-*` hooks block — failure aborts the operation. `post-*` hooks run in the background with output logged (use [`wt config state logs`](@/config.md#wt-config-state-logs) to find and manage log files). Use `-v` to see expanded command details for background hooks. -The most common creation hook is `post-create` — it runs background tasks (dev servers, file copying, builds) without blocking worktree creation. Prefer `post-create` over `pre-create` unless a later step needs the work completed first. - -`pre-start`/`post-start` are accepted as deprecated aliases for the renamed `pre-create`/`post-create` hooks, in config and on the command line. The [hook rename plan](https://github.com/max-sixty/worktrunk/issues/2838) tracks the deprecation. +The most common creation hook is `post-start` — it runs background tasks (dev servers, file copying, builds) without blocking worktree creation. Prefer `post-start` over `pre-start` unless a later step needs the work completed first. | Hook | Purpose | |------|---------| | `pre-switch` | Runs before branch resolution or worktree creation. `{{ branch }}` is the destination as typed (before resolution) | | `post-switch` | Triggers on all switch results: creating, switching to existing, or staying on current | -| `pre-create` | Runs once when a new worktree is created, blocking `post-create`/`--execute` until complete: dependency install, env file generation | -| `post-create` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches | +| `pre-start` | Runs once when a new worktree is created, blocking `post-start`/`--execute` until complete: dependency install, env file generation | +| `post-start` | Runs once when a new worktree is created, in the background: dev servers, long builds, file watchers, copying caches | | `pre-commit` | Formatters, linters, type checking — runs during `wt merge` before the squash commit | | `post-commit` | CI triggers, notifications, background linting | | `pre-merge` | Tests, security scans, build verification — runs after rebase, before merge to target | @@ -1239,11 +1237,11 @@ Project commands require approval on first run: ``` ▲ repo needs approval to execute 3 commands: -○ pre-create install: +○ pre-start install: npm ci -○ pre-create build: +○ pre-start build: cargo build --release -○ pre-create env: +○ pre-start env: echo 'PORT={{ branch | hash_port }}' > .env.local ❯ Allow and remember? [y/N] @@ -1268,13 +1266,13 @@ Hooks take one of three forms, determined by their TOML shape. A string is a single command: ```toml -pre-create = "npm install" +pre-start = "npm install" ``` A table is multiple commands that run concurrently: ```toml -[post-create] +[post-start] server = "npm run dev" watch = "npm run watch" ``` @@ -1282,10 +1280,10 @@ watch = "npm run watch" A pipeline is a sequence of `[[hook]]` blocks run in order. Each block is one step; multiple keys within a block run concurrently. A failing step aborts the rest of the pipeline: ```toml -[[post-create]] +[[post-start]] install = "npm ci" -[[post-create]] +[[post-start]] build = "npm run build" server = "npm run dev" ``` @@ -1333,7 +1331,7 @@ Hooks can use template variables that expand at runtime: | | `{{ remote }}` | Primary remote name | | | `{{ remote_url }}` | Remote URL | | exec | `{{ cwd }}` | Directory where the hook command runs | -| | `{{ hook_type }}` | Hook type being run (e.g. `pre-create`, `pre-merge`) | +| | `{{ hook_type }}` | Hook type being run (e.g. `pre-start`, `pre-merge`) | | | `{{ hook_name }}` | Hook command name (if named) | | | `{{ args }}` | Tokens forwarded from the CLI — see [Running Hooks Manually](#running-hooks-manually) | | user | `{{ vars. }}` | Per-branch variables from [`wt config state vars`](@/config.md#wt-config-state-vars) | @@ -1347,12 +1345,12 @@ Bare variables (`branch`, `worktree_path`, `commit`) refer to the branch the ope | merge | feature being merged | = bare vars | merge target | | remove | branch being removed | = bare vars | where you end up | -Pre and post hooks share the same perspective — `{{ branch | hash_port }}` produces the same port in `post-create` and `post-remove`. `cwd` is the worktree root where the hook command runs. It differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target. +Pre and post hooks share the same perspective — `{{ branch | hash_port }}` produces the same port in `post-start` and `post-remove`. `cwd` is the worktree root where the hook command runs. It differs from `worktree_path` in three cases: pre-switch, where the hook runs in the source but `worktree_path` is the destination; post-remove, where the active worktree is gone so the hook runs in primary; and post-merge with removal, same — the active worktree is gone, so the hook runs in target. -Some variables are conditional: `upstream` requires remote tracking; `base` only appears in switch/create hooks; `target_worktree_path` requires the target to have a worktree; `pr_number`/`pr_url` are populated for `post-switch`, `pre-create`, and `post-create` hooks when creating via `pr:N` or `mr:N`; `vars` keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior: +Some variables are conditional: `upstream` requires remote tracking; `base` only appears in switch/create hooks; `target_worktree_path` requires the target to have a worktree; `pr_number`/`pr_url` are populated for `post-switch`, `pre-start`, and `post-start` hooks when creating via `pr:N` or `mr:N`; `vars` keys may not exist. Undefined variables error — use conditionals or defaults for optional behavior: ```toml -[pre-create] +[pre-start] # Rebase onto upstream if tracking a remote branch (e.g., wt switch --create feature origin/feature) sync = "{% if upstream %}git fetch && git rebase {{ upstream }}{% endif %}" ``` @@ -1362,7 +1360,7 @@ Run any hook-firing command with `-v` to see the resolved variables for the actu Variables use dot access and the `default` filter for missing keys. JSON object/array values are parsed automatically, so `{{ vars.config.port }}` works when the value is `{"port": 3000}`: ```toml -[post-create] +[post-start] dev = "ENV={{ vars.env | default('development') }} npm start -- --port {{ vars.config.port | default('3000') }}" ``` @@ -1411,7 +1409,7 @@ worktree-path = "{{ repo_path }}/../{{ repo_path | dirname | basename }}.{{ bran The `hash_port` filter is useful for running dev servers on unique ports per worktree: ```toml -[post-create] +[post-start] dev = "npm run dev -- --host {{ branch }}.localhost --port {{ branch | hash_port }}" ``` @@ -1435,7 +1433,7 @@ Templates also support functions for dynamic lookups: The `worktree_path_of_branch` function returns the filesystem path of a worktree given a branch name, or an empty string if no worktree exists for that branch. This is useful for referencing files in other worktrees: ```toml -[pre-create] +[pre-start] # Copy config from main worktree setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path }}" ``` @@ -1445,8 +1443,8 @@ setup = "cp {{ worktree_path_of_branch('main') }}/config.local {{ worktree_path Hooks receive all template variables as JSON on stdin, enabling complex logic that templates can't express: ```toml -[pre-create] -setup = "python3 scripts/pre-create-setup.py" +[pre-start] +setup = "python3 scripts/pre-start-setup.py" ``` ```python @@ -1461,7 +1459,7 @@ if ctx['branch'].startswith('feature/') and 'backend' in ctx['repo']: One specific command worth calling out: [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored). Git worktrees share the repository but not untracked files, and this copies gitignored files between worktrees: ```toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` @@ -1477,7 +1475,7 @@ $ wt hook pre-merge user: # Run all user hooks $ wt hook pre-merge project: # Run all project hooks $ wt hook pre-merge user:test # Run only user's "test" hook $ wt hook pre-merge --yes # Skip approval prompts (for CI) -$ wt hook pre-create --branch=feature/test # Override a template variable +$ wt hook pre-start --branch=feature/test # Override a template variable $ wt hook pre-merge -- --extra args # Forward tokens into {{ args }} ``` @@ -1505,8 +1503,8 @@ test result: ok. 18 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; fin ``` ```console -$ wt hook post-create -◎ Running post-create: project @ ~/acme +$ wt hook post-start +◎ Running post-start: project @ ~/acme ``` ## Passing values @@ -1519,16 +1517,16 @@ The long form `--var KEY=VALUE` is deprecated but still supported. It force-bind # Recipes -- [Eliminate cold starts](@/tips-patterns.md#eliminate-cold-starts): `wt step copy-ignored` in `post-create` shares build caches and dependencies; use a `[[post-create]]` pipeline when a later hook depends on the copy -- [Dev server per worktree](@/tips-patterns.md#dev-server-per-worktree): `wt step tether` in `post-create` runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing -- [Database per worktree](@/tips-patterns.md#database-per-worktree): a `post-create` pipeline stores container name, port, and connection string as [per-branch vars](@/config.md#wt-config-state-vars) that later hooks reference +- [Eliminate cold starts](@/tips-patterns.md#eliminate-cold-starts): `wt step copy-ignored` in `post-start` shares build caches and dependencies; use a `[[post-start]]` pipeline when a later hook depends on the copy +- [Dev server per worktree](@/tips-patterns.md#dev-server-per-worktree): `wt step tether` in `post-start` runs the dev server and kills its whole process group when the worktree is removed, with optional subdomain routing +- [Database per worktree](@/tips-patterns.md#database-per-worktree): a `post-start` pipeline stores container name, port, and connection string as [per-branch vars](@/config.md#wt-config-state-vars) that later hooks reference - [Progressive validation](@/tips-patterns.md#progressive-validation): quick lint/typecheck in `pre-commit`, expensive tests and builds in `pre-merge` - [Target-specific hooks](@/tips-patterns.md#target-specific-hooks): branch on `{{ target }}` in `post-merge` for per-environment deploys ## See also - [`wt merge`](@/merge.md) — Runs hooks automatically during merge -- [`wt switch`](@/switch.md) — Runs pre-create/post-create hooks on `--create` +- [`wt switch`](@/switch.md) — Runs pre-start/post-start hooks on `--create` - [`wt config approvals`](@/config.md#wt-config-approvals) — Manage approvals - [`wt config state logs`](@/config.md#wt-config-state-logs) — Access background hook logs "# @@ -1590,7 +1588,7 @@ command = "CLAUDECODE= MAX_THINKING_TOKENS=0 claude -p --no-session-persistence ```toml # .config/wt.toml -[pre-create] +[pre-start] deps = "npm ci" [pre-merge] @@ -1807,28 +1805,28 @@ worktree-path = ".worktrees/{{ branch | sanitize }}" list.full = true merge.squash = false remove.delete-branch = false -pre-create.env = "cp .env.example .env" +pre-start.env = "cp .env.example .env" step.copy-ignored.exclude = [".repo-local-cache/"] aliases.deploy = "make deploy BRANCH={{ branch }}" ``` -Hooks support all three [hook forms](@/hook.md#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-create.server = "..."` and a `[projects."github.com/user/repo".post-create]` table the same way: +Hooks support all three [hook forms](@/hook.md#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-start.server = "..."` and a `[projects."github.com/user/repo".post-start]` table the same way: ```toml # Single command [projects."github.com/user/repo"] -post-create = "mise trust" +post-start = "mise trust" # Multiple commands, running concurrently -[projects."github.com/user/repo".post-create] +[projects."github.com/user/repo".post-start] mise = "mise trust" server = "npm run dev" # Pipeline: steps run in sequence -[[projects."github.com/user/repo".post-create]] +[[projects."github.com/user/repo".post-start]] install = "npm ci" -[[projects."github.com/user/repo".post-create]] +[[projects."github.com/user/repo".post-start]] build = "npm run build" server = "npm run dev" ``` @@ -1973,8 +1971,8 @@ To create a starter file with commented-out examples, run `wt config create --pr Project hooks apply to this repository only. See [`wt hook`](@/hook.md) for hook types, execution order, and examples. ```toml -pre-create = "npm ci" -post-create = "npm run dev" +pre-start = "npm ci" +post-start = "npm run dev" pre-merge = "npm test" ``` diff --git a/src/cli/step.rs b/src/cli/step.rs index cf29831851..ff64926acc 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -277,7 +277,7 @@ Add to the project config: ```toml # .config/wt.toml -[post-create] +[post-start] copy = "wt step copy-ignored" ``` @@ -329,7 +329,7 @@ Reflink copies share disk blocks until modified — no data is actually copied. Uses per-file reflink (like `cp -Rc`) — copy time scales with file count. -Use the `post-create` hook so the copy runs in the background. Use `pre-create` instead if subsequent hooks or `--execute` command need the copied files immediately. +Use the `post-start` hook so the copy runs in the background. Use `pre-start` instead if subsequent hooks or `--execute` command need the copied files immediately. ## Background-hook priority (experimental) @@ -348,7 +348,7 @@ The `target/` directory is huge (often 1-10GB). Copying with reflink cuts first `node_modules/` is large but mostly static. If the project has no native dependencies, symlinks are even faster: ```toml -[pre-create] +[pre-start] deps = "ln -sf {{ primary_worktree_path }}/node_modules ." ``` @@ -689,7 +689,7 @@ Note: This command is experimental and may change in future versions. /// Teardown is automatic and needs no `pre-remove` hook; the group gets `SIGTERM` then `SIGKILL`. #[command(after_long_help = r#"## Why -A `post-create` hook to start a long-lived process and a `pre-remove` hook to +A `post-start` hook to start a long-lived process and a `pre-remove` hook to stop it is usually enough. But `pre-remove` only runs when worktrunk removes the worktree, so a `git worktree remove`, an `rm -rf`, or a crashed hook skips it. Across enough worktree churn some process is bound to outlive its worktree, @@ -718,7 +718,7 @@ Run a dev server, torn down automatically when the worktree goes away: ```toml # .config/wt.toml -[post-create] +[post-start] server = "wt step tether -- npm run dev -- --port {{ branch | hash_port }}" ``` diff --git a/src/command_log.rs b/src/command_log.rs index 04ea714ca7..8a25e4406b 100644 --- a/src/command_log.rs +++ b/src/command_log.rs @@ -193,7 +193,7 @@ mod tests { let entry = serde_json::json!({ "ts": "2026-02-17T10:00:00Z", "wt": "wt switch", - "label": "post-create user:server", + "label": "post-start user:server", "cmd": "npm run dev", "exit": null, "dur_ms": null, diff --git a/src/commands/config/state.rs b/src/commands/config/state.rs index 51f8167042..a6e2208e1d 100644 --- a/src/commands/config/state.rs +++ b/src/commands/config/state.rs @@ -371,7 +371,7 @@ struct HookStructure { branch: String, /// `"user"`, `"project"`, or `"internal"`. source: String, - /// Hook type (`post-create`, `post-switch`, …) for user/project hooks; + /// Hook type (`post-start`, `post-switch`, …) for user/project hooks; /// `None` for internal operations. hook_type: Option, /// Sanitized hook name for user/project hooks; internal op name diff --git a/src/commands/hook_announcement.rs b/src/commands/hook_announcement.rs index 1f8917c0e1..e9b60fb1d6 100644 --- a/src/commands/hook_announcement.rs +++ b/src/commands/hook_announcement.rs @@ -12,7 +12,7 @@ //! ``` //! //! `;` is overloaded across the two outermost tiers. The reader disambiguates -//! by lookahead — `;` followed by a `:` (e.g. `post-create:`) is a +//! by lookahead — `;` followed by a `:` (e.g. `post-start:`) is a //! cross-type boundary; otherwise it's a cross-source boundary within the //! current hook-type clause. In practice the two often coexist on one line. //! @@ -289,7 +289,7 @@ mod tests { #[test] fn test_format_pipeline_summary_concurrent_then_concurrent() { // The canonical pipeline: two concurrent groups in sequence. - // post-create = [ + // post-start = [ // { install = "npm install", setup = "setup-db" }, // { build = "npm run build", lint = "npm run lint" }, // ] diff --git a/src/commands/hook_commands.rs b/src/commands/hook_commands.rs index df87f80f14..1ed70bdd52 100644 --- a/src/commands/hook_commands.rs +++ b/src/commands/hook_commands.rs @@ -209,10 +209,10 @@ pub struct HookCliArgs<'a> { /// regardless of whether any template references the key. /// /// The `foreground` parameter controls execution mode for hooks that normally run -/// in background (post-create, post-switch): +/// in background (post-start, post-switch): /// - `None` = use default behavior for this hook type /// - `Some(true)` = run in foreground (for debugging) -/// - `Some(false)` = run in background (default for post-create/post-switch) +/// - `Some(false)` = run in background (default for post-start/post-switch) pub fn run_hook( hook_type: HookType, yes: bool, diff --git a/src/commands/hook_plan.rs b/src/commands/hook_plan.rs index 67080ec084..4abe48ce83 100644 --- a/src/commands/hook_plan.rs +++ b/src/commands/hook_plan.rs @@ -12,7 +12,7 @@ //! authorization (project templates ∈ [`Approvals`]) and rendering //! (template → shell string, needs live git) are three separate concerns. //! Operation-driven hooks (`pre-merge`, `post-merge`, `pre-remove`, -//! `post-remove`, `post-switch`, `pre-create`, `post-create`) are gated *before* +//! `post-remove`, `post-switch`, `pre-start`, `post-start`) are gated *before* //! a state mutation (auto-rebase rewrites the feature `.config/wt.toml`; a //! merge moves the target ref; a removal scrubs the worktree; `git worktree //! add` materializes a `--create` worktree) and executed *after* it. When @@ -312,7 +312,7 @@ fn render_planned( /// Foreground execution of a covered hook from the approved plan. /// -/// Replaces `execute_hook` for `pre-merge` / `pre-remove` / `pre-create`. The +/// Replaces `execute_hook` for `pre-merge` / `pre-remove` / `pre-start`. The /// signature carries no config — only the plan, the render context, and the /// failure strategy. /// @@ -343,7 +343,7 @@ pub fn execute_planned_hook( } /// Register a covered background hook (`post-merge` / `post-remove` / -/// `post-switch` / `post-create`) from the approved plan onto `announcer`. +/// `post-switch` / `post-start`) from the approved plan onto `announcer`. /// /// The plan-backed counterpart of `HookAnnouncer::register`: steps are /// rendered from the frozen selection and added via the announcer's existing diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index bdc114799c..0702de6873 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -11,7 +11,7 @@ //! execution. //! //! **Plan-backed (the TOCTOU-covered set):** `pre-merge`, `post-merge`, -//! `pre-remove`, `post-remove`, `post-switch`, `pre-create`, `post-create`. A +//! `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`. A //! merge, rebase, removal, or `git worktree add` runs between the gate and //! these hooks, so a second config read could select a command the user never //! approved. Each command gate selects the commands once and freezes them into @@ -24,7 +24,7 @@ //! |---|---|---| //! | `pre-merge`, `pre-remove`, `post-remove` | the feature/removed worktree | `merge::approve_merge_plan`, `main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks` | //! | `post-merge`, `post-switch` (after a removal) | the merge/removal destination | the same gates | -//! | `pre-create`, `post-create`, `post-switch` (on switch) | the new/destination worktree (the `--create` base ref's committed config, read via `git show` by `switch_hook_project_config`) | `worktree::switch::approve_switch_hooks` | +//! | `pre-start`, `post-start`, `post-switch` (on switch) | the new/destination worktree (the `--create` base ref's committed config, read via `git show` by `switch_hook_project_config`) | `worktree::switch::approve_switch_hooks` | //! //! **Invocation-resolved (no gate→exec mutation):** `pre-commit`, //! `post-commit`, `pre-switch`, `wt hook `, aliases. They resolve config diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 03533eac39..1ddd48c963 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -75,8 +75,8 @@ use worktrunk::styling::{eprintln, format_with_gutter}; /// Format command execution label with optional command name. /// /// Examples: -/// - `format_command_label("post-create", Some("install"))` → `"Running post-create install"` (with bold) -/// - `format_command_label("post-create", None)` → `"Running post-create"` +/// - `format_command_label("post-start", Some("install"))` → `"Running post-start install"` (with bold) +/// - `format_command_label("post-start", None)` → `"Running post-start"` pub(crate) fn format_command_label(command_type: &str, name: Option<&str>) -> String { match name { Some(name) => cformat!("Running {command_type} {name}"), @@ -203,9 +203,9 @@ mod tests { #[test] fn test_format_command_label() { use insta::assert_snapshot; - assert_snapshot!(format_command_label("post-create", Some("install")), @"Running post-create install"); + assert_snapshot!(format_command_label("post-start", Some("install")), @"Running post-start install"); assert_snapshot!(format_command_label("pre-merge", None), @"Running pre-merge"); - assert_snapshot!(format_command_label("post-create", Some("build")), @"Running post-create build"); + assert_snapshot!(format_command_label("post-start", Some("build")), @"Running post-start build"); assert_snapshot!(format_command_label("pre-commit", None), @"Running pre-commit"); } } diff --git a/src/commands/process.rs b/src/commands/process.rs index 3f4b194583..33a78b498d 100644 --- a/src/commands/process.rs +++ b/src/commands/process.rs @@ -40,7 +40,7 @@ pub enum InternalOp { /// /// Hook commands produce logs at: `{branch}/{source}/{hook-type}/{name}.log` /// (`source` is `user` or `project`) -/// - Example: `feature/user/post-create/server.log` +/// - Example: `feature/user/post-start/server.log` /// /// Per-branch internal operations produce logs at: `{branch}/internal/{op}.log` /// - Example: `feature/internal/remove.log` @@ -986,20 +986,20 @@ mod tests { let log = HookLog::hook(HookSource::User, HookType::PostCreate, "server"); assert_snapshot!( log.path(log_dir, "main").to_slash_lossy(), - @"/repo/.git/wt/logs/main/user/post-create/server.log" + @"/repo/.git/wt/logs/main/user/post-start/server.log" ); // Slash in branch name gets sanitized (feature/auth → feature-auth-{hash}) assert_snapshot!( log.path(log_dir, "feature/auth").to_slash_lossy(), - @"/repo/.git/wt/logs/feature-auth-j34/user/post-create/server.log" + @"/repo/.git/wt/logs/feature-auth-j34/user/post-start/server.log" ); // Project source let log = HookLog::hook(HookSource::Project, HookType::PreCreate, "build"); assert_snapshot!( log.path(log_dir, "main").to_slash_lossy(), - @"/repo/.git/wt/logs/main/project/pre-create/build.log" + @"/repo/.git/wt/logs/main/project/pre-start/build.log" ); // Per-branch internal operation path: {log_dir}/{sanitized-branch}/internal/{op}.log diff --git a/src/commands/project_config.rs b/src/commands/project_config.rs index 358b87d8a4..a559556b88 100644 --- a/src/commands/project_config.rs +++ b/src/commands/project_config.rs @@ -88,7 +88,7 @@ mod tests { fn make_project_config_with_hooks() -> ProjectConfig { // Use TOML deserialization to create ProjectConfig let toml_content = r#" -pre-create = "npm install" +pre-start = "npm install" pre-merge = "cargo test" "#; toml::from_str(toml_content).unwrap() @@ -151,7 +151,7 @@ pre-merge = "cargo test" #[test] fn test_collect_commands_for_hooks_named_commands() { let toml_content = r#" -[pre-create] +[pre-start] install = "npm install" build = "npm run build" "#; diff --git a/src/commands/step/tether.rs b/src/commands/step/tether.rs index 53c64445f6..a9dfe994a2 100644 --- a/src/commands/step/tether.rs +++ b/src/commands/step/tether.rs @@ -39,7 +39,7 @@ const POLL_INTERVAL: Duration = Duration::from_millis(250); /// Run `command` supervised; tear its whole process tree down when the command /// exits or its worktree is removed. pub(crate) fn step_tether(command: &[String]) -> Result<()> { - // post-create hooks run with cwd at the worktree root; capture it now so + // post-start hooks run with cwd at the worktree root; capture it now so // the reaper can notice the worktree being removed. `None` (cwd // unavailable) degrades to "tear down only on the command's own exit", // never a false teardown. diff --git a/src/commands/worktree/hooks.rs b/src/commands/worktree/hooks.rs index 3aaec35948..9b9ac0954d 100644 --- a/src/commands/worktree/hooks.rs +++ b/src/commands/worktree/hooks.rs @@ -1,6 +1,6 @@ //! Hook execution for worktree operations. //! -//! CommandContext implementations for pre-create hooks, and PostRemoveContext +//! CommandContext implementations for pre-start hooks, and PostRemoveContext //! for building template variables for post-remove hooks. use std::path::Path; @@ -14,10 +14,10 @@ use crate::commands::command_executor::FailureStrategy; use crate::commands::hook_plan::{ApprovedHookPlan, execute_planned_hook}; impl<'a> CommandContext<'a> { - /// Execute pre-create commands sequentially (blocking) from the frozen plan. + /// Execute pre-start commands sequentially (blocking) from the frozen plan. /// /// Runs user hooks first, then project hooks. `anchor` is the new - /// worktree's path — the gate selected `pre-create` under it from the + /// worktree's path — the gate selected `pre-start` under it from the /// base-ref config; the executor never re-reads the (now on-disk) config. /// Shows path in hook announcements when shell integration isn't active /// (the user's shell won't cd to the new worktree). diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 0586f3f065..934b99cf1d 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -1121,11 +1121,11 @@ pub fn execute_switch( // Compute base worktree path for hooks and result. // // `git worktree add` already mutated the worktree list, but `repo` - // cached it pre-create (populated by `plan_switch`). Reading + // cached it pre-start (populated by `plan_switch`). Reading // `worktree_for_branch` through `repo` here would observe the stale - // pre-create inventory — see the caching contract in + // pre-start inventory — see the caching contract in // `git/repository/mod.rs`. Probe through a fresh `Repository::at` - // so the lookup reflects the post-create state. + // so the lookup reflects the post-start state. let base_worktree_path = base_branch .as_ref() .and_then(|b| { @@ -1136,9 +1136,9 @@ pub fn execute_switch( }) .map(|p| worktrunk::path::to_posix_path(&p.to_string_lossy())); - // PR/MR identity travels into both the pre-create hook below and the + // PR/MR identity travels into both the pre-start hook below and the // SwitchResult — TemplateVars::for_post_switch then forwards it to - // background post-switch / post-create hooks. + // background post-switch / post-start hooks. let (pr_number, pr_url) = match &method { CreationMethod::ForkRef { number, ref_url, .. @@ -1146,7 +1146,7 @@ pub fn execute_switch( CreationMethod::Regular { .. } => (None, None), }; - // Execute pre-create commands. The hook resolves its `.config/wt.toml` + // Execute pre-start commands. The hook resolves its `.config/wt.toml` // from the new worktree (created just above) — see // `hook_repo_for_worktree`. if run_hooks { @@ -1360,7 +1360,7 @@ pub(crate) fn run_pre_switch_hooks( /// Hook types that apply after a switch operation. /// -/// Creates trigger pre-create + post-create + post-switch hooks; +/// Creates trigger pre-start + post-start + post-switch hooks; /// existing worktrees trigger only post-switch. fn switch_post_hook_types(is_create: bool) -> &'static [HookType] { if is_create { @@ -1404,7 +1404,7 @@ fn base_ref_for_create( // Multiple remotes: replicate `git worktree add `'s DWIM — when // `checkout.defaultRemote` names one of these remotes, that's the ref the // new worktree will check out, so the hook preview must match. Without - // this, a `pre-create` on `/` could run unapproved + // this, a `pre-start` on `/` could run unapproved // because the preview defaulted to `HEAD`. if remotes.len() > 1 && let Ok(Some(default)) = repo.config_value("checkout.defaultRemote") @@ -1415,8 +1415,8 @@ fn base_ref_for_create( "HEAD".to_string() } -/// The `.config/wt.toml` that `wt switch`'s post-switch hooks (`pre-create` / -/// `post-create` / `post-switch`) will resolve against, viewed from *before* +/// The `.config/wt.toml` that `wt switch`'s post-switch hooks (`pre-start` / +/// `post-start` / `post-switch`) will resolve against, viewed from *before* /// the worktree is created — so the approval prompt and the pre-flight /// template validation see the exact config `execute_switch` / /// [`spawn_switch_background_hooks`] (via [`hook_repo_for_worktree`]) read at @@ -1480,7 +1480,7 @@ fn hook_repo_for_worktree(worktree_path: &Path) -> anyhow::Result { /// /// Returns `(hooks_approved, plan)`. `hooks_approved` is `false` and the plan /// empty when `!verify` or the user declined; the covered switch hooks -/// (`pre-create` / `post-create` / `post-switch`) execute only from `plan`. +/// (`pre-start` / `post-start` / `post-switch`) execute only from `plan`. pub(crate) fn approve_switch_hooks( repo: &Repository, config: &UserConfig, @@ -1520,7 +1520,7 @@ pub(crate) fn approve_switch_hooks( } } -/// Spawn post-switch (and post-create for creates) background hooks. +/// Spawn post-switch (and post-start for creates) background hooks. pub(crate) fn spawn_switch_background_hooks( config: &UserConfig, result: &SwitchResult, @@ -1761,7 +1761,7 @@ pub fn run_switch( // Spawn background hooks after success message // - post-switch: runs on ALL switches (shows "@ path" when shell won't be there) - // - post-create: runs only when creating a NEW worktree + // - post-start: runs only when creating a NEW worktree // Batch hooks into a single message when both types are present if hooks_approved { spawn_switch_background_hooks( @@ -1775,7 +1775,7 @@ pub fn run_switch( )?; } - // Execute user command after post-create hooks have been spawned + // Execute user command after post-start hooks have been spawned // Note: execute_args requires execute via clap's `requires` attribute if let Some(cmd) = execute { // Build template context for expansion (includes base vars when creating) @@ -1866,7 +1866,7 @@ pub fn run_switch( /// Validates: /// - `--execute` command template (if present) /// - `--execute` trailing arg templates (if present) -/// - Hook templates (pre-create, post-create, post-switch) from user and project config +/// - Hook templates (pre-start, post-start, post-switch) from user and project config pub(crate) fn validate_switch_templates( repo: &Repository, config: &UserConfig, diff --git a/src/completion.rs b/src/completion.rs index 589d4bfd5d..84b6838f2f 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -267,8 +267,8 @@ impl ValueCompleter for HookCommandCompleter { let hook_type = CONTEXT.with(|ctx| { ctx.borrow().as_ref().and_then(|ctx| { for hook in &[ - "pre-create", - "post-create", + "pre-start", + "post-start", "pre-commit", "post-commit", "pre-merge", @@ -279,12 +279,12 @@ impl ValueCompleter for HookCommandCompleter { return Some(*hook); } } - // Deprecated aliases still complete, mapped to the canonical name. - if ctx.contains("pre-start") { - return Some("pre-create"); + // Silent aliases still complete, mapped to the canonical name. + if ctx.contains("pre-create") { + return Some("pre-start"); } - if ctx.contains("post-start") { - return Some("post-create"); + if ctx.contains("post-create") { + return Some("post-start"); } None }) diff --git a/src/config/commands.rs b/src/config/commands.rs index a9b7488c80..2fe56d8f8e 100644 --- a/src/config/commands.rs +++ b/src/config/commands.rs @@ -35,7 +35,7 @@ //! Naming is user-visible: it changes the step from anonymous `Single` to //! named `Single`, which affects log file paths //! (`.../set-vars.log` vs. a positional slot) and hook-selection filtering -//! (`wt hook post-create `). +//! (`wt hook post-start `). use std::collections::BTreeMap; @@ -94,9 +94,9 @@ pub enum HookStep { /// Configuration for commands — canonical representation. /// /// Internally stores a pipeline of `HookStep`s. Deserializes from three TOML forms: -/// - Single string: `post-create = "npm install"` -/// - Named table: `[post-create]` with `name = "command"` entries → one Concurrent step -/// - Pipeline: `post-create = ["cmd", { a = "cmd1", b = "cmd2" }]` → serial steps +/// - Single string: `post-start = "npm install"` +/// - Named table: `[post-start]` with `name = "command"` entries → one Concurrent step +/// - Pipeline: `post-start = ["cmd", { a = "cmd1", b = "cmd2" }]` → serial steps /// /// **Order preservation:** Named commands preserve TOML insertion order (IndexMap). #[derive(Debug, Clone, PartialEq)] diff --git a/src/config/deprecation.rs b/src/config/deprecation.rs index 9bcb6e20d9..4ad33c7bc9 100644 --- a/src/config/deprecation.rs +++ b/src/config/deprecation.rs @@ -82,8 +82,7 @@ pub struct DeprecatedSection { } /// Top-level keys that are deprecated and handled by the deprecation system — -/// renamed sections (`[commit-generation]` → `[commit.generation]`) and renamed -/// flattened hook keys (`pre-start` → `pre-create`). +/// renamed sections (`[commit-generation]` → `[commit.generation]`). /// /// When a deprecated key appears in the config type where its canonical replacement /// is valid, `warn_unknown_fields` skips it (the deprecation system provides better @@ -105,16 +104,6 @@ pub const DEPRECATED_SECTION_KEYS: &[DeprecatedSection] = &[ canonical_top_key: "forge", canonical_display: "[forge]", }, - DeprecatedSection { - key: "pre-start", - canonical_top_key: "pre-create", - canonical_display: "pre-create", - }, - DeprecatedSection { - key: "post-start", - canonical_top_key: "post-create", - canonical_display: "post-create", - }, ]; /// Normalize a template string by replacing deprecated variables with their canonical names. @@ -486,9 +475,9 @@ pub struct Deprecations { pub approved_commands: bool, /// Has `[select]` section (moved to `[switch.picker]`) pub select: bool, - /// Has a `pre-start` hook key (renamed to `pre-create`) + /// Reserved for future hook-name deprecation tracking; always false. pub pre_start: bool, - /// Has a `post-start` hook key (renamed to `post-create`) + /// Reserved for future hook-name deprecation tracking; always false. pub post_start: bool, /// Has `[ci]` section (moved to `[forge]`) pub ci_section: bool, @@ -544,8 +533,8 @@ fn detect_deprecations_from_doc( commit_gen: find_commit_generation_from_doc(doc), approved_commands: find_approved_commands_from_doc(doc), select: find_select_from_doc(doc), - pre_start: find_pre_start_from_doc(doc), - post_start: find_post_start_from_doc(doc), + pre_start: false, + post_start: false, ci_section: find_ci_section_from_doc(doc), no_ff: find_negated_bool_from_doc(doc, "merge", "no-ff", "ff"), no_cd: find_negated_bool_from_doc(doc, "switch", "no-cd", "cd"), @@ -842,90 +831,6 @@ fn has_select_without_picker(table: &toml_edit::Table) -> bool { false } -/// Find a `pre-start` hook key (renamed to `pre-create`). -fn find_pre_start_from_doc(doc: &toml_edit::DocumentMut) -> bool { - find_renamed_hook_key(doc, "pre-start", "pre-create") -} - -/// Find a `post-start` hook key (renamed to `post-create`). -fn find_post_start_from_doc(doc: &toml_edit::DocumentMut) -> bool { - find_renamed_hook_key(doc, "post-start", "post-create") -} - -/// Detect a hook key renamed from `old_key` to `new_key`. -/// -/// Flags the deprecation when `old_key` is present (and non-empty) and `new_key` -/// is not — that is the case the migrator rewrites. When both are present the -/// migrator leaves `old_key` alone, so it is not flagged either. -/// -/// Hooks are flattened into the top level of user config, project config, and -/// each `[projects."id"]` subtree, so all three locations are checked. -fn find_renamed_hook_key(doc: &toml_edit::DocumentMut, old_key: &str, new_key: &str) -> bool { - if doc.get(new_key).is_none() && doc.get(old_key).is_some_and(is_non_empty_item) { - return true; - } - - if let Some(projects) = doc.get("projects").and_then(|p| p.as_table()) { - for (_key, project_value) in projects.iter() { - if let Some(project_table) = project_value.as_table() - && project_table.get(new_key).is_none() - && project_table.get(old_key).is_some_and(is_non_empty_item) - { - return true; - } - } - } - - false -} - -/// Check if a TOML item is non-empty (strings are always non-empty, tables must have entries). -fn is_non_empty_item(item: &toml_edit::Item) -> bool { - match item { - toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => !t.is_empty(), - toml_edit::Item::Table(t) => !t.is_empty(), - _ => true, // strings and other values are always "non-empty" - } -} - -/// Rename the `pre-start`/`post-start` hook keys to `pre-create`/`post-create`. -fn migrate_start_hooks_doc(doc: &mut toml_edit::DocumentMut) -> bool { - let mut modified = rename_hook_key(doc, "pre-start", "pre-create"); - modified |= rename_hook_key(doc, "post-start", "post-create"); - modified -} - -/// Rename `old_key` to `new_key` at the top level and under each `[projects."..."]`. -/// -/// Skips any location where `new_key` already exists — the user has already -/// consolidated there, and clobbering their canonical value would lose config. -/// The rewrite preserves the value shape (string, `[table]`, or -/// `[[array-of-tables]]`) since it moves the `Item` unchanged. -fn rename_hook_key(doc: &mut toml_edit::DocumentMut, old_key: &str, new_key: &str) -> bool { - let mut modified = false; - - if doc.get(new_key).is_none() - && let Some(value) = doc.remove(old_key) - { - doc.insert(new_key, value); - modified = true; - } - - if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) { - for (_key, project_value) in projects.iter_mut() { - if let Some(project_table) = project_value.as_table_mut() - && project_table.get(new_key).is_none() - && let Some(value) = project_table.remove(old_key) - { - project_table.insert(new_key, value); - modified = true; - } - } - } - - modified -} - /// Migrate `[select]` sections to `[switch.picker]`. /// /// Handles both top-level and project-level `[projects."...".select]` sections. @@ -983,7 +888,7 @@ fn migrate_select_table(table: &mut toml_edit::Table, modified: &mut bool) { /// The 5 canonical pre-* hook keys. const PRE_HOOK_KEYS: &[&str] = &[ "pre-switch", - "pre-create", + "pre-start", "pre-commit", "pre-merge", "pre-remove", @@ -1276,9 +1181,12 @@ fn migrate_content_doc(doc: &mut toml_edit::DocumentMut) -> bool { let mut modified = false; modified |= migrate_commit_generation_doc(doc); modified |= migrate_select_doc(doc); - // Rename `-start` hooks to `-create` before the table-form pass, which - // keys off `PRE_HOOK_KEYS` (now `pre-create`, not `pre-start`). - modified |= migrate_start_hooks_doc(doc); + // Silently rename `-create` hooks back to canonical `-start`. The + // creation hook rename is paused (see #2838): both names load via + // serde aliases, but in-memory migration to canonical keeps round-trip + // analysis (`unknown_tree`) coherent for the table and array-of-tables + // forms, where serde aliases on the field don't cover every shape. + modified |= migrate_create_hooks_doc(doc); modified |= migrate_pre_hook_table_form_doc(doc); modified |= migrate_ci_doc(doc); modified |= migrate_negated_bool_doc(doc, "merge", "no-ff", "ff"); @@ -1287,6 +1195,46 @@ fn migrate_content_doc(doc: &mut toml_edit::DocumentMut) -> bool { modified } +/// Rename the `pre-create`/`post-create` hook keys to `pre-start`/`post-start`. +/// +/// Silent — see [`migrate_content_doc`] and `format_deprecation_warnings`. +fn migrate_create_hooks_doc(doc: &mut toml_edit::DocumentMut) -> bool { + let mut modified = rename_hook_key(doc, "pre-create", "pre-start"); + modified |= rename_hook_key(doc, "post-create", "post-start"); + modified +} + +/// Rename `old_key` to `new_key` at the top level and under each `[projects."..."]`. +/// +/// Skips any location where `new_key` already exists — the user has already +/// consolidated there, and clobbering their canonical value would lose config. +/// The rewrite preserves the value shape (string, `[table]`, or +/// `[[array-of-tables]]`) since it moves the `Item` unchanged. +fn rename_hook_key(doc: &mut toml_edit::DocumentMut, old_key: &str, new_key: &str) -> bool { + let mut modified = false; + + if doc.get(new_key).is_none() + && let Some(value) = doc.remove(old_key) + { + doc.insert(new_key, value); + modified = true; + } + + if let Some(projects) = doc.get_mut("projects").and_then(|p| p.as_table_mut()) { + for (_key, project_value) in projects.iter_mut() { + if let Some(project_table) = project_value.as_table_mut() + && project_table.get(new_key).is_none() + && let Some(value) = project_table.remove(old_key) + { + project_table.insert(new_key, value); + modified = true; + } + } + } + + modified +} + /// Check if a table has `timeout-ms` under `[switch.picker]`. /// /// `[switch.picker]` can be written either as a section (regular table) or @@ -2140,31 +2088,6 @@ mod tests { .is_some_and(|doc| find_select_from_doc(&doc)) } - fn find_pre_start_deprecation(content: &str) -> bool { - content - .parse::() - .ok() - .is_some_and(|doc| find_pre_start_from_doc(&doc)) - } - - fn find_post_start_deprecation(content: &str) -> bool { - content - .parse::() - .ok() - .is_some_and(|doc| find_post_start_from_doc(&doc)) - } - - fn migrate_start_hooks(content: &str) -> String { - let Ok(mut doc) = content.parse::() else { - return content.to_string(); - }; - if migrate_start_hooks_doc(&mut doc) { - doc.to_string() - } else { - content.to_string() - } - } - fn migrate_commit_generation_sections(content: &str) -> String { let Ok(mut doc) = content.parse::() else { return content.to_string(); @@ -2210,7 +2133,7 @@ worktree-path = "../{{ repo }}.{{ branch | sanitize }}" #[test] fn test_find_deprecated_vars_repo_root() { let content = r#" -post-create = "ln -sf {{ repo_root }}/node_modules node_modules" +post-start = "ln -sf {{ repo_root }}/node_modules node_modules" "#; let found = find_deprecated_vars(content); assert_eq!(found, vec![("repo_root", "repo_path")]); @@ -2219,7 +2142,7 @@ post-create = "ln -sf {{ repo_root }}/node_modules node_modules" #[test] fn test_find_deprecated_vars_worktree() { let content = r#" -post-create = "cd {{ worktree }} && npm install" +post-start = "cd {{ worktree }} && npm install" "#; let found = find_deprecated_vars(content); assert_eq!(found, vec![("worktree", "worktree_path")]); @@ -2237,7 +2160,7 @@ worktree-path = "../{{ main_worktree }}.{{ branch | sanitize }}" #[test] fn test_find_deprecated_vars_main_worktree_path() { let content = r#" -post-create = "ln -sf {{ main_worktree_path }}/node_modules ." +post-start = "ln -sf {{ main_worktree_path }}/node_modules ." "#; let found = find_deprecated_vars(content); assert_eq!(found, vec![("main_worktree_path", "primary_worktree_path")]); @@ -2247,7 +2170,7 @@ post-create = "ln -sf {{ main_worktree_path }}/node_modules ." fn test_find_deprecated_vars_multiple() { let content = r#" worktree-path = "../{{ main_worktree }}.{{ branch | sanitize }}" -post-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" +post-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" "#; let found = find_deprecated_vars(content); assert_eq!( @@ -2263,7 +2186,7 @@ post-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" #[test] fn test_find_deprecated_vars_with_filter() { let content = r#" -post-create = "ln -sf {{ repo_root | something }}/node_modules" +post-start = "ln -sf {{ repo_root | something }}/node_modules" "#; let found = find_deprecated_vars(content); assert_eq!(found, vec![("repo_root", "repo_path")]); @@ -2272,7 +2195,7 @@ post-create = "ln -sf {{ repo_root | something }}/node_modules" #[test] fn test_find_deprecated_vars_deduplicates() { let content = r#" -post-create = "{{ repo_root }}/a {{ repo_root }}/b" +post-start = "{{ repo_root }}/a {{ repo_root }}/b" "#; let found = find_deprecated_vars(content); assert_eq!(found, vec![("repo_root", "repo_path")]); @@ -2282,7 +2205,7 @@ post-create = "{{ repo_root }}/a {{ repo_root }}/b" fn test_find_deprecated_vars_does_not_match_suffix() { // Should NOT match "worktree_path" when looking for "worktree" let content = r#" -post-create = "cd {{ worktree_path }} && npm install" +post-start = "cd {{ worktree_path }} && npm install" "#; let found = find_deprecated_vars(content); assert!( @@ -2311,8 +2234,8 @@ post-create = "cd {{ worktree_path }} && npm install" /// while detection still warned. The toml_edit-tree rewrite handles it. #[test] fn test_replace_deprecated_vars_with_escaped_quotes() { - // Source TOML: pre-create = "echo \"{{ repo_root }}\"" - let content = r#"pre-create = "echo \"{{ repo_root }}\"""#; + // Source TOML: pre-start = "echo \"{{ repo_root }}\"" + let content = r#"pre-start = "echo \"{{ repo_root }}\"""#; let result = replace_deprecated_vars(content); assert!( !result.contains("repo_root"), @@ -2327,7 +2250,7 @@ post-create = "cd {{ worktree_path }} && npm install" /// Same, exercised through the public `compute_migrated_content` entry. #[test] fn test_compute_migrated_content_escaped_quotes() { - let content = "pre-create = \"echo \\\"{{ repo_root }}\\\"\"\n"; + let content = "pre-start = \"echo \\\"{{ repo_root }}\\\"\"\n"; let migrated = compute_migrated_content(content); assert!( !migrated.contains("repo_root"), @@ -2354,14 +2277,14 @@ post-create = "cd {{ worktree_path }} && npm install" fn test_replace_deprecated_vars_multiple() { let content = r#" worktree-path = "../{{ main_worktree }}.{{ branch | sanitize }}" -post-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" +post-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" "#; let result = replace_deprecated_vars(content); assert_eq!( result, r#" worktree-path = "../{{ repo }}.{{ branch | sanitize }}" -post-create = "ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modules" +post-start = "ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modules" "# ); } @@ -2373,7 +2296,7 @@ post-create = "ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modu worktree-path = "../{{ repo }}.{{ branch }}" [hooks] -post-create = "echo hello" +post-start = "echo hello" "#; let result = replace_deprecated_vars(content); assert_eq!(result, content); // No changes since no deprecated vars @@ -2427,7 +2350,7 @@ timeout = 30 /// `compute_migrated_content` byte-for-byte (the unmodified branch). #[test] fn test_compute_migrated_content_noop_returns_input_unchanged() { - let content = "pre-create = \"echo {{ repo_path }}\"\n"; + let content = "pre-start = \"echo {{ repo_path }}\"\n"; assert_eq!(compute_migrated_content(content), content); } @@ -3972,127 +3895,6 @@ pager = "delta --paging=never" ); } - // --- pre-start/post-start → pre-create/post-create rename tests --- - - #[test] - fn test_find_start_hook_deprecation_none() { - // Canonical `-create` keys: no deprecation. - let content = r#" -pre-create = "npm install" -post-create = "npm run dev" -"#; - assert!(!find_pre_start_deprecation(content)); - assert!(!find_post_start_deprecation(content)); - } - - #[test] - fn test_find_start_hook_deprecation_top_level() { - assert!(find_pre_start_deprecation("pre-start = \"npm install\"\n")); - assert!(find_post_start_deprecation( - "post-start = \"npm run dev\"\n" - )); - } - - #[test] - fn test_find_start_hook_deprecation_project_level() { - // User config format: hooks flattened into [projects."..."] - let content = r#" -[projects."my-project"] -pre-start = "npm install" -"#; - assert!(find_pre_start_deprecation(content)); - } - - #[test] - fn test_find_start_hook_deprecation_named_commands() { - // Named command table form - let content = r#" -[pre-start] -lint = "npm run lint" -build = "npm run build" -"#; - assert!(find_pre_start_deprecation(content)); - } - - #[test] - fn test_find_start_hook_deprecation_empty_table_not_flagged() { - // Empty [pre-start] table is a no-op — don't flag. - assert!(!find_pre_start_deprecation("[pre-start]\n")); - } - - #[test] - fn test_find_start_hook_deprecation_skips_when_create_exists_top_level() { - // Both present at top level — the migrator leaves the old key alone. - let content = r#" -pre-start = "old" -pre-create = "new" -"#; - assert!(!find_pre_start_deprecation(content)); - } - - #[test] - fn test_find_start_hook_deprecation_skips_when_create_exists_project() { - let content = r#" -[projects."my-project"] -post-start = "old" -post-create = "new" -"#; - assert!(!find_post_start_deprecation(content)); - } - - #[test] - fn test_migrate_start_hooks_top_level() { - let content = r#" -pre-start = "npm install" - -[post-start] -server = "npm run dev" -"#; - let result = migrate_start_hooks(content); - assert!(result.contains("pre-create"), "got: {result}"); - assert!(result.contains("[post-create]"), "got: {result}"); - assert!(!result.contains("pre-start"), "got: {result}"); - assert!(!result.contains("post-start"), "got: {result}"); - } - - #[test] - fn test_migrate_start_hooks_project_level() { - let content = r#" -[projects."my-project"] -pre-start = "npm install" -"#; - let result = migrate_start_hooks(content); - assert!(result.contains("pre-create"), "got: {result}"); - assert!(!result.contains("pre-start"), "got: {result}"); - } - - #[test] - fn test_migrate_start_hooks_skips_when_create_exists() { - let content = r#" -pre-start = "old" -pre-create = "new" -"#; - let result = migrate_start_hooks(content); - assert_eq!(result, content, "must not clobber an existing pre-create"); - } - - #[test] - fn test_migrate_start_hooks_invalid_toml() { - let content = "this is { not valid toml"; - assert_eq!(migrate_start_hooks(content), content); - } - - #[test] - fn snapshot_migrate_start_to_create() { - let content = r#"pre-start = "npm install" - -[post-start] -server = "npm run dev" -"#; - let migrated = compute_migrated_content(content); - insta::assert_snapshot!(migration_diff(content, &migrated)); - } - fn migrate_switch_picker_timeout(content: &str) -> String { let Ok(mut doc) = content.parse::() else { return content.to_string(); @@ -4226,17 +4028,6 @@ pager = "delta" ); } - #[test] - fn test_detect_deprecations_includes_start_hooks() { - let deprecations = detect_deprecations("pre-start = \"npm install\"\n"); - assert!(deprecations.pre_start); - assert!(!deprecations.is_empty()); - - let deprecations = detect_deprecations("post-start = \"npm run dev\"\n"); - assert!(deprecations.post_start); - assert!(!deprecations.is_empty()); - } - // ==================== negated bool format + migration tests ==================== #[test] @@ -4502,7 +4293,7 @@ ff = true a = "1" b = "2" -[pre-create] +[pre-start] a = "1" b = "2" @@ -4523,7 +4314,7 @@ b = "2" found, vec![ "pre-switch", - "pre-create", + "pre-start", "pre-commit", "pre-merge", "pre-remove" @@ -4535,12 +4326,12 @@ b = "2" fn test_detect_pre_hook_table_form_per_project() { // Per-project overrides: hooks are flattened under [projects."id"] let content = r#" -[projects."github.com/user/repo".pre-create] +[projects."github.com/user/repo".pre-start] install = "npm ci" build = "npm run build" "#; let found = find_pre_hook_table_form(content); - assert_eq!(found, vec!["projects.\"github.com/user/repo\".pre-create"]); + assert_eq!(found, vec!["projects.\"github.com/user/repo\".pre-start"]); } #[test] @@ -4610,14 +4401,14 @@ third = "3" #[test] fn test_migrate_pre_hook_table_form_per_project() { let content = r#" -[projects."web".pre-create] +[projects."web".pre-start] install = "npm ci" build = "npm run build" "#; let result = migrate_pre_hook_table_form(content); let doc: toml_edit::DocumentMut = result.parse().unwrap(); let project = doc["projects"]["web"].as_table().unwrap(); - let arr = project["pre-create"] + let arr = project["pre-start"] .as_array_of_tables() .expect("should be array of tables"); assert_eq!(arr.len(), 2); @@ -4650,11 +4441,11 @@ no-ff = true test = "cargo test" lint = "cargo clippy" -[post-create] +[post-start] server = "npm run dev" "#; // migrate_pre_hook_table_form only transforms pre-* pipeline hooks; - // post-create is a post-* hook and must pass through untouched. + // post-start is a post-* hook and must pass through untouched. let result = migrate_pre_hook_table_form(content); insta::assert_snapshot!(migration_diff(content, &result)); } diff --git a/src/config/expansion.rs b/src/config/expansion.rs index 40fa9d54dc..33123c9e97 100644 --- a/src/config/expansion.rs +++ b/src/config/expansion.rs @@ -819,7 +819,7 @@ pub fn validate_template_syntax(template: &str, name: &str) -> Result<(), miniji /// available in `scope` (see [`vars_available_in`]). Catches syntax errors and /// undefined variable references *before* irreversible operations like worktree /// creation — including context-mismatch typos like `{{ args }}` in a hook or -/// `{{ target }}` in a `pre-create` hook. +/// `{{ target }}` in a `pre-start` hook. /// /// This is deliberately more permissive than real expansion: conditional vars /// like `upstream` are provided even when they may be absent at runtime. A @@ -2663,7 +2663,7 @@ mod tests { err.message ); - // `base` is available in pre-create. + // `base` is available in pre-start. assert!( validate_template( "{{ base }}", @@ -2700,7 +2700,7 @@ mod tests { ); assert!(err.message.contains("targte"), "got: {}", err.message); - // `pr_number`/`pr_url` are available in pre-create (populated when + // `pr_number`/`pr_url` are available in pre-start (populated when // creating via `pr:N` / `mr:N`). for var in ["pr_number", "pr_url"] { assert!( @@ -2711,7 +2711,7 @@ mod tests { "test" ) .is_ok(), - "{var} should validate in pre-create scope" + "{var} should validate in pre-start scope" ); } diff --git a/src/config/hooks.rs b/src/config/hooks.rs index e88539c487..44ed7a04e8 100644 --- a/src/config/hooks.rs +++ b/src/config/hooks.rs @@ -27,7 +27,8 @@ pub struct HooksConfig { /// Commands to execute before worktree creation (blocking) #[serde( default, - rename = "pre-create", + rename = "pre-start", + alias = "pre-create", skip_serializing_if = "Option::is_none" )] pub pre_create: Option, @@ -35,7 +36,8 @@ pub struct HooksConfig { /// Commands to execute after worktree creation (background) #[serde( default, - rename = "post-create", + rename = "post-start", + alias = "post-create", skip_serializing_if = "Option::is_none" )] pub post_create: Option, diff --git a/src/config/mod.rs b/src/config/mod.rs index d49d248698..61b44d874f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -298,7 +298,7 @@ mod tests { #[test] fn test_command_config_single() { - let toml = r#"post-create = "npm install""#; + let toml = r#"post-start = "npm install""#; let config: ProjectConfig = toml::from_str(toml).unwrap(); let cmd_config = config.hooks.post_create.unwrap(); let commands: Vec<_> = cmd_config.commands().collect(); @@ -309,7 +309,7 @@ mod tests { #[test] fn test_command_config_named() { let toml = r#" - [post-create] + [post-start] server = "npm run dev" watch = "npm run watch" "#; @@ -363,7 +363,7 @@ mod tests { fn test_command_config_task_order() { // Test exact ordering as used in post_create tests let toml = r#" -[post-create] +[post-start] task1 = "echo 'Task 1 running' > task1.txt" task2 = "echo 'Task 2 running' > task2.txt" "#; @@ -427,18 +427,18 @@ task2 = "echo 'Task 2 running' > task2.txt" #[test] fn test_command_config_roundtrip_single() { - let original = r#"post-create = "npm install""#; + let original = r#"post-start = "npm install""#; let config: ProjectConfig = toml::from_str(original).unwrap(); let serialized = toml::to_string(&config).unwrap(); let config2: ProjectConfig = toml::from_str(&serialized).unwrap(); assert_eq!(config, config2); - assert_snapshot!(serialized, @r#"post-create = "npm install""#); + assert_snapshot!(serialized, @r#"post-start = "npm install""#); } #[test] fn test_command_config_roundtrip_named() { let original = r#" - [post-create] + [post-start] server = "npm run dev" watch = "npm run watch" "#; @@ -447,7 +447,7 @@ task2 = "echo 'Task 2 running' > task2.txt" let config2: ProjectConfig = toml::from_str(&serialized).unwrap(); assert_eq!(config, config2); assert_snapshot!(serialized, @r#" - [post-create] + [post-start] server = "npm run dev" watch = "npm run watch" "#); @@ -677,7 +677,7 @@ squash-template-file = "~/file.txt" let toml_str = r#" worktree-path = "../{{ main_worktree }}.{{ branch }}" -[post-create] +[post-start] log = "echo '{{ repo }}' >> ~/.log" [pre-merge] @@ -686,11 +686,11 @@ lint = "cargo clippy" "#; let config: UserConfig = toml::from_str(toml_str).unwrap(); - // Check post-create + // Check post-start let post_create = config .hooks .post_create - .expect("post-create should be present"); + .expect("post-start should be present"); let commands: Vec<_> = post_create.commands().collect(); assert_eq!(commands.len(), 1); assert_eq!(commands[0].name.as_deref(), Some("log")); @@ -707,14 +707,14 @@ lint = "cargo clippy" fn test_user_hooks_config_single_command() { let toml_str = r#" worktree-path = "../{{ main_worktree }}.{{ branch }}" -post-create = "npm install" +post-start = "npm install" "#; let config: UserConfig = toml::from_str(toml_str).unwrap(); let post_create = config .hooks .post_create - .expect("post-create should be present"); + .expect("post-start should be present"); let commands: Vec<_> = post_create.commands().collect(); assert_eq!(commands.len(), 1); assert!(commands[0].name.is_none()); // single command has no name @@ -725,7 +725,7 @@ post-create = "npm install" fn test_user_hooks_not_reported_as_unknown() { let toml_str = r#" worktree-path = "../test" -post-create = "npm install" +post-start = "npm install" [pre-merge] test = "cargo test" diff --git a/src/config/project.rs b/src/config/project.rs index d905bea828..1b1198f426 100644 --- a/src/config/project.rs +++ b/src/config/project.rs @@ -311,17 +311,26 @@ impl ProjectConfig { /// /// This includes keys from ProjectConfig and HooksConfig (flattened). /// Public for use by the `WorktrunkConfig` trait implementation. +/// +/// `pre-create`/`post-create` are appended as silent serde aliases for the +/// canonical `pre-start`/`post-start` hook keys (see `HooksConfig` in +/// `src/config/hooks.rs`). The schema only knows canonical names from +/// `#[serde(rename = ...)]`; adding the aliases here keeps the unknown-key +/// round-trip from flagging an accepted name as unknown. pub fn valid_project_config_keys() -> Vec { use schemars::SchemaGenerator; let schema = SchemaGenerator::default().into_root_schema_for::(); - schema + let mut keys: Vec = schema .as_object() .and_then(|obj| obj.get("properties")) .and_then(|p| p.as_object()) .map(|props| props.keys().cloned().collect()) - .unwrap_or_default() + .unwrap_or_default(); + keys.push("pre-create".to_string()); + keys.push("post-create".to_string()); + keys } #[cfg(test)] @@ -333,8 +342,8 @@ mod tests { let contents = r#" pre-switch = "echo switching" post-switch = "rename-tab" -pre-create = "npm install" -post-create = "npm run watch" +pre-start = "npm install" +post-start = "npm run watch" pre-commit = "cargo fmt --check" post-commit = "echo committed" pre-merge = "cargo test" diff --git a/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_pre_hook_table_form.snap b/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_pre_hook_table_form.snap index 4ec82e1ee5..a3342f01e8 100644 --- a/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_pre_hook_table_form.snap +++ b/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_pre_hook_table_form.snap @@ -9,5 +9,5 @@ expression: "migration_diff(content, &result)" +[[pre-merge]] lint = "cargo clippy" - [post-create] + [post-start] server = "npm run dev" diff --git a/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_start_to_create.snap b/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_start_to_create.snap deleted file mode 100644 index a02646256b..0000000000 --- a/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_start_to_create.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: src/config/deprecation.rs -expression: "migration_diff(content, &migrated)" ---- --pre-start = "npm install" -+pre-create = "npm install" - --[post-start] -+[post-create] - server = "npm run dev" diff --git a/src/config/user/schema.rs b/src/config/user/schema.rs index 096cd0d653..d4327c049d 100644 --- a/src/config/user/schema.rs +++ b/src/config/user/schema.rs @@ -12,15 +12,24 @@ use super::UserConfig; /// /// This includes keys from UserConfig and HooksConfig (flattened). /// Public for use by the `WorktrunkConfig` trait implementation. +/// +/// `pre-create`/`post-create` are appended as silent serde aliases for the +/// canonical `pre-start`/`post-start` hook keys (see `HooksConfig` in +/// `src/config/hooks.rs`). The schema only knows canonical names from +/// `#[serde(rename = ...)]`; adding the aliases here keeps the unknown-key +/// round-trip from flagging an accepted name as unknown. pub fn valid_user_config_keys() -> Vec { let schema = SchemaGenerator::default().into_root_schema_for::(); // Extract property names from the schema // The schema flattens nested structs, so all top-level keys appear in properties - schema + let mut keys: Vec = schema .as_object() .and_then(|obj| obj.get("properties")) .and_then(|p| p.as_object()) .map(|props| props.keys().cloned().collect()) - .unwrap_or_default() + .unwrap_or_default(); + keys.push("pre-create".to_string()); + keys.push("post-create".to_string()); + keys } diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index b0c74f6720..a9088696ec 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -83,7 +83,7 @@ squash = true [step.copy-ignored] exclude = [".conductor/"] -[post-create] +[post-start] run = "npm install" [post-switch] @@ -1880,17 +1880,17 @@ fn parse_hooks(toml_str: &str) -> HooksConfig { #[test] fn test_hooks_merge_append_semantics() { - // Global has post-create, per-project has post-create + // Global has post-start, per-project has post-start // Both should run (global first, then per-project) let mut config = UserConfig { - hooks: parse_hooks("post-create = \"echo global\""), + hooks: parse_hooks("post-start = \"echo global\""), ..Default::default() }; config.projects.insert( "github.com/user/repo".to_string(), UserProjectOverrides { - hooks: parse_hooks("post-create = \"echo project\""), + hooks: parse_hooks("post-start = \"echo project\""), ..Default::default() }, ); @@ -1907,7 +1907,7 @@ fn test_hooks_merge_append_semantics() { fn test_hooks_no_project_override_uses_global() { // Global has hooks, project doesn't - global hooks used let config = UserConfig { - hooks: parse_hooks("post-create = \"echo global\""), + hooks: parse_hooks("post-start = \"echo global\""), ..Default::default() }; @@ -1926,7 +1926,7 @@ fn test_hooks_project_only_no_global() { config.projects.insert( "github.com/user/repo".to_string(), UserProjectOverrides { - hooks: parse_hooks("post-create = \"echo project\""), + hooks: parse_hooks("post-start = \"echo project\""), ..Default::default() }, ); @@ -1940,10 +1940,10 @@ fn test_hooks_project_only_no_global() { #[test] fn test_hooks_different_hook_types_not_merged() { - // Global has post-create, per-project has pre-commit + // Global has post-start, per-project has pre-commit // These should remain separate (different hook types) let mut config = UserConfig { - hooks: parse_hooks("post-create = \"echo global-start\""), + hooks: parse_hooks("post-start = \"echo global-start\""), ..Default::default() }; @@ -1957,7 +1957,7 @@ fn test_hooks_different_hook_types_not_merged() { let effective = config.hooks(Some("github.com/user/repo")); - // post-create: only global + // post-start: only global let post_create = effective.post_create.unwrap(); let start_commands: Vec<_> = post_create.commands().collect(); assert_eq!(start_commands.len(), 1); @@ -1974,7 +1974,7 @@ fn test_hooks_different_hook_types_not_merged() { fn test_hooks_none_project_uses_global() { // When no project is provided, only global hooks are used let config = UserConfig { - hooks: parse_hooks("post-create = \"echo global\""), + hooks: parse_hooks("post-start = \"echo global\""), ..Default::default() }; @@ -1997,7 +1997,7 @@ fn test_valid_user_config_keys_includes_all_hook_types() { let valid_keys = valid_user_config_keys(); for hook_type in HookType::iter() { - let key = hook_type.to_string(); // e.g., "post-create", "pre-merge" + let key = hook_type.to_string(); // e.g., "post-start", "pre-merge" assert!( valid_keys.contains(&key), "HookType::{hook_type:?} ({key}) is missing from valid_user_config_keys()" @@ -2021,6 +2021,9 @@ fn test_valid_user_config_keys_all_deserialize() { for key in &valid_keys { match key.as_str() { "projects" => continue, // Skip - table type tested separately + // Silent aliases for canonical `pre-start`/`post-start`; including + // both would produce a duplicate-field error. + "pre-create" | "post-create" => continue, "skip-shell-integration-prompt" | "skip-commit-generation-prompt" => { scalar_lines.push(format!("{key} = true")); } @@ -2064,12 +2067,12 @@ fn test_valid_user_config_keys_all_deserialize() { #[test] fn test_hooks_merge_mixed_formats_preserves_order() { // Global uses string format (unnamed command) - let global_hooks = parse_hooks(r#"post-create = "npm install""#); + let global_hooks = parse_hooks(r#"post-start = "npm install""#); // Per-project uses table format (named commands) let project_hooks = parse_hooks( r#" -[post-create] +[post-start] setup = "echo setup" "#, ); @@ -2101,14 +2104,14 @@ fn test_hooks_merge_same_names_both_run() { // Both define "test" command - both should execute let global_hooks = parse_hooks( r#" -[post-create] +[post-start] test = "cargo test" "#, ); let project_hooks = parse_hooks( r#" -[post-create] +[post-start] test = "npm test" "#, ); @@ -2277,14 +2280,14 @@ fn test_hooks_merge_trait_appends_for_global_project_merge() { #[test] fn test_hooks_merge_post_create_both_sides() { - // `post-create` from global and per-project config combine (global first). - let global = parse_hooks("post-create = \"npm install\""); - let project = parse_hooks("post-create = \"cargo build\""); + // `post-start` from global and per-project config combine (global first). + let global = parse_hooks("post-start = \"npm install\""); + let project = parse_hooks("post-start = \"cargo build\""); let merged = global.merge_with(&project); let post_create = merged .get(HookType::PostCreate) - .expect("should have post-create"); + .expect("should have post-start"); let commands: Vec<_> = post_create.commands().collect(); assert_eq!(commands.len(), 2); assert_eq!(commands[0].template, "npm install"); @@ -3020,12 +3023,12 @@ fn test_save_to_existing_file_replaces_changed_inline_table() { // replaces it (even though this changes formatting from inline to standard). let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.toml"); - std::fs::write(&config_path, "post-create = { build = \"cargo build\" }\n").unwrap(); + std::fs::write(&config_path, "post-start = { build = \"cargo build\" }\n").unwrap(); // Load, modify the hook, then save let mut config = UserConfig::load_from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); - config.hooks = toml::from_str("post-create = { build = \"cargo test\" }").unwrap(); + config.hooks = toml::from_str("post-start = { build = \"cargo test\" }").unwrap(); config.save_to(&config_path).unwrap(); let saved = std::fs::read_to_string(&config_path).unwrap(); @@ -3238,12 +3241,12 @@ future-per-project = "value" #[test] fn test_save_to_existing_file_preserves_inline_table_formatting() { - // When a user writes a hook as an inline table (e.g., `post-create = { ... }`), + // When a user writes a hook as an inline table (e.g., `post-start = { ... }`), // the diff-based merge must not rewrite it to a standard table if the value // is semantically unchanged. let dir = tempfile::tempdir().unwrap(); let config_path = dir.path().join("config.toml"); - let original = "post-create = { build = \"cargo build\" }\n"; + let original = "post-start = { build = \"cargo build\" }\n"; std::fs::write(&config_path, original).unwrap(); // Load the config (which parses hooks via flatten), then save it back @@ -3251,13 +3254,13 @@ fn test_save_to_existing_file_preserves_inline_table_formatting() { config.save_to(&config_path).unwrap(); let saved = std::fs::read_to_string(&config_path).unwrap(); - // The inline table syntax should be preserved (not expanded to [post-create]) + // The inline table syntax should be preserved (not expanded to [post-start]) assert!( - saved.contains("post-create = { build = \"cargo build\" }"), + saved.contains("post-start = { build = \"cargo build\" }"), "inline table should be preserved: {saved}" ); assert!( - !saved.contains("[post-create]"), + !saved.contains("[post-start]"), "should not be expanded to standard table: {saved}" ); } diff --git a/src/git/error.rs b/src/git/error.rs index 38a6b34f6c..9e2151f75b 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -1460,7 +1460,7 @@ impl ErrorExt for anyhow::Error { /// user's intent: /// - `wt merge` — user wants to merge, hooks run as part of that /// - `wt commit` — user wants to commit, pre-commit hooks run -/// - `wt switch --create` — user wants a worktree, post-create hooks run +/// - `wt switch --create` — user wants a worktree, post-start hooks run /// /// ## When NOT to use /// @@ -1905,7 +1905,7 @@ mod tests { error: "setup failed".into(), exit_code: None, }; - assert_snapshot!(err.render(), @"✗ pre-create command failed: setup failed"); + assert_snapshot!(err.render(), @"✗ pre-start command failed: setup failed"); // Silent errors produce empty output assert_eq!(format!("{}", WorktrunkError::CommandNotApproved), ""); diff --git a/src/git/mod.rs b/src/git/mod.rs index 40f2631f2d..ab77aab8b0 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -469,7 +469,17 @@ pub fn branch_tracks_ref( pub enum HookType { PreSwitch, PostSwitch, + // Canonical display is `pre-start`/`post-start`; `pre-create`/`post-create` + // are accepted as silent aliases via `FromStr`. The Rust variant name uses + // the future canonical (`-create`) so the eventual flip is a Display-only + // change. + #[strum(to_string = "pre-start", serialize = "pre-create")] + #[serde(rename = "pre-start", alias = "pre-create")] + #[cfg_attr(feature = "cli", value(name = "pre-start", alias = "pre-create"))] PreCreate, + #[strum(to_string = "post-start", serialize = "post-create")] + #[serde(rename = "post-start", alias = "post-create")] + #[cfg_attr(feature = "cli", value(name = "post-start", alias = "post-create"))] PostCreate, PreCommit, PostCommit, diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index 2364313147..4679898ed1 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -670,7 +670,7 @@ mod tests { assert!(repo.project_config_at_ref("no-such-ref").unwrap().is_none()); // Commit one and read it back at that branch. - test.write_project_config(r#"post-create = "echo hi""#); + test.write_project_config(r#"post-start = "echo hi""#); test.run_git(&["add", ".config/wt.toml"]); test.run_git(&["commit", "-m", "Add config"]); let cfg = repo @@ -704,7 +704,7 @@ mod tests { let test = TestRepo::with_initial_commit(); let repo = Repository::at(test.root_path()).unwrap(); - test.write_project_config(r#"post-create = "echo hi""#); + test.write_project_config(r#"post-start = "echo hi""#); test.run_git(&["add", ".config/wt.toml"]); test.run_git(&["commit", "-m", "Add config"]); diff --git a/src/help.rs b/src/help.rs index 5938e3dde8..ad6b67e2e3 100644 --- a/src/help.rs +++ b/src/help.rs @@ -572,11 +572,11 @@ fn post_process_for_html(text: &str) -> String { "```\n\ ▲ repo needs approval to execute 3 commands:\n\ \n\ - ○ pre-create install:\n\ + ○ pre-start install:\n\ \x20\x20\x20npm ci\n\ - ○ pre-create build:\n\ + ○ pre-start build:\n\ \x20\x20\x20cargo build --release\n\ - ○ pre-create env:\n\ + ○ pre-start env:\n\ \x20\x20\x20echo 'PORT={{ branch | hash_port }}' > .env.local\n\ \n\ ❯ Allow and remember? [y/N]\n\ @@ -584,11 +584,11 @@ fn post_process_for_html(text: &str) -> String { "{% terminal() %}\n\ repo needs approval to execute 3 commands:\n\ \n\ - pre-create install:\n\ + pre-start install:\n\ npm ci\n\ - pre-create build:\n\ + pre-start build:\n\ cargo build --release\n\ - pre-create env:\n\ + pre-start env:\n\ echo 'PORT={{ branch | hash_port }}' > .env.local\n\ \n\ Allow and remember? [y/N]\n\ diff --git a/src/styling/mod.rs b/src/styling/mod.rs index d9908c5528..4002bc288d 100644 --- a/src/styling/mod.rs +++ b/src/styling/mod.rs @@ -691,7 +691,7 @@ cp -cR {{ repo_root }}/target/debug/.fingerprint {{ repo_root }}/target/debug/bu // We never emit [39m ourselves - all our resets use [0m (full reset). // So any [39m in the output is an artifact from wrap_ansi that we must strip. // - // This is the actual post-create command from user config that exposed the bug. + // This is the actual post-start command from user config that exposed the bug. let command = r#"[ -d {{ repo_root }}/target/debug/deps ] && [ ! -e {{ worktree }}/target ] && mkdir -p {{ worktree }}/target/debug/deps && cp -c {{ repo_root }}/target/debug/deps/*.rlib {{ repo_root }}/target/debug/deps/*.rmeta {{ worktree diff --git a/src/testing/mod.rs b/src/testing/mod.rs index 1008893706..a0999f719f 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -851,7 +851,7 @@ impl TestRepo { /// - Remote (origin) bare repository /// - Three feature worktrees (feature-a, feature-b, feature-c) each with one commit /// - /// Uses a pre-created fixture for fast initialization - copies the fixture + /// Uses a pre-started fixture for fast initialization - copies the fixture /// from `tests/fixtures/standard/` instead of running git commands. /// /// Also sets up mock gh/glab commands that appear authenticated to prevent diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e3949967fc..3adc17fa06 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -977,10 +977,10 @@ fn setup_snapshot_settings_for_paths_with_home( add_standard_env_redactions(&mut settings); // Normalize timestamps in log filenames (format: YYYYMMDD-HHMMSS) - // Match: post-create-NAME-SHA-HHMMSS.log + // Match: post-start-NAME-SHA-HHMMSS.log settings.add_filter( - r"post-create-[^-]+-[0-9a-f]{7,40}-\d{6}\.log", - "post-create-[NAME]-[TIMESTAMP].log", + r"post-start-[^-]+-[0-9a-f]{7,40}-\d{6}\.log", + "post-start-[NAME]-[TIMESTAMP].log", ); add_remove_stats_byte_filter(&mut settings); diff --git a/tests/integration_tests/approval_pty.rs b/tests/integration_tests/approval_pty.rs index 676a2072f8..0090f9b545 100644 --- a/tests/integration_tests/approval_pty.rs +++ b/tests/integration_tests/approval_pty.rs @@ -80,7 +80,7 @@ fn test_approval_prompt_accept(repo: TestRepo) { // Remove origin so worktrunk uses directory name as project identifier repo.run_git(&["remote", "remove", "origin"]); - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); // Configure shell integration so we get the "Restart shell" hint instead of the prompt @@ -104,7 +104,7 @@ fn test_approval_prompt_decline(repo: TestRepo) { // Remove origin so worktrunk uses directory name as project identifier repo.run_git(&["remote", "remove", "origin"]); - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); // Configure shell integration so we get the "Restart shell" hint instead of the prompt @@ -129,7 +129,7 @@ fn test_approval_prompt_multiple_commands(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( - r#"pre-create = [ + r#"pre-start = [ {first = "echo 'First command'"}, {second = "echo 'Second command'"}, {third = "echo 'Third command'"}, @@ -161,7 +161,7 @@ fn test_approval_prompt_permission_error(repo: TestRepo) { // Remove origin so worktrunk uses directory name as project identifier repo.run_git(&["remote", "remove", "origin"]); - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); // Configure shell integration before making the approvals directory read-only @@ -235,7 +235,7 @@ fn test_approval_prompt_named_commands(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( - r#"pre-create = [ + r#"pre-start = [ {install = "echo 'Installing dependencies...'"}, {build = "echo 'Building project...'"}, {test = "echo 'Running tests...'"}, @@ -278,7 +278,7 @@ fn test_approval_prompt_mixed_approved_unapproved_accept(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( - r#"pre-create = [ + r#"pre-start = [ {first = "echo 'First command'"}, {second = "echo 'Second command'"}, {third = "echo 'Third command'"}, @@ -335,7 +335,7 @@ fn test_approval_prompt_mixed_approved_unapproved_decline(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( - r#"pre-create = [ + r#"pre-start = [ {first = "echo 'First command'"}, {second = "echo 'Second command'"}, {third = "echo 'Third command'"}, @@ -377,9 +377,9 @@ approved-commands = ["echo 'Second command'"] "Should show 'Commands declined' message" ); // Commands appear in the prompt, but should not be executed - // Check for "Running pre-create" which indicates execution + // Check for "Running pre-start" which indicates execution assert!( - !output.contains("Running pre-create"), + !output.contains("Running pre-start"), "Should NOT execute any commands when declined" ); assert!( @@ -855,7 +855,7 @@ fn test_config_approvals_add_accept(repo: TestRepo) { // Include a project commit append so `add_approvals` also collects the // `commit-template-append` entry (covers that branch in add_approvals). repo.write_project_config( - r#"pre-create = "echo 'test command'" + r#"pre-start = "echo 'test command'" [commit.generation] template-append = "Use conventional commits" @@ -883,7 +883,7 @@ template-append = "Use conventional commits" #[rstest] fn test_config_approvals_add_decline(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); let env_vars = repo.test_env_vars(); diff --git a/tests/integration_tests/approval_ui.rs b/tests/integration_tests/approval_ui.rs index c157fb7bf9..39d3d4fa99 100644 --- a/tests/integration_tests/approval_ui.rs +++ b/tests/integration_tests/approval_ui.rs @@ -40,7 +40,7 @@ fn snapshot_approval(test_name: &str, repo: &TestRepo, args: &[&str], approve: b #[rstest] fn test_approval_single_command(repo: TestRepo) { - repo.write_project_config(r#"pre-create = "echo 'Worktree path: {{ worktree_path }}'""#); + repo.write_project_config(r#"pre-start = "echo 'Worktree path: {{ worktree_path }}'""#); repo.commit("Add config"); @@ -55,7 +55,7 @@ fn test_approval_single_command(repo: TestRepo) { #[rstest] fn test_approval_multiple_commands(repo: TestRepo) { repo.write_project_config( - r#"[pre-create] + r#"[pre-start] branch = "echo 'Branch: {{ branch }}'" worktree = "echo 'Worktree: {{ worktree_path }}'" repo = "echo 'Repo: {{ repo }}'" @@ -79,7 +79,7 @@ fn test_approval_mixed_approved_unapproved(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( - r#"[pre-create] + r#"[pre-start] first = "echo 'First command'" second = "echo 'Second command'" third = "echo 'Third command'" @@ -106,7 +106,7 @@ approved-commands = ["echo 'Second command'"] #[rstest] fn test_yes_flag_does_not_save_approvals(repo: TestRepo) { - repo.write_project_config(r#"pre-create = "echo 'test command' > output.txt""#); + repo.write_project_config(r#"pre-start = "echo 'test command' > output.txt""#); repo.commit("Add config"); @@ -136,7 +136,7 @@ fn test_already_approved_commands_skip_prompt(repo: TestRepo) { // Remove origin so worktrunk uses directory name as project identifier repo.run_git(&["remote", "remove", "origin"]); - repo.write_project_config(r#"pre-create = "echo 'approved' > output.txt""#); + repo.write_project_config(r#"pre-start = "echo 'approved' > output.txt""#); repo.commit("Add config"); @@ -161,7 +161,7 @@ fn test_decline_approval_skips_only_unapproved(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( - r#"[pre-create] + r#"[pre-start] first = "echo 'First command'" second = "echo 'Second command'" third = "echo 'Third command'" @@ -193,7 +193,7 @@ approved-commands = ["echo 'Second command'"] #[rstest] fn test_approval_named_commands(repo: TestRepo) { repo.write_project_config( - r#"[pre-create] + r#"[pre-start] install = "echo 'Installing dependencies...'" build = "echo 'Building project...'" test = "echo 'Running tests...'" @@ -282,7 +282,7 @@ fn test_run_hook_post_merge_requires_approval(repo: TestRepo) { /// The command should fail with a clear error telling users to use --yes. #[rstest] fn test_approval_fails_in_non_tty(repo: TestRepo) { - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); // Run WITHOUT piping stdin - this simulates non-TTY environment @@ -297,7 +297,7 @@ fn test_approval_fails_in_non_tty(repo: TestRepo) { /// Even in non-TTY environments, --yes should allow commands to execute. #[rstest] fn test_yes_bypasses_tty_check(repo: TestRepo) { - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); // Run with --yes to bypass approval entirely @@ -637,7 +637,7 @@ fn test_step_hook_run_all_commands(repo: TestRepo) { /// approval prompt, so a clean exit confirms `-y` was honored. #[rstest] fn test_global_yes_before_subcommand(repo: TestRepo) { - repo.write_project_config(r#"pre-create = "echo 'test command'""#); + repo.write_project_config(r#"pre-start = "echo 'test command'""#); repo.commit("Add config"); // Place `-y` before the subcommand name. diff --git a/tests/integration_tests/approvals.rs b/tests/integration_tests/approvals.rs index d77f260abe..72576e50d8 100644 --- a/tests/integration_tests/approvals.rs +++ b/tests/integration_tests/approvals.rs @@ -42,7 +42,7 @@ fn test_add_approvals_no_config(repo: TestRepo) { #[rstest] fn test_add_approvals_all_with_none_approved(repo: TestRepo) { - repo.write_project_config(r#"pre-create = "echo 'test'""#); + repo.write_project_config(r#"pre-start = "echo 'test'""#); repo.commit("Add config"); snapshot_add_approvals("add_approvals_all_none_approved", &repo, &["--all"]); @@ -105,7 +105,7 @@ fn test_clear_approvals_with_approvals(repo: TestRepo) { // matches what `Repository::project_identifier` computes at runtime. repo.run_git(&["remote", "remove", "origin"]); repo.commit("Initial commit"); - repo.write_project_config(r#"pre-create = "echo 'test'""#); + repo.write_project_config(r#"pre-start = "echo 'test'""#); repo.commit("Add config"); // Manually approve the command using the same project id wt will compute. @@ -133,7 +133,7 @@ fn test_clear_approvals_global_with_approvals(repo: TestRepo) { // matches what `Repository::project_identifier` computes at runtime. repo.run_git(&["remote", "remove", "origin"]); repo.commit("Initial commit"); - repo.write_project_config(r#"pre-create = "echo 'test'""#); + repo.write_project_config(r#"pre-start = "echo 'test'""#); repo.commit("Add config"); // Manually approve the command using the same project id wt will compute. @@ -160,7 +160,7 @@ fn test_clear_approvals_after_clear(repo: TestRepo) { // matches what `Repository::project_identifier` computes at runtime. repo.run_git(&["remote", "remove", "origin"]); repo.commit("Initial commit"); - repo.write_project_config(r#"pre-create = "echo 'test'""#); + repo.write_project_config(r#"pre-start = "echo 'test'""#); repo.commit("Add config"); // Manually approve the command using the same project id wt will compute. @@ -228,8 +228,8 @@ fn test_clear_approvals_multiple_approvals(repo: TestRepo) { repo.run_git(&["remote", "remove", "origin"]); repo.write_project_config( r#" -pre-create = "echo 'first'" -post-create = "echo 'second'" +pre-start = "echo 'first'" +post-start = "echo 'second'" [pre-commit] lint = "echo 'third'" "#, @@ -275,7 +275,7 @@ fn test_add_approvals_all_already_approved(repo: TestRepo) { // matches what `Repository::project_identifier` computes at runtime. repo.run_git(&["remote", "remove", "origin"]); repo.commit("Initial commit"); - repo.write_project_config(r#"pre-create = "echo 'test'""#); + repo.write_project_config(r#"pre-start = "echo 'test'""#); repo.commit("Add config"); // Manually approve the command using the same project id wt will compute. @@ -326,7 +326,7 @@ fn test_add_approvals_bare_repo_config_in_primary_worktree() { std::fs::create_dir_all(&config_dir).unwrap(); std::fs::write( config_dir.join("wt.toml"), - r#"pre-create = "echo 'hello'" + r#"pre-start = "echo 'hello'" "#, ) .unwrap(); diff --git a/tests/integration_tests/bare_repository.rs b/tests/integration_tests/bare_repository.rs index 931b0abadd..4b5d30fd2e 100644 --- a/tests/integration_tests/bare_repository.rs +++ b/tests/integration_tests/bare_repository.rs @@ -756,7 +756,7 @@ fn test_bare_repo_project_config_found_from_bare_root() { let marker_str = marker_path.to_str().unwrap().replace('\\', "/"); fs::write( config_dir.join("wt.toml"), - format!("post-create = \"echo hook-executed > '{}'\"\n", marker_str), + format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str), ) .unwrap(); @@ -818,7 +818,7 @@ fn test_bare_repo_project_config_found_with_dash_c_flag() { let marker_str = marker_path.to_str().unwrap().replace('\\', "/"); fs::write( config_dir.join("wt.toml"), - format!("post-create = \"echo hook-executed > '{}'\"\n", marker_str), + format!("post-start = \"echo hook-executed > '{}'\"\n", marker_str), ) .unwrap(); @@ -886,7 +886,7 @@ fn test_bare_repo_ignores_config_in_bare_root() { let marker_str = marker_path.to_str().unwrap().replace('\\', "/"); fs::write( config_dir.join("wt.toml"), - format!("post-create = \"echo bad > '{}'\"\n", marker_str), + format!("post-start = \"echo bad > '{}'\"\n", marker_str), ) .unwrap(); diff --git a/tests/integration_tests/completion.rs b/tests/integration_tests/completion.rs index fe0347b7d4..d30f491a65 100644 --- a/tests/integration_tests/completion.rs +++ b/tests/integration_tests/completion.rs @@ -801,8 +801,8 @@ fn test_complete_hook_subcommands(repo: TestRepo) { let subcommands = value_suggestions(&stdout); // Hook types and commands assert!(subcommands.contains(&"show"), "Missing show"); - assert!(subcommands.contains(&"pre-create"), "Missing pre-create"); - assert!(subcommands.contains(&"post-create"), "Missing post-create"); + assert!(subcommands.contains(&"pre-start"), "Missing pre-start"); + assert!(subcommands.contains(&"post-start"), "Missing post-start"); assert!(subcommands.contains(&"post-switch"), "Missing post-switch"); assert!(subcommands.contains(&"pre-switch"), "Missing pre-switch"); assert!(subcommands.contains(&"pre-commit"), "Missing pre-commit"); @@ -822,7 +822,7 @@ fn test_complete_hook_subcommands(repo: TestRepo) { assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); let subcommands = value_suggestions(&stdout); - assert!(subcommands.contains(&"post-create")); + assert!(subcommands.contains(&"post-start")); assert!(subcommands.contains(&"post-switch")); assert!(subcommands.contains(&"post-commit")); assert!(subcommands.contains(&"post-merge")); diff --git a/tests/integration_tests/config_init.rs b/tests/integration_tests/config_init.rs index 15ff50d8d1..75cc620d37 100644 --- a/tests/integration_tests/config_init.rs +++ b/tests/integration_tests/config_init.rs @@ -99,7 +99,7 @@ fn test_config_create_project_already_exists(repo: TestRepo) { fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"[[project.pre-create]] + r#"[[project.pre-start]] run = "echo hello" "#, ) diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index 445d35fd6e..34ec72feff 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -34,9 +34,9 @@ approved-commands = ["npm install"] fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"pre-create = "npm install" + r#"pre-start = "npm install" -[post-create] +[post-start] server = "npm run dev" "#, ) @@ -364,7 +364,7 @@ my-lint = "my-lint-tool" /// A current-worktree `.config/wt.toml` that still uses the deprecated /// `pre-start`/`post-start` hook keys keeps working through `wt hook show`: -/// `ProjectConfig::load` migrates the keys to `pre-create`/`post-create` +/// `ProjectConfig::load` migrates the keys to `pre-start`/`post-start` /// before deserializing, the value parser accepts both the canonical type /// argument and the deprecated alias, and Phase 1 of the rename (issue #2838) /// migrates silently — no deprecation warning. @@ -385,9 +385,9 @@ deps = "post-start-tool" // must deserialize into the `*_create` field); the `-start` arg additionally // exercises the value parser accepting the deprecated alias. let cases = [ - ("pre-create", "pre-start-tool"), ("pre-start", "pre-start-tool"), - ("post-create", "post-start-tool"), + ("pre-start", "pre-start-tool"), + ("post-start", "post-start-tool"), ("post-start", "post-start-tool"), ]; for (type_arg, expected) in cases { @@ -663,7 +663,7 @@ pager = "delta --paging=never" let mut cmd = repo.wt_command(); cmd.env("WORKTRUNK_SYSTEM_CONFIG_PATH", &system_config_path) .env("NO_COLOR", "1"); - cmd.args(["hook", "show", "post-create"]) + cmd.args(["hook", "show", "post-start"]) .current_dir(repo.root_path()); let output = cmd.output().unwrap(); @@ -680,7 +680,7 @@ pager = "delta --paging=never" let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("npm install"), - "post-start in system config should migrate to post-create and stay active, got: {stdout}" + "post-start in system config should migrate to post-start and stay active, got: {stdout}" ); } @@ -1862,7 +1862,7 @@ fn test_deprecated_template_variables_show_warning(repo: TestRepo, temp_home: Te // Use all deprecated variables: repo_root, worktree, main_worktree // Note: hooks are at top-level in user config, not in a [hooks] section r#"worktree-path = "../{{ main_worktree }}.{{ branch }}" -pre-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" +pre-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" "#, ) .unwrap(); @@ -1908,7 +1908,7 @@ fn test_deprecated_template_variables_verbose_shows_content(repo: TestRepo, temp fs::write( config_path, r#"worktree-path = "../{{ main_worktree }}.{{ branch }}" -pre-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" +pre-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" "#, ) .unwrap(); @@ -1980,7 +1980,7 @@ fn test_fixing_deprecated_config_then_reintroducing_still_warns( fs::write( &project_config_path, - r#"pre-create = "ln -sf {{ main_worktree }}/node_modules" + r#"pre-start = "ln -sf {{ main_worktree }}/node_modules" "#, ) .unwrap(); @@ -1993,7 +1993,7 @@ fn test_fixing_deprecated_config_then_reintroducing_still_warns( fs::write( &project_config_path, - r#"pre-create = "ln -sf {{ repo }}/node_modules" + r#"pre-start = "ln -sf {{ repo }}/node_modules" "#, ) .unwrap(); @@ -2012,7 +2012,7 @@ fn test_fixing_deprecated_config_then_reintroducing_still_warns( fs::write( &project_config_path, - r#"pre-create = "cd {{ worktree }} && npm install" + r#"pre-start = "cd {{ worktree }} && npm install" "#, ) .unwrap(); @@ -2949,7 +2949,7 @@ fn test_config_show_displays_deprecation_details(mut repo: TestRepo, temp_home: fs::write( &config_path, r#"worktree-path = "../{{ main_worktree }}.{{ branch }}" -pre-create = "ln -sf {{ repo_root }}/node_modules" +pre-start = "ln -sf {{ repo_root }}/node_modules" "#, ) .unwrap(); @@ -2990,7 +2990,7 @@ fn test_config_show_from_linked_worktree_shows_main_worktree_hint( fs::create_dir_all(&project_config_dir).unwrap(); fs::write( project_config_dir.join("wt.toml"), - r#"pre-create = "ln -sf {{ main_worktree }}/node_modules" + r#"pre-start = "ln -sf {{ main_worktree }}/node_modules" "#, ) .unwrap(); @@ -3135,7 +3135,7 @@ approved-commands = ["npm install", "npm test"] #[rstest] fn test_config_update_applies_project_config_migration(repo: TestRepo) { repo.write_project_config( - r#"pre-create = "ln -sf {{ main_worktree }}/node_modules" + r#"pre-start = "ln -sf {{ main_worktree }}/node_modules" "#, ); repo.commit("Add deprecated project config"); @@ -3153,7 +3153,7 @@ fn test_config_update_applies_project_config_migration(repo: TestRepo) { ); let updated = fs::read_to_string(&project_config_path).unwrap(); - assert!(updated.contains("pre-create")); + assert!(updated.contains("pre-start")); assert!(updated.contains("{{ repo }}")); assert!(!updated.contains("main_worktree")); } @@ -3164,7 +3164,7 @@ fn test_config_update_applies_project_config_migration(repo: TestRepo) { #[rstest] fn test_config_update_clean_project_config_is_noop(repo: TestRepo) { repo.write_project_config( - r#"pre-create = "echo ready" + r#"pre-start = "echo ready" "#, ); repo.commit("Add clean project config"); @@ -3188,7 +3188,7 @@ fn test_config_update_clean_project_config_is_noop(repo: TestRepo) { #[rstest] fn test_config_update_project_config_from_linked_worktree_shows_hint(repo: TestRepo) { repo.write_project_config( - r#"pre-create = "ln -sf {{ main_worktree }}/node_modules" + r#"pre-start = "ln -sf {{ main_worktree }}/node_modules" "#, ); repo.commit("Add deprecated project config"); @@ -3235,7 +3235,7 @@ fn test_config_update_print_emits_both_configs(repo: TestRepo) { ) .unwrap(); repo.write_project_config( - r#"pre-create = "ln -sf {{ main_worktree }}/node_modules" + r#"pre-start = "ln -sf {{ main_worktree }}/node_modules" "#, ); repo.commit("Add deprecated project config"); @@ -3250,7 +3250,7 @@ fn test_config_update_print_emits_both_configs(repo: TestRepo) { assert!(stdout.contains("# User config")); assert!(stdout.contains("# Project config")); assert!(stdout.contains("{{ repo }}")); - assert!(stdout.contains("pre-create")); + assert!(stdout.contains("pre-start")); } /// `wt config update --print` on a clean config exits silently with empty @@ -3344,7 +3344,7 @@ fn test_config_update_applies_template_var_migration(repo: TestRepo) { fs::write( config_path, r#"worktree-path = "../{{ main_worktree }}.{{ branch }}" -pre-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" +pre-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" "#, ) .unwrap(); @@ -3398,7 +3398,7 @@ fn test_config_show_displays_pre_hook_table_form_deprecation( test = "cargo test" lint = "cargo clippy" -[pre-create] +[pre-start] install = "npm ci" env = "cp .env.example .env" "#, @@ -4578,12 +4578,12 @@ fn test_project_config_path_env_var_override(repo: TestRepo, temp_home: TempDir) // override points elsewhere. let in_repo_config = repo.root_path().join(".config").join("wt.toml"); fs::create_dir_all(in_repo_config.parent().unwrap()).unwrap(); - fs::write(&in_repo_config, "pre-create = \"in-repo-hook\"\n").unwrap(); + fs::write(&in_repo_config, "pre-start = \"in-repo-hook\"\n").unwrap(); // Write the override project config at an arbitrary path. let override_dir = tempfile::tempdir().unwrap(); let override_path = override_dir.path().join("override.toml"); - fs::write(&override_path, "pre-create = \"override-hook\"\n").unwrap(); + fs::write(&override_path, "pre-start = \"override-hook\"\n").unwrap(); let mut cmd = wt_command(); repo.configure_wt_cmd(&mut cmd); @@ -4602,7 +4602,7 @@ fn test_project_config_path_env_var_override(repo: TestRepo, temp_home: TempDir) let json: serde_json::Value = serde_json::from_str(&String::from_utf8_lossy(&output.stdout)).unwrap(); assert_eq!( - json["project"]["config"]["pre-create"], "override-hook", + json["project"]["config"]["pre-start"], "override-hook", "expected override config to be loaded, got: {}", json["project"] ); diff --git a/tests/integration_tests/config_state.rs b/tests/integration_tests/config_state.rs index b11750a08d..c0ff4ca33c 100644 --- a/tests/integration_tests/config_state.rs +++ b/tests/integration_tests/config_state.rs @@ -684,7 +684,7 @@ fn test_state_get_logs_with_files(repo: TestRepo) { std::fs::create_dir_all(&log_dir).unwrap(); write_log_at( &log_dir, - &hook_log_rel_path("feature", "user", "post-create", "npm"), + &hook_log_rel_path("feature", "user", "post-start", "npm"), "npm output here", ); // >= 1024 bytes to exercise the `{}K` size-formatting branch in @@ -709,10 +709,10 @@ fn test_state_get_logs_with_files(repo: TestRepo) { commands.jsonl HOOK OUTPUT @ - File Size Age - ──────────────────────────────── ──── ────── - bugfix/internal/remove.log - feature/user/post-create/npm.log + File Size Age + ─────────────────────────────── ──── ────── + bugfix/internal/remove.log + feature/user/post-start/npm.log DIAGNOSTIC @   (none) @@ -823,7 +823,7 @@ fn test_state_clear_logs_with_files(repo: TestRepo) { std::fs::create_dir_all(&log_dir).unwrap(); write_log_at( &log_dir, - &hook_log_rel_path("feature", "user", "post-create", "npm"), + &hook_log_rel_path("feature", "user", "post-start", "npm"), "npm output", ); write_log_at( @@ -848,7 +848,7 @@ fn test_state_clear_logs_sweeps_legacy_flat_files(repo: TestRepo) { let git_dir = repo.root_path().join(".git"); let log_dir = git_dir.join("wt/logs"); std::fs::create_dir_all(&log_dir).unwrap(); - std::fs::write(log_dir.join("feature-post-create-npm.log"), "old layout").unwrap(); + std::fs::write(log_dir.join("feature-post-start-npm.log"), "old layout").unwrap(); std::fs::write(log_dir.join("bugfix-remove.log"), "old layout").unwrap(); let output = wt_state_cmd(&repo, "logs", "clear", &[]).output().unwrap(); @@ -1163,7 +1163,7 @@ fn test_state_get_comprehensive(repo: TestRepo) { std::fs::create_dir_all(&log_dir).unwrap(); write_log_at( &log_dir, - &hook_log_rel_path("feature", "user", "post-create", "npm"), + &hook_log_rel_path("feature", "user", "post-start", "npm"), "npm output", ); write_log_at( @@ -1336,7 +1336,7 @@ fn test_state_get_json_with_logs(repo: TestRepo) { std::fs::create_dir_all(&log_dir).unwrap(); write_log_at( &log_dir, - &hook_log_rel_path("feature", "user", "post-create", "npm"), + &hook_log_rel_path("feature", "user", "post-start", "npm"), "npm output", ); write_log_at( @@ -1392,11 +1392,11 @@ fn test_state_get_json_with_logs(repo: TestRepo) { }, { "branch": "feature", - "file": "feature/user/post-create/npm.log", - "hook_type": "post-create", + "file": "feature/user/post-start/npm.log", + "hook_type": "post-start", "modified_at": "", "name": "npm", - "path": "_REPO_/.git/wt/logs/feature/user/post-create/npm.log", + "path": "_REPO_/.git/wt/logs/feature/user/post-start/npm.log", "size": "", "source": "user" } @@ -2108,7 +2108,7 @@ fn test_logs_get_json_with_files(repo: TestRepo) { std::fs::write(log_dir.join("diagnostic.md"), "# report").unwrap(); write_log_at( &log_dir, - &hook_log_rel_path("main", "user", "post-create", "server"), + &hook_log_rel_path("main", "user", "post-start", "server"), "output", ); diff --git a/tests/integration_tests/config_update_pty.rs b/tests/integration_tests/config_update_pty.rs index ae98b8608b..26a0680fb2 100644 --- a/tests/integration_tests/config_update_pty.rs +++ b/tests/integration_tests/config_update_pty.rs @@ -37,7 +37,7 @@ fn test_config_update_prompt_accept(repo: TestRepo) { fs::write( config_path, r#"worktree-path = "../{{ main_worktree }}.{{ branch }}" -pre-create = "ln -sf {{ repo_root }}/node_modules" +pre-start = "ln -sf {{ repo_root }}/node_modules" "#, ) .unwrap(); @@ -60,7 +60,7 @@ pre-create = "ln -sf {{ repo_root }}/node_modules" fn test_config_update_prompt_decline(repo: TestRepo) { let config_path = repo.test_config_path(); let original_content = r#"worktree-path = "../{{ main_worktree }}.{{ branch }}" -pre-create = "ln -sf {{ repo_root }}/node_modules" +pre-start = "ln -sf {{ repo_root }}/node_modules" "#; fs::write(config_path, original_content).unwrap(); diff --git a/tests/integration_tests/e2e_shell_post_create.rs b/tests/integration_tests/e2e_shell_post_start.rs similarity index 90% rename from tests/integration_tests/e2e_shell_post_create.rs rename to tests/integration_tests/e2e_shell_post_start.rs index c7e4fc6000..51c86a1acb 100644 --- a/tests/integration_tests/e2e_shell_post_create.rs +++ b/tests/integration_tests/e2e_shell_post_start.rs @@ -13,17 +13,17 @@ use std::fs; // Test with bash and fish #[case("bash")] #[case("fish")] -fn test_shell_integration_post_create_background(#[case] shell: &str, repo: TestRepo) { +fn test_shell_integration_post_start_background(#[case] shell: &str, repo: TestRepo) { // Create project config with background command let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "sleep 0.05 && echo 'Background task done' > bg_marker.txt""#, + r#"post-start = "sleep 0.05 && echo 'Background task done' > bg_marker.txt""#, ) .unwrap(); - repo.commit("Add post-create config"); + repo.commit("Add post-start config"); // Pre-approve the command repo.write_test_approvals( @@ -98,20 +98,20 @@ approved-commands = ["sleep 0.05 && echo 'Background task done' > bg_marker.txt" } #[rstest] -fn test_bash_shell_integration_post_create_parallel(repo: TestRepo) { +fn test_bash_shell_integration_post_start_parallel(repo: TestRepo) { // Create project config with multiple background commands let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"[post-create] + r#"[post-start] task1 = "sleep 0.05 && echo 'Task 1' > task1.txt" task2 = "sleep 0.05 && echo 'Task 2' > task2.txt" "#, ) .unwrap(); - repo.commit("Add multiple post-create commands"); + repo.commit("Add multiple post-start commands"); // Pre-approve commands repo.write_test_approvals( @@ -157,17 +157,17 @@ approved-commands = [ } #[rstest] -fn test_bash_shell_integration_post_create_blocks(repo: TestRepo) { +fn test_bash_shell_integration_post_start_blocks(repo: TestRepo) { // Create project config with blocking command let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"pre-create = "echo 'Setup done' > setup.txt""#, + r#"pre-start = "echo 'Setup done' > setup.txt""#, ) .unwrap(); - repo.commit("Add pre-create command"); + repo.commit("Add pre-start command"); // Pre-approve command repo.write_test_approvals( @@ -203,12 +203,12 @@ approved-commands = ["echo 'Setup done' > setup.txt"] output ); - // Verify that pre-create command completed before wt returned (blocking behavior) + // Verify that pre-start command completed before wt returned (blocking behavior) // The file should exist immediately after wt exits let setup_file = worktree_path.join("setup.txt"); assert!( setup_file.exists(), - "Setup file should exist immediately after wt returns (pre-create is blocking)" + "Setup file should exist immediately after wt returns (pre-start is blocking)" ); let content = fs::read_to_string(&setup_file).unwrap(); @@ -221,13 +221,13 @@ approved-commands = ["echo 'Setup done' > setup.txt"] #[cfg(unix)] #[rstest] -fn test_fish_shell_integration_post_create_background(repo: TestRepo) { +fn test_fish_shell_integration_post_start_background(repo: TestRepo) { // Create project config with background command let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"[post-create] + r#"[post-start] fish_bg = "sleep 0.05 && echo 'Fish background done' > fish_bg.txt" "#, ) diff --git a/tests/integration_tests/help.rs b/tests/integration_tests/help.rs index ec6d818d65..eef5e66a0c 100644 --- a/tests/integration_tests/help.rs +++ b/tests/integration_tests/help.rs @@ -280,11 +280,7 @@ fn test_help_description_unknown_command() { #[case("nested_subcommand_step_squash", "squash", "wt step squash")] #[case("nested_subcommand_step_commit", "commit", "wt step commit")] #[case("nested_subcommand_hook_pre_merge", "pre-merge", "wt hook pre-merge")] -#[case( - "nested_subcommand_hook_pre_create", - "pre-create", - "wt hook pre-create" -)] +#[case("nested_subcommand_hook_pre_start", "pre-start", "wt hook pre-start")] fn test_nested_subcommand_suggestion( #[case] test_name: &str, #[case] subcommand: &str, diff --git a/tests/integration_tests/hook_show.rs b/tests/integration_tests/hook_show.rs index c9bc603fc3..0f45806cb2 100644 --- a/tests/integration_tests/hook_show.rs +++ b/tests/integration_tests/hook_show.rs @@ -31,7 +31,7 @@ user-lint = "pre-commit run --all-files" {test = "cargo test"}, ] -[post-create] +[post-start] deps = "npm install" "#, ); @@ -88,7 +88,7 @@ fn setup_all_hook_types(repo: &TestRepo, temp_home: &TempDir) { {test = "cargo test"}, ] -[post-create] +[post-start] deps = "npm install" [post-merge] @@ -251,10 +251,10 @@ fn test_hook_show_merges_user_project_hooks(repo: TestRepo, temp_home: TempDir) format!( r#"worktree-path = "../{{{{ repo }}}}.{{{{ branch }}}}" -[post-create] +[post-start] global-hook = "echo global" -[projects.'{project_id_str}'.post-create] +[projects.'{project_id_str}'.post-start] project-hook = "echo per-project" "# ), @@ -391,7 +391,7 @@ broken = "echo {{ branch" } /// Test that undefined variable errors show both template and error with --expanded. -/// The `base` variable is only defined for pre-create hooks, so using it in pre-commit +/// The `base` variable is only defined for pre-start hooks, so using it in pre-commit /// will trigger an undefined variable error that shows both the error and raw template. #[rstest] fn test_hook_show_expanded_undefined_var(repo: TestRepo, temp_home: TempDir) { @@ -405,7 +405,7 @@ fn test_hook_show_expanded_undefined_var(repo: TestRepo, temp_home: TempDir) { ) .unwrap(); - // Create project config with `base` variable (only defined for pre-create hooks) + // Create project config with `base` variable (only defined for pre-start hooks) // In pre-commit context, this will be undefined and should show error + template repo.write_project_config( r#"[pre-commit] @@ -450,7 +450,7 @@ user-lint = "pre-commit run --all-files" {build = "cargo build"}, ] -[post-create] +[post-start] deps = "npm install" "#, ); @@ -474,7 +474,7 @@ deps = "npm install" assert_eq!( entries.len(), 3, - "user pre-commit + project pre-merge + project post-create" + "user pre-commit + project pre-merge + project post-start" ); // User entry @@ -511,7 +511,7 @@ fn test_hook_show_filtered_expanded_json(repo: TestRepo, temp_home: TempDir) { [pre-commit] user-lint = "echo {{ branch }}" -[post-create] +[post-start] user-greet = "echo hi" "#, ) @@ -520,7 +520,7 @@ user-greet = "echo hi" r#"[pre-commit] project-fmt = "echo fmt {{ branch }}" -[post-create] +[post-start] project-deps = "echo deps" "#, ); @@ -546,7 +546,7 @@ project-deps = "echo deps" let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); let entries = parsed.as_array().expect("array"); - // Filter dropped the post-create hooks from both user and project. + // Filter dropped the post-start hooks from both user and project. let names: Vec<&str> = entries .iter() .map(|e| e["name"].as_str().unwrap()) diff --git a/tests/integration_tests/list.rs b/tests/integration_tests/list.rs index 972173c0ab..2a48a287a7 100644 --- a/tests/integration_tests/list.rs +++ b/tests/integration_tests/list.rs @@ -2266,7 +2266,7 @@ fn test_tips_dev_server_workflow(mut repo: TestRepo) { // Add project config with URL template for dev servers repo.write_project_config( - r#"[post-create] + r#"[post-start] server = "npm run dev -- --port {{ branch | hash_port }} &" [list] diff --git a/tests/integration_tests/merge.rs b/tests/integration_tests/merge.rs index 12d20b8913..f1935ab871 100644 --- a/tests/integration_tests/merge.rs +++ b/tests/integration_tests/merge.rs @@ -1215,7 +1215,7 @@ command = "{llm_path_str}" ); } -// NOTE: test_readme_example_hooks_pre_create and test_readme_example_hooks_pre_merge +// NOTE: test_readme_example_hooks_pre_start and test_readme_example_hooks_pre_merge // were removed - they're covered by PTY-based tests in shell_wrapper.rs that capture // combined stdout/stderr for README examples. diff --git a/tests/integration_tests/mod.rs b/tests/integration_tests/mod.rs index 13ca94d287..50cfb46f34 100644 --- a/tests/integration_tests/mod.rs +++ b/tests/integration_tests/mod.rs @@ -28,7 +28,7 @@ pub mod diagnostic; pub mod directives; pub mod doc_templates; pub mod e2e_shell; -pub mod e2e_shell_post_create; +pub mod e2e_shell_post_start; pub mod eval; pub mod for_each; pub mod git_error_display; @@ -41,7 +41,7 @@ pub mod list_config; pub mod list_progressive; pub mod merge; pub mod output_system_guard; -pub mod post_create_commands; +pub mod post_start_commands; pub mod push; pub mod readme_sync; pub mod remove; diff --git a/tests/integration_tests/post_create_commands.rs b/tests/integration_tests/post_start_commands.rs similarity index 84% rename from tests/integration_tests/post_create_commands.rs rename to tests/integration_tests/post_start_commands.rs index 0e812384e3..a9322d6a3f 100644 --- a/tests/integration_tests/post_create_commands.rs +++ b/tests/integration_tests/post_start_commands.rs @@ -37,15 +37,15 @@ fn snapshot_switch(test_name: &str, repo: &TestRepo, args: &[&str]) { // ============================================================================ #[rstest] -fn test_post_create_no_config(repo: TestRepo) { +fn test_post_start_no_config(repo: TestRepo) { // Switch without project config should work normally - snapshot_switch("post_create_no_config", &repo, &["--create", "feature"]); + snapshot_switch("post_start_no_config", &repo, &["--create", "feature"]); } #[rstest] -fn test_post_create_single_command(repo: TestRepo) { +fn test_post_start_single_command(repo: TestRepo) { // Create project config with a single command (string format) - repo.write_project_config(r#"pre-create = "echo 'Setup complete'""#); + repo.write_project_config(r#"pre-start = "echo 'Setup complete'""#); repo.commit("Add config"); @@ -57,15 +57,11 @@ approved-commands = ["echo 'Setup complete'"] ); // Command should execute without prompting - snapshot_switch( - "post_create_single_command", - &repo, - &["--create", "feature"], - ); + snapshot_switch("post_start_single_command", &repo, &["--create", "feature"]); } /// A config that still uses the deprecated `pre-start` hook key loads and runs: -/// the key is silently migrated to `pre-create`. Phase 1 of the rename emits no +/// the key is silently migrated to `pre-start`. Phase 1 of the rename emits no /// warning (see https://github.com/max-sixty/worktrunk/issues/2838). #[rstest] fn test_deprecated_start_hook_key_runs_silently(repo: TestRepo) { @@ -90,7 +86,7 @@ approved-commands = ["echo ran > marker.txt"] String::from_utf8_lossy(&output.stderr) ); - // The hook ran — the `pre-start` key was migrated to `pre-create`. + // The hook ran — the `pre-start` key was migrated to `pre-start`. let worktree_path = repo.root_path().parent().unwrap().join("repo.feature"); assert!( worktree_path.join("marker.txt").exists(), @@ -107,10 +103,10 @@ approved-commands = ["echo ran > marker.txt"] } #[rstest] -fn test_post_create_named_commands(repo: TestRepo) { +fn test_post_start_named_commands(repo: TestRepo) { // Create project config with named commands (table format) repo.write_project_config( - r#"[pre-create] + r#"[pre-start] install = "echo 'Installing deps'" setup = "echo 'Running setup'" "#, @@ -129,17 +125,13 @@ approved-commands = [ ); // Commands should execute sequentially - snapshot_switch( - "post_create_named_commands", - &repo, - &["--create", "feature"], - ); + snapshot_switch("post_start_named_commands", &repo, &["--create", "feature"]); } #[rstest] -fn test_post_create_failing_command(repo: TestRepo) { +fn test_post_start_failing_command(repo: TestRepo) { // Create project config with a command that will fail - repo.write_project_config(r#"pre-create = "exit 1""#); + repo.write_project_config(r#"pre-start = "exit 1""#); repo.commit("Add config with failing command"); @@ -150,19 +142,19 @@ approved-commands = ["exit 1"] "#, ); - // Failing pre-create hook (via deprecated pre-create name) aborts with FailFast + // Failing pre-start hook (via deprecated pre-start name) aborts with FailFast snapshot_switch( - "post_create_failing_command", + "post_start_failing_command", &repo, &["--create", "feature"], ); } #[rstest] -fn test_post_create_template_expansion(repo: TestRepo) { +fn test_post_start_template_expansion(repo: TestRepo) { // Create project config with template variables repo.write_project_config( - r#"[pre-create] + r#"[pre-start] repo = "echo 'Repo: {{ repo }}' > info.txt" branch = "echo 'Branch: {{ branch }}' >> info.txt" hash_port = "echo 'Port: {{ branch | hash_port }}' >> info.txt" @@ -190,7 +182,7 @@ approved-commands = [ // Commands should execute with expanded templates snapshot_switch( - "post_create_template_expansion", + "post_start_template_expansion", &repo, &["--create", "feature/test"], ); @@ -240,10 +232,10 @@ approved-commands = [ } #[rstest] -fn test_post_create_verbose_template_expansion(repo: TestRepo) { - // Test that -v shows template expansion for pre-create hooks +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-create] + r#"[pre-start] setup = "echo 'Setting up {{ branch | sanitize }} in {{ worktree_path }}'" "#, ); @@ -273,15 +265,15 @@ approved-commands = [ &["-v"], ); set_temp_home_env(&mut cmd, temp_home.path()); - assert_cmd_snapshot!("post_create_verbose_template_expansion", cmd); + assert_cmd_snapshot!("post_start_verbose_template_expansion", cmd); }); } #[rstest] -fn test_post_create_default_branch_template(repo: TestRepo) { +fn test_post_start_default_branch_template(repo: TestRepo) { // Create project config with default_branch template variable repo.write_project_config( - r#"pre-create = "echo 'Default: {{ default_branch }}' > default.txt""#, + r#"pre-start = "echo 'Default: {{ default_branch }}' > default.txt""#, ); repo.commit("Add config with default_branch template"); @@ -295,7 +287,7 @@ approved-commands = ["echo 'Default: {{ default_branch }}' > default.txt"] // Create a feature branch worktree (--yes skips approval prompt) snapshot_switch( - "post_create_default_branch_template", + "post_start_default_branch_template", &repo, &["--create", "feature", "--yes"], ); @@ -318,7 +310,7 @@ approved-commands = ["echo 'Default: {{ default_branch }}' > default.txt"] } #[rstest] -fn test_post_create_git_variables_template(#[from(repo_with_remote)] repo: TestRepo) { +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"]) @@ -327,7 +319,7 @@ fn test_post_create_git_variables_template(#[from(repo_with_remote)] repo: TestR // Create project config with git-related template variables repo.write_project_config( - r#"[pre-create] + 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" @@ -339,7 +331,7 @@ worktree_name = "echo 'Worktree Name: {{ worktree_name }}' >> git_vars.txt" // Create a feature branch worktree (--yes skips approval prompt) snapshot_switch( - "post_create_git_variables_template", + "post_start_git_variables_template", &repo, &["--create", "feature", "--yes"], ); @@ -391,7 +383,7 @@ worktree_name = "echo 'Worktree Name: {{ worktree_name }}' >> git_vars.txt" } #[rstest] -fn test_post_create_upstream_template(#[from(repo_with_remote)] repo: TestRepo) { +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"]) @@ -401,14 +393,14 @@ fn test_post_create_upstream_template(#[from(repo_with_remote)] repo: TestRepo) // 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_create_upstream_conditional for the fix. - repo.write_project_config(r#"pre-create = "echo 'Upstream: {{ upstream }}' > upstream.txt""#); + // 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_create_upstream_template", + "post_start_upstream_template", &repo, &["--create", "feature", "--yes"], ); @@ -425,7 +417,7 @@ fn test_post_create_upstream_template(#[from(repo_with_remote)] repo: TestRepo) } #[rstest] -fn test_post_create_upstream_conditional(#[from(repo_with_remote)] repo: TestRepo) { +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"]) @@ -435,7 +427,7 @@ fn test_post_create_upstream_conditional(#[from(repo_with_remote)] repo: TestRep // Create project config with conditional upstream check // Using {% if not upstream %} allows safe handling of undefined variables repo.write_project_config( - r#"pre-create = "{% if not upstream %}echo 'no-upstream' > upstream.txt{% else %}echo '{{ upstream }}' > upstream.txt{% endif %}""#, + r#"pre-start = "{% if not upstream %}echo 'no-upstream' > upstream.txt{% else %}echo '{{ upstream }}' > upstream.txt{% endif %}""#, ); repo.commit("Add config with conditional upstream"); @@ -449,7 +441,7 @@ approved-commands = ["{% if not upstream %}echo 'no-upstream' > upstream.txt{% e // Create a feature branch - it won't have upstream tracking configured yet snapshot_switch( - "post_create_upstream_conditional", + "post_start_upstream_conditional", &repo, &["--create", "feature", "--yes"], ); @@ -472,10 +464,10 @@ approved-commands = ["{% if not upstream %}echo 'no-upstream' > upstream.txt{% e } #[rstest] -fn test_post_create_base_variables(repo: TestRepo) { +fn test_post_start_base_variables(repo: TestRepo) { // Create project config with base template variables repo.write_project_config( - r#"[pre-create] + r#"[pre-start] base = "echo 'Base: {{ base }}' > base_info.txt" base_path = "echo 'Base Path: {{ base_worktree_path }}' >> base_info.txt" "#, @@ -495,7 +487,7 @@ approved-commands = [ // Create a feature branch worktree from main snapshot_switch( - "post_create_base_variables", + "post_start_base_variables", &repo, &["--create", "feature", "--base", "main"], ); @@ -542,12 +534,12 @@ approved-commands = [ } #[rstest] -fn test_pre_create_json_stdin(repo: TestRepo) { +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-create = "cat > context.json""#); + repo.write_project_config(r#"pre-start = "cat > context.json""#); repo.commit("Add config"); @@ -612,14 +604,14 @@ approved-commands = ["cat > context.json"] ); assert_eq!( json["hook_type"].as_str(), - Some("pre-create"), + Some("pre-start"), "JSON should contain hook_type" ); } #[rstest] #[cfg(unix)] -fn test_post_create_script_reads_json(repo: TestRepo) { +fn test_post_start_script_reads_json(repo: TestRepo) { use crate::common::wt_command; use std::os::unix::fs::PermissionsExt; @@ -650,7 +642,7 @@ with open('hook_output.txt', 'w') as f: // Create project config that runs the script repo.write_project_config( - r#"[pre-create] + r#"[pre-start] setup = "./scripts/setup.py" "#, ); @@ -705,7 +697,7 @@ approved-commands = ["./scripts/setup.py"] contents ); assert!( - contents.contains("hook_type=pre-create"), + contents.contains("hook_type=pre-start"), "Output should contain hook_type: {}", contents ); @@ -717,11 +709,11 @@ approved-commands = ["./scripts/setup.py"] } #[rstest] -fn test_post_create_json_stdin(repo: TestRepo) { +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-create = "cat > context.json""#); + repo.write_project_config(r#"post-start = "cat > context.json""#); repo.commit("Add config"); @@ -764,7 +756,7 @@ approved-commands = ["cat > context.json"] ); assert_eq!( json["hook_type"].as_str(), - Some("post-create"), + Some("post-start"), "Background hook should receive hook_type" ); } @@ -774,10 +766,10 @@ approved-commands = ["cat > context.json"] // ============================================================================ #[rstest] -fn test_post_create_single_background_command(repo: TestRepo) { +fn test_post_start_single_background_command(repo: TestRepo) { // Create project config with a background command repo.write_project_config( - r#"post-create = "sleep 0.1 && echo 'Background task done' > background.txt""#, + r#"post-start = "sleep 0.1 && echo 'Background task done' > background.txt""#, ); repo.commit("Add background command"); @@ -791,7 +783,7 @@ approved-commands = ["sleep 0.1 && echo 'Background task done' > background.txt" // Command should spawn in background (wt exits immediately) snapshot_switch( - "post_create_single_background", + "post_start_single_background", &repo, &["--create", "feature"], ); @@ -809,10 +801,10 @@ approved-commands = ["sleep 0.1 && echo 'Background task done' > background.txt" /// Test that -v shows verbose per-hook output for background hooks #[rstest] -fn test_post_create_verbose_shows_per_hook_output(repo: TestRepo) { +fn test_post_start_verbose_shows_per_hook_output(repo: TestRepo) { // Create project config with a background command repo.write_project_config( - r#"[post-create] + r#"[post-start] setup = "echo 'verbose test' > verbose.txt" "#, ); @@ -828,17 +820,17 @@ approved-commands = ["echo 'verbose test' > verbose.txt"] // With -v, should show detailed per-hook output with command in gutter snapshot_switch( - "post_create_verbose_output", + "post_start_verbose_output", &repo, &["-v", "--create", "feature"], ); } #[rstest] -fn test_post_create_multiple_background_commands(repo: TestRepo) { +fn test_post_start_multiple_background_commands(repo: TestRepo) { // Create project config with multiple background commands (table format) repo.write_project_config( - r#"[post-create] + r#"[post-start] task1 = "echo 'Task 1 running' > task1.txt" task2 = "echo 'Task 2 running' > task2.txt" "#, @@ -858,7 +850,7 @@ approved-commands = [ // Commands should spawn in parallel snapshot_switch( - "post_create_multiple_background", + "post_start_multiple_background", &repo, &["--create", "feature"], ); @@ -870,12 +862,12 @@ approved-commands = [ } #[rstest] -fn test_both_pre_create_and_post_create(repo: TestRepo) { +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-create = "echo 'Setup done' > setup.txt" + r#"pre-start = "echo 'Setup done' > setup.txt" -[post-create] +[post-start] server = "sleep 0.05 && echo 'Server running' > server.txt" "#, ); @@ -892,10 +884,10 @@ approved-commands = [ "#, ); - // pre-create runs first (blocking), then post-create (background) - snapshot_switch("both_pre_and_post_create", &repo, &["--create", "feature"]); + // 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-create is blocking) + // 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(), @@ -909,7 +901,7 @@ approved-commands = [ #[rstest] fn test_invalid_toml(repo: TestRepo) { // Create invalid TOML - repo.write_project_config("pre-create = [invalid syntax\n"); + repo.write_project_config("pre-start = [invalid syntax\n"); repo.commit("Add invalid config"); @@ -922,11 +914,9 @@ fn test_invalid_toml(repo: TestRepo) { // ============================================================================ #[rstest] -fn test_post_create_log_file_captures_output(repo: TestRepo) { +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-create = "echo 'stdout output' && echo 'stderr output' >&2""#, - ); + repo.write_project_config(r#"post-start = "echo 'stdout output' && echo 'stderr output' >&2""#); repo.commit("Add command with stdout/stderr"); @@ -938,7 +928,7 @@ approved-commands = ["echo 'stdout output' && echo 'stderr output' >&2"] ); snapshot_switch( - "post_create_log_captures_output", + "post_start_log_captures_output", &repo, &["--create", "feature"], ); @@ -950,13 +940,13 @@ approved-commands = ["echo 'stdout output' && echo 'stderr output' >&2"] // 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-create/cmd-0-*.log`. - let post_create_dir = log_dir + // 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-create"); - let cmd_log = fs::read_dir(&post_create_dir) - .unwrap_or_else(|e| panic!("reading {post_create_dir:?}: {e}")) + .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| { @@ -980,9 +970,9 @@ approved-commands = ["echo 'stdout output' && echo 'stderr output' >&2"] } #[rstest] -fn test_post_create_invalid_command_handling(repo: TestRepo) { +fn test_post_start_invalid_command_handling(repo: TestRepo) { // Create command with syntax error (missing quote) - repo.write_project_config(r#"post-create = "echo 'unclosed quote""#); + repo.write_project_config(r#"post-start = "echo 'unclosed quote""#); repo.commit("Add invalid command"); @@ -995,7 +985,7 @@ approved-commands = ["echo 'unclosed quote"] // wt should still complete successfully even if background command has errors snapshot_switch( - "post_create_invalid_command", + "post_start_invalid_command", &repo, &["--create", "feature"], ); @@ -1004,15 +994,15 @@ approved-commands = ["echo 'unclosed quote"] let worktree_path = repo.root_path().parent().unwrap().join("repo.feature"); assert!( worktree_path.exists(), - "Worktree should be created even if post-create command fails" + "Worktree should be created even if post-start command fails" ); } #[rstest] -fn test_post_create_multiple_commands_separate_logs(repo: TestRepo) { +fn test_post_start_multiple_commands_separate_logs(repo: TestRepo) { // Create multiple background commands with distinct output repo.write_project_config( - r#"[post-create] + r#"[post-start] task1 = "echo 'TASK1_OUTPUT'" task2 = "echo 'TASK2_OUTPUT'" task3 = "echo 'TASK3_OUTPUT'" @@ -1032,7 +1022,7 @@ approved-commands = [ "#, ); - snapshot_switch("post_create_separate_logs", &repo, &["--create", "feature"]); + 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"); @@ -1041,13 +1031,13 @@ approved-commands = [ 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-create/{task}.log` in the nested layout. - let post_create_dir = log_dir + // `{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-create"); - let log_files: Vec<_> = fs::read_dir(&post_create_dir) - .unwrap_or_else(|e| panic!("reading {post_create_dir:?}: {e}")) + .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 [ @@ -1058,7 +1048,7 @@ approved-commands = [ 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_create_dir:?}")); + .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(); @@ -1070,9 +1060,9 @@ approved-commands = [ } #[rstest] -fn test_execute_flag_with_post_create_commands(repo: TestRepo) { - // Create post-create command - repo.write_project_config(r#"post-create = "echo 'Background task' > background.txt""#); +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"); @@ -1083,9 +1073,9 @@ approved-commands = ["echo 'Background task' > background.txt"] "#, ); - // Use --execute flag along with post-create command + // Use --execute flag along with post-start command snapshot_switch( - "execute_with_post_create", + "execute_with_post_start", &repo, &[ "--create", @@ -1108,10 +1098,10 @@ approved-commands = ["echo 'Background task' > background.txt"] } #[rstest] -fn test_post_create_complex_shell_commands(repo: TestRepo) { +fn test_post_start_complex_shell_commands(repo: TestRepo) { // Create command with pipes and redirects repo.write_project_config( - r#"post-create = "echo 'line1\nline2\nline3' | grep line2 > filtered.txt""#, + r#"post-start = "echo 'line1\nline2\nline3' | grep line2 > filtered.txt""#, ); repo.commit("Add complex shell command"); @@ -1123,7 +1113,7 @@ approved-commands = ["echo 'line1\nline2\nline3' | grep line2 > filtered.txt"] "#, ); - snapshot_switch("post_create_complex_shell", &repo, &["--create", "feature"]); + 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"); @@ -1135,10 +1125,10 @@ approved-commands = ["echo 'line1\nline2\nline3' | grep line2 > filtered.txt"] } #[rstest] -fn test_post_create_multiline_commands_with_newlines(repo: TestRepo) { +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-create = """ + r#"post-start = """ echo 'first line' > multiline.txt echo 'second line' >> multiline.txt echo 'third line' >> multiline.txt @@ -1163,7 +1153,7 @@ approved-commands = [""" )); snapshot_switch( - "post_create_multiline_with_newlines", + "post_start_multiline_with_newlines", &repo, &["--create", "feature"], ); @@ -1182,10 +1172,10 @@ approved-commands = [""" } #[rstest] -fn test_post_create_multiline_with_control_structures(repo: TestRepo) { +fn test_post_start_multiline_with_control_structures(repo: TestRepo) { // Test multiline command with if-else control structure repo.write_project_config( - r#"pre-create = """ + r#"pre-start = """ if [ ! -f test.txt ]; then echo 'File does not exist' > result.txt else @@ -1214,7 +1204,7 @@ approved-commands = [""" )); snapshot_switch( - "post_create_multiline_control_structure", + "post_start_multiline_control_structure", &repo, &["--create", "feature"], ); @@ -1236,44 +1226,44 @@ approved-commands = [""" // ============================================================================ /// -/// This is a regression test for a bug where post-create commands were running on ALL +/// 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_create_skipped_on_existing_worktree(repo: TestRepo) { - // Create project config with post-create command - repo.write_project_config(r#"post-create = "echo 'POST-START-RAN' > post_create_marker.txt""#); +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-create config"); + repo.commit("Add post-start config"); // Pre-approve the command repo.write_test_approvals( r#"[projects."../origin"] -approved-commands = ["echo 'POST-START-RAN' > post_create_marker.txt"] +approved-commands = ["echo 'POST-START-RAN' > post_start_marker.txt"] "#, ); - // First: Create worktree - post-create SHOULD run + // First: Create worktree - post-start SHOULD run snapshot_switch( - "post_create_create_with_command", + "post_start_create_with_command", &repo, &["--create", "feature"], ); - // Wait for background post-create command to complete + // 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_create_marker.txt"); + 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-create runs again + // Remove the marker file to detect if post-start runs again fs::remove_file(&marker_file).unwrap(); - // Second: Switch to EXISTING worktree - post-create should NOT run - snapshot_switch("post_create_skip_existing", &repo, &["feature"]); + // 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-create did NOT run when switching to existing worktree + // 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" @@ -1285,10 +1275,10 @@ approved-commands = ["echo 'POST-START-RAN' > post_create_marker.txt"] // ============================================================================ #[rstest] -fn test_post_create_project_pipeline(repo: TestRepo) { +fn test_post_start_project_pipeline(repo: TestRepo) { // Project config with pipeline: serial setup, then concurrent tasks repo.write_project_config( - r#"post-create = [ + r#"post-start = [ "echo SETUP > setup_marker.txt", { task1 = "cat setup_marker.txt > task1_saw_setup.txt", task2 = "echo TASK2 > task2.txt" } ] @@ -1307,7 +1297,7 @@ approved-commands = [ ); snapshot_switch( - "post_create_project_pipeline", + "post_start_project_pipeline", &repo, &["--create", "feature"], ); @@ -1325,10 +1315,10 @@ approved-commands = [ } #[rstest] -fn test_post_create_pipeline_with_template_vars(repo: TestRepo) { +fn test_post_start_pipeline_with_template_vars(repo: TestRepo) { // Pipeline with template variable expansion repo.write_project_config( - r#"post-create = [ + r#"post-start = [ "echo {{ branch }} > branch_marker.txt", { check = "cat branch_marker.txt > branch_check.txt" } ] @@ -1346,7 +1336,7 @@ approved-commands = [ ); snapshot_switch( - "post_create_pipeline_template_vars", + "post_start_pipeline_template_vars", &repo, &["--create", "feature"], ); @@ -1363,11 +1353,11 @@ approved-commands = [ } #[rstest] -fn test_post_create_target_variable_expands_to_branch(repo: TestRepo) { - // `{{ target }}` in post-create should equal the bare `{{ branch }}` — +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-create = "echo '{{ target }}' > target_marker.txt""#); - repo.commit("Add post-create with target var"); + 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"] @@ -1376,7 +1366,7 @@ approved-commands = ["echo '{{ target }}' > target_marker.txt"] ); snapshot_switch( - "post_create_target_variable", + "post_start_target_variable", &repo, &["--create", "feature"], ); @@ -1411,7 +1401,7 @@ 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"); + .expect("failed to pre-start feature worktree"); repo.wt_command() .args(["switch", "main", "--no-hooks", "--yes"]) .output() @@ -1432,11 +1422,11 @@ approved-commands = ["echo '{{ target }}' > target_marker.txt"] } #[rstest] -fn test_post_create_mixed_user_pipeline_project_flat(repo: TestRepo) { +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-create = [ + r#"post-start = [ "echo USER_SETUP > user_pipeline_marker.txt", { user_bg = "echo USER_BG > user_bg.txt" } ] @@ -1444,7 +1434,7 @@ fn test_post_create_mixed_user_pipeline_project_flat(repo: TestRepo) { ); repo.write_project_config( - r#"[post-create] + r#"[post-start] proj = "echo PROJECT > project_marker.txt" "#, ); @@ -1457,7 +1447,7 @@ approved-commands = ["echo PROJECT > project_marker.txt"] ); snapshot_switch( - "post_create_mixed_user_pipeline_project_flat", + "post_start_mixed_user_pipeline_project_flat", &repo, &["--create", "feature"], ); @@ -1482,14 +1472,14 @@ approved-commands = ["echo PROJECT > project_marker.txt"] /// failing-first-command variant additionally exercises the bail-on-failure /// path inside the serial branch — second never gets to run. #[rstest] -fn test_post_create_concurrent_serial_force(repo: TestRepo) { +fn test_post_start_concurrent_serial_force(repo: TestRepo) { repo.write_project_config( - r#"[post-create] + r#"[post-start] first = "echo FIRST >> serial_order.txt" second = "echo SECOND >> serial_order.txt" "#, ); - repo.commit("Add concurrent post-create"); + repo.commit("Add concurrent post-start"); repo.write_test_approvals( r#"[projects."../origin"] @@ -1518,17 +1508,17 @@ approved-commands = [ } #[rstest] -fn test_post_create_concurrent_serial_bails_on_failure(repo: TestRepo) { +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-create] + r#"[post-start] first = "echo FIRST > first_marker.txt && false" second = "echo SECOND > second_marker.txt" "#, ); - repo.commit("Add failing post-create"); + repo.commit("Add failing post-start"); repo.write_test_approvals( r#"[projects."../origin"] diff --git a/tests/integration_tests/readme_sync.rs b/tests/integration_tests/readme_sync.rs index 72eb593ccf..1faaac3431 100644 --- a/tests/integration_tests/readme_sync.rs +++ b/tests/integration_tests/readme_sync.rs @@ -1242,10 +1242,11 @@ fn test_config_docs_include_all_sections() { let all_keys = valid_user_config_keys(); - // Hook keys from HookType enum + removed post-create (kept in schema but rejected at load) + // Hook keys from HookType enum + `pre-create`/`post-create` aliases (see + // `HooksConfig` in src/config/hooks.rs). let hook_keys: HashSet = HookType::iter() .map(|h| h.to_string()) - .chain(std::iter::once("post-create".to_string())) + .chain(["pre-create".to_string(), "post-create".to_string()]) .collect(); // Keys that are bare scalars or internal flags, not TOML section headers @@ -1306,10 +1307,11 @@ fn test_project_config_docs_include_all_sections() { let all_keys = valid_project_config_keys(); - // Hook keys from HookType enum + removed post-create (kept in schema but rejected at load) + // Hook keys from HookType enum + `pre-create`/`post-create` aliases (see + // `HooksConfig` in src/config/hooks.rs). let hook_keys: HashSet = HookType::iter() .map(|h| h.to_string()) - .chain(std::iter::once("post-create".to_string())) + .chain(["pre-create".to_string(), "post-create".to_string()]) .collect(); // Separate schema keys into section keys and hook keys diff --git a/tests/integration_tests/shell_wrapper.rs b/tests/integration_tests/shell_wrapper.rs index 55e331b2f7..c518d03ef5 100644 --- a/tests/integration_tests/shell_wrapper.rs +++ b/tests/integration_tests/shell_wrapper.rs @@ -1024,7 +1024,7 @@ mod unix_tests { ); } - /// Test switch --create with pre-create (blocking) and post-create (background) + /// Test switch --create with pre-start (blocking) and post-start (background) /// Note: bash and fish disabled due to flaky PTY buffering race conditions /// /// TODO: Fix timing/race condition in bash where "Building project..." output appears @@ -1035,19 +1035,19 @@ mod unix_tests { #[case("zsh")] // #[case("fish")] // TODO: Fish shell has non-deterministic PTY output ordering fn test_wrapper_switch_with_hooks(#[case] shell: &str, repo: TestRepo) { - // Create project config with both pre-create and post-create hooks + // Create project config with both pre-start and post-start hooks let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), r#"# Blocking commands that run before worktree is ready -pre-create = [ +pre-start = [ {install = "echo 'Installing dependencies...'"}, {build = "echo 'Building project...'"}, ] # Background commands that run in parallel -[post-create] +[post-start] server = "echo 'Starting dev server on port 3000'" watch = "echo 'Watching for file changes'" "#, @@ -1269,18 +1269,18 @@ approved-commands = [ // ======================================================================== #[rstest] - fn test_switch_with_post_create_command_no_directive_leak(repo: TestRepo) { - // Configure a post-create command in the project config (this is where the bug manifests) - // The println! in handle_post_create_commands causes directive leaks + fn test_switch_with_post_start_command_no_directive_leak(repo: TestRepo) { + // Configure a post-start command in the project config (this is where the bug manifests) + // The println! in handle_post_start_commands causes directive leaks let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'test command executed'""#, + r#"post-start = "echo 'test command executed'""#, ) .unwrap(); - repo.commit("Add post-create command"); + repo.commit("Add post-start command"); // Pre-approve the command repo.write_test_approvals( @@ -1411,16 +1411,16 @@ approved-commands = ["echo 'test command executed'"] #[rstest] fn test_wrapper_preserves_progress_messages(repo: TestRepo) { - // Configure a post-create background command that will trigger progress output + // Configure a post-start background command that will trigger progress output let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'background task'""#, + r#"post-start = "echo 'background task'""#, ) .unwrap(); - repo.commit("Add post-create command"); + repo.commit("Add post-start command"); // Pre-approve the command repo.write_test_approvals( @@ -1456,16 +1456,16 @@ approved-commands = ["echo 'background task'"] #[rstest] fn test_fish_wrapper_preserves_progress_messages(repo: TestRepo) { - // Configure a post-create background command that will trigger progress output + // Configure a post-start background command that will trigger progress output let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'fish background task'""#, + r#"post-start = "echo 'fish background task'""#, ) .unwrap(); - repo.commit("Add post-create command"); + repo.commit("Add post-start command"); // Pre-approve the command repo.write_test_approvals( @@ -1665,16 +1665,16 @@ approved-commands = ["echo 'fish background task'"] /// The NO_MONITOR option should suppress [1] 12345 and [1] + done messages #[rstest] fn test_zsh_no_job_control_notifications(repo: TestRepo) { - // Configure a post-create command that will trigger background job + // Configure a post-start command that will trigger background job let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'background job'""#, + r#"post-start = "echo 'background job'""#, ) .unwrap(); - repo.commit("Add post-create command"); + repo.commit("Add post-start command"); // Pre-approve the command repo.write_test_approvals( @@ -1713,16 +1713,16 @@ approved-commands = ["echo 'background job'"] /// - DONE notifications (`[1]+ Done`): `set +m` before backgrounding #[rstest] fn test_bash_job_control_suppression(repo: TestRepo) { - // Configure a post-create command that will trigger background job + // Configure a post-start command that will trigger background job let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'bash background'""#, + r#"post-start = "echo 'bash background'""#, ) .unwrap(); - repo.commit("Add post-create command"); + repo.commit("Add post-start command"); // Pre-approve the command repo.write_test_approvals( @@ -2276,16 +2276,16 @@ approved-commands = ["echo 'bash background'"] #[case("fish")] #[case("nu")] fn test_shell_completes_cleanly(#[case] shell: &str, repo: TestRepo) { - // Configure a post-create command to exercise the background job code path + // Configure a post-start command to exercise the background job code path let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'cleanup test'""#, + r#"post-start = "echo 'cleanup test'""#, ) .unwrap(); - repo.commit("Add post-create command"); + repo.commit("Add post-start command"); // Pre-approve the command repo.write_test_approvals( @@ -2540,7 +2540,7 @@ command = "{}" shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined)); } - /// README example: Creating worktree with pre-create and post-create hooks + /// README example: Creating worktree with pre-start and post-start hooks /// /// This test demonstrates: /// - Pre-start hooks (install dependencies) @@ -2548,10 +2548,10 @@ command = "{}" /// /// Uses shell wrapper to avoid "To enable automatic cd" hint. /// - /// Source: tests/snapshots/shell_wrapper__tests__readme_example_hooks_pre_create.snap + /// Source: tests/snapshots/shell_wrapper__tests__readme_example_hooks_pre_start.snap #[rstest] - fn test_readme_example_hooks_pre_create(repo: TestRepo) { - // Create project config with pre-create and post-create hooks + fn test_readme_example_hooks_pre_start(repo: TestRepo) { + // Create project config with pre-start and post-start hooks let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); @@ -2587,10 +2587,10 @@ fi } let config_content = r#" -[pre-create] +[pre-start] "install" = "uv sync" -[post-create] +[post-start] "dev" = "uv run dev" "#; @@ -2620,7 +2620,7 @@ fi shell_wrapper_settings().bind(|| assert_snapshot!(&output.combined)); } - /// README example: approval prompt for pre-create commands + /// README example: approval prompt for pre-start commands /// This test captures just the prompt (before responding) to show what users see. /// /// Note: This uses direct PTY execution (not shell wrapper) because interactive prompts @@ -2634,9 +2634,9 @@ fi // Remove origin so worktrunk uses directory name as project identifier repo.run_git(&["remote", "remove", "origin"]); - // Create project config with named pre-create commands + // Create project config with named pre-start commands repo.write_project_config( - r#"pre-create = [ + r#"pre-start = [ {install = "echo 'Installing dependencies...'"}, {build = "echo 'Building project...'"}, {test = "echo 'Running tests...'"}, diff --git a/tests/integration_tests/snapshots/integration__integration_tests__git_error_display__hook_command_failed_without_name.snap b/tests/integration_tests/snapshots/integration__integration_tests__git_error_display__hook_command_failed_without_name.snap index 0a0f27ba40..04c3f0ca3d 100644 --- a/tests/integration_tests/snapshots/integration__integration_tests__git_error_display__hook_command_failed_without_name.snap +++ b/tests/integration_tests/snapshots/integration__integration_tests__git_error_display__hook_command_failed_without_name.snap @@ -1,5 +1,5 @@ --- source: tests/integration_tests/git_error_display.rs -expression: err.to_string() +expression: err.render() --- -✗ pre-create command failed: command not found +✗ pre-start command failed: command not found diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index b76662581d..c19147d3b6 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -980,7 +980,7 @@ fn test_switch_no_config_commands_execute_still_runs(repo: TestRepo) { } #[rstest] -fn test_switch_no_config_commands_skips_post_create_commands(repo: TestRepo) { +fn test_switch_no_config_commands_skips_post_start_commands(repo: TestRepo) { use std::fs; // Create project config with a command that would create a file @@ -991,7 +991,7 @@ fn test_switch_no_config_commands_skips_post_create_commands(repo: TestRepo) { fs::write( config_dir.join("wt.toml"), - format!(r#"post-create = "{}""#, create_file_cmd), + format!(r#"post-start = "{}""#, create_file_cmd), ) .unwrap(); @@ -1013,14 +1013,14 @@ approved-commands = ["{}"] ) .unwrap(); - // With --no-hooks, the post-create command should be skipped + // With --no-hooks, the post-start command should be skipped snapshot_switch( - "switch_no_hooks_skips_post_create", + "switch_no_hooks_skips_post_start", &repo, - &["--create", "no-post-create", "--no-hooks"], + &["--create", "no-post-start", "--no-hooks"], ); - // post-create runs in the background; with --no-hooks it is never spawned, + // post-start runs in the background; with --no-hooks it is never spawned, // but sleep briefly so a regression that incorrectly spawns it has time to // create the marker (per tests/CLAUDE.md "Testing absence"). std::thread::sleep(std::time::Duration::from_millis(500)); @@ -1029,10 +1029,10 @@ approved-commands = ["{}"] .root_path() .parent() .unwrap() - .join(format!("{repo_name}.no-post-create")); + .join(format!("{repo_name}.no-post-start")); assert!( !worktree.join("marker.txt").exists(), - "post-create hook should have been skipped, but marker.txt was created" + "post-start hook should have been skipped, but marker.txt was created" ); } @@ -1062,7 +1062,7 @@ fn test_switch_no_config_commands_with_yes(repo: TestRepo) { fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), - r#"post-create = "echo 'marker' > marker.txt""#, + r#"post-start = "echo 'marker' > marker.txt""#, ) .unwrap(); @@ -1076,7 +1076,7 @@ fn test_switch_no_config_commands_with_yes(repo: TestRepo) { &["--create", "yes-no-hooks", "--yes", "--no-hooks"], ); - // post-create runs in the background; with --no-hooks it is never spawned, + // post-start runs in the background; with --no-hooks it is never spawned, // but sleep briefly so a regression that incorrectly spawns it has time to // create the marker (per tests/CLAUDE.md "Testing absence"). std::thread::sleep(std::time::Duration::from_millis(500)); @@ -1088,11 +1088,11 @@ fn test_switch_no_config_commands_with_yes(repo: TestRepo) { .join(format!("{repo_name}.yes-no-hooks")); assert!( !worktree.join("marker.txt").exists(), - "post-create hook should have been skipped, but marker.txt was created" + "post-start hook should have been skipped, but marker.txt was created" ); } -/// `wt switch --create --base ` resolves `pre-create` / `post-create` +/// `wt switch --create --base ` resolves `pre-start` / `post-start` /// from the base branch's committed `.config/wt.toml` — the worktree these hooks /// run in is a checkout of that branch. The primary worktree (cwd) has no /// project config at all; only `other-base` does, so a regression that read the @@ -1106,8 +1106,8 @@ fn test_switch_create_reads_base_branch_config(mut repo: TestRepo) { other_wt.join(".config/wt.toml"), // `{{ repo_path }}` is the main worktree root regardless of which // worktree the hook runs in, so the markers land where the test reads. - r#"pre-create = "echo pre-create-from-base > {{ repo_path }}/pre-create-marker.txt" -post-create = "echo post-create-from-base > {{ repo_path }}/post-create-marker.txt" + r#"pre-start = "echo pre-start-from-base > {{ repo_path }}/pre-start-marker.txt" +post-start = "echo post-start-from-base > {{ repo_path }}/post-start-marker.txt" "#, ) .unwrap(); @@ -1132,19 +1132,19 @@ post-create = "echo post-create-from-base > {{ repo_path }}/post-create-marker.t String::from_utf8_lossy(&output.stderr) ); - let pre_marker = repo.root_path().join("pre-create-marker.txt"); + let pre_marker = repo.root_path().join("pre-start-marker.txt"); wait_for_file_content(&pre_marker); assert_eq!( fs::read_to_string(&pre_marker).unwrap().trim(), - "pre-create-from-base", - "pre-create should run with the base branch's config" + "pre-start-from-base", + "pre-start should run with the base branch's config" ); - let post_marker = repo.root_path().join("post-create-marker.txt"); + let post_marker = repo.root_path().join("post-start-marker.txt"); wait_for_file_content(&post_marker); assert_eq!( fs::read_to_string(&post_marker).unwrap().trim(), - "post-create-from-base", - "post-create should run with the base branch's config" + "post-start-from-base", + "post-start should run with the base branch's config" ); } @@ -1217,7 +1217,7 @@ fn test_switch_create_honors_project_config_path_override(mut repo: TestRepo) { fs::create_dir_all(other_wt.join(".config")).unwrap(); fs::write( other_wt.join(".config/wt.toml"), - r#"post-create = "echo IGNORED-COMMITTED-HOOK > {{ repo_path }}/wrong-marker.txt""#, + r#"post-start = "echo IGNORED-COMMITTED-HOOK > {{ repo_path }}/wrong-marker.txt""#, ) .unwrap(); repo.run_git_in(&other_wt, &["add", ".config/wt.toml"]); @@ -1227,7 +1227,7 @@ fn test_switch_create_honors_project_config_path_override(mut repo: TestRepo) { let override_path = repo.root_path().parent().unwrap().join("override-wt.toml"); fs::write( &override_path, - r#"post-create = "echo OVERRIDE-HOOK > {{ repo_path }}/right-marker.txt""#, + r#"post-start = "echo OVERRIDE-HOOK > {{ repo_path }}/right-marker.txt""#, ) .unwrap(); @@ -1971,25 +1971,25 @@ fn test_switch_post_hook_no_path_with_shell_integration(repo: TestRepo) { ); } -/// When both post-switch and post-create hooks are configured, they should be combined -/// into a single output line with format: "Running post-switch: {names}; post-create: {names} @ path" +/// When both post-switch and post-start hooks are configured, they should be combined +/// into a single output line with format: "Running post-switch: {names}; post-start: {names} @ path" #[rstest] -fn test_switch_combined_post_switch_and_post_create_hooks(repo: TestRepo) { - // Create project config with both post-switch and post-create hooks +fn test_switch_combined_post_switch_and_post_start_hooks(repo: TestRepo) { + // Create project config with both post-switch and post-start hooks let config_dir = repo.root_path().join(".config"); fs::create_dir_all(&config_dir).unwrap(); fs::write( config_dir.join("wt.toml"), r#"post-switch = "echo switched" -post-create = "echo started" +post-start = "echo started" "#, ) .unwrap(); repo.commit("Add config"); - // Run switch --create (triggers both post-switch and post-create) - // Should show a single combined line: "Running post-switch: project; post-create: project @ path" + // Run switch --create (triggers both post-switch and post-start) + // Should show a single combined line: "Running post-switch: project; post-start: project @ path" snapshot_switch( "switch_combined_hooks", &repo, @@ -2551,7 +2551,7 @@ fn test_switch_pr_fork(#[from(repo_with_remote)] repo: TestRepo) { }); } -/// `pre-create`, `post-create`, and `post-switch` hooks on PR/MR-created worktrees +/// `pre-start`, `post-start`, and `post-switch` hooks on PR/MR-created worktrees /// see `pr_number` and `pr_url` in their template context. Both GitHub PRs and /// GitLab MRs canonicalize to the same `pr_*` names — hook authors don't need /// to branch on platform. Pre-switch fires before PR resolution and never sees @@ -2565,13 +2565,13 @@ fn test_switch_pr_hooks_see_pr_vars(#[from(repo_with_remote)] repo: TestRepo) { repo.run_git(&["checkout", "-b", "pr-source"]); fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap(); // Each hook writes its own marker in the primary worktree so we can verify - // post-switch + post-create (background) populate pr_number/pr_url too. + // post-switch + post-start (background) populate pr_number/pr_url too. // {{ repo_path }} is always the main repo's working tree. fs::create_dir_all(repo.root_path().join(".config")).unwrap(); fs::write( repo.root_path().join(".config/wt.toml"), - r#"pre-create = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/pre_create.txt" -post-create = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_create.txt" + r#"pre-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/pre_start.txt" +post-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_start.txt" post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_switch.txt" "#, ) @@ -2641,7 +2641,7 @@ post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_pa ); let expected = "pr_number=42 pr_url=https://github.com/owner/test-repo/pull/42"; - for marker in ["pre_create.txt", "post_create.txt", "post_switch.txt"] { + for marker in ["pre_start.txt", "post_start.txt", "post_switch.txt"] { let path = repo.root_path().join(marker); // post-* hooks run in the background; poll until the file appears. wait_for_file_content(&path); @@ -2655,7 +2655,7 @@ post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_pa } } -/// `wt switch pr:N` resolves `post-create` etc. from the PR's tree — the fetched +/// `wt switch pr:N` resolves `post-start` etc. from the PR's tree — the fetched /// head ref, potentially from a fork — and the approval prompt reads that same /// PR config, so what the prompt lists can't diverge from what runs. The local /// repo has no project config; only the PR commit does. Without `--yes` the @@ -2671,7 +2671,7 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) fs::create_dir_all(repo.root_path().join(".config")).unwrap(); fs::write( repo.root_path().join(".config/wt.toml"), - r#"post-create = "echo PR-CONFIG-HOOK-RAN > {{ repo_path }}/pr-hook-marker.txt""#, + r#"post-start = "echo PR-CONFIG-HOOK-RAN > {{ repo_path }}/pr-hook-marker.txt""#, ) .unwrap(); repo.run_git(&["add", "pr-file.txt", ".config/wt.toml"]); @@ -2741,7 +2741,7 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("PR-CONFIG-HOOK-RAN"), - "approval prompt should list the PR ref's post-create hook; stderr:\n{stderr}" + "approval prompt should list the PR ref's post-start hook; stderr:\n{stderr}" ); assert!( !output.status.success(), @@ -2752,7 +2752,7 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) "a declined approval must not run the hook" ); - // (b) With `--yes`: the PR's `post-create` runs against the new worktree. + // (b) With `--yes`: the PR's `post-start` runs against the new worktree. let mut cmd = repo.wt_command(); cmd.args(["switch", "pr:42", "--yes"]); configure_mock_cli_env(&mut cmd, &mock_bin); @@ -2768,7 +2768,7 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) assert_eq!( fs::read_to_string(&marker).unwrap().trim(), "PR-CONFIG-HOOK-RAN", - "post-create should run with the PR ref's config" + "post-start should run with the PR ref's config" ); } diff --git a/tests/integration_tests/switch_picker.rs b/tests/integration_tests/switch_picker.rs index 567b224462..68b8210e95 100644 --- a/tests/integration_tests/switch_picker.rs +++ b/tests/integration_tests/switch_picker.rs @@ -1015,10 +1015,10 @@ fn test_switch_picker_create_with_empty_query_fails(mut repo: TestRepo) { /// Picker-create validates hook templates *before* `git worktree add`, mirroring /// the pre-flight that `wt switch --create` already performs. /// -/// Without this, a broken `pre-create` template would let the worktree be +/// Without this, a broken `pre-start` template would let the worktree be /// created, then fail at expansion time — leaving a half-state that blocks /// re-running (the branch already exists). The test commits a syntax-broken -/// `pre-create` to the user config, fires picker-create, asserts that no branch +/// `pre-start` to the user config, fires picker-create, asserts that no branch /// or worktree was created, then fixes the template and confirms re-running /// succeeds — proving the pre-flight aborts cleanly rather than leaving a /// half-created worktree behind. @@ -1027,12 +1027,12 @@ fn test_switch_picker_create_validates_templates_before_worktree(mut repo: TestR repo.remove_fixture_worktrees(); repo.run_git(&["remote", "remove", "origin"]); - // Broken `pre-create` in user config: unbalanced `{{` is a minijinja parse + // Broken `pre-start` in user config: unbalanced `{{` is a minijinja parse // error, so `validate_template` rejects it without needing approvals. // Project config would also trigger the validation path, but it routes // through the approval gate first and would prompt for a TTY response — // user-config hooks are trusted and exercise validation directly. - repo.write_test_config(r#"pre-create = "echo {{ unclosed""#); + repo.write_test_config(r#"pre-start = "echo {{ unclosed""#); let env_vars = repo.test_env_vars(); @@ -1050,7 +1050,7 @@ fn test_switch_picker_create_validates_templates_before_worktree(mut repo: TestR assert_ne!( result.exit_code, 0, - "Expected non-zero exit when pre-create template is broken.\nScreen:\n{}", + "Expected non-zero exit when pre-start template is broken.\nScreen:\n{}", result.screen() ); @@ -1082,7 +1082,7 @@ fn test_switch_picker_create_validates_templates_before_worktree(mut repo: TestR ); // Fix the template and re-run — proves no half-state was left behind. - repo.write_test_config(r#"pre-create = "true""#); + repo.write_test_config(r#"pre-start = "true""#); let result = exec_in_pty_with_input_expectations( wt_bin().to_str().unwrap(), diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 72149dfa27..18e6e323cd 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -36,22 +36,22 @@ fn snapshot_switch(test_name: &str, repo: &TestRepo, args: &[&str]) { } #[rstest] -fn test_user_pre_create_hook_executes(repo: TestRepo) { - // Write user config with pre-create hook (no project config) +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-create] + r#"[pre-start] log = "echo 'USER_PRE_CREATE_RAN' > user_hook_marker.txt" "#, ); - snapshot_switch("user_pre_create_executes", &repo, &["--create", "feature"]); + 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-create hook should have created marker file" + "User pre-start hook should have created marker file" ); let contents = fs::read_to_string(&marker_file).unwrap(); @@ -63,13 +63,13 @@ log = "echo 'USER_PRE_CREATE_RAN' > user_hook_marker.txt" #[rstest] fn test_user_hooks_run_before_project_hooks(repo: TestRepo) { - // Create project config with pre-create hook - repo.write_project_config(r#"pre-create = "echo 'PROJECT_HOOK' >> hook_order.txt""#); + // 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-create] + r#"[pre-start] log = "echo 'USER_HOOK' >> hook_order.txt" "#, ); @@ -99,7 +99,7 @@ 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-create] + r#"[pre-start] setup = "echo 'NO_APPROVAL_NEEDED' > no_approval.txt" "#, ); @@ -117,13 +117,13 @@ setup = "echo 'NO_APPROVAL_NEEDED' > no_approval.txt" #[rstest] fn test_no_hooks_flag_skips_all_hooks(repo: TestRepo) { - // Create project config with pre-create hook - repo.write_project_config(r#"pre-create = "echo 'PROJECT_HOOK' > project_marker.txt""#); + // 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-create] + r#"[pre-start] log = "echo 'USER_HOOK' > user_marker.txt" "#, ); @@ -158,24 +158,24 @@ approved-commands = ["echo 'PROJECT_HOOK' > project_marker.txt"] } #[rstest] -fn test_user_post_create_hook_failure(repo: TestRepo) { +fn test_user_post_start_hook_failure(repo: TestRepo) { // Write user config with failing hook repo.write_test_config( - r#"[pre-create] + r#"[pre-start] failing = "exit 1" "#, ); - // Failing pre-create hook (via deprecated pre-create name) aborts with FailFast. - // The worktree is already created before pre-create runs (it was renamed from - // pre-create), so the worktree exists but the command exits non-zero. - snapshot_switch("user_post_create_failure", &repo, &["--create", "feature"]); + // Failing pre-start hook (via deprecated pre-start name) aborts with FailFast. + // The worktree is already created before pre-start runs (it was renamed from + // pre-start), so the worktree exists but the command exits non-zero. + snapshot_switch("user_post_start_failure", &repo, &["--create", "feature"]); - // Worktree exists (created before pre-create ran) but the command failed + // 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-create ran" + "Worktree should exist — it was created before pre-start ran" ); } @@ -184,15 +184,15 @@ failing = "exit 1" // ============================================================================ #[rstest] -fn test_user_post_create_hook_executes(repo: TestRepo) { - // Write user config with post-create hook (background) +fn test_user_post_start_hook_executes(repo: TestRepo) { + // Write user config with post-start hook (background) repo.write_test_config( - r#"[post-create] + r#"[post-start] bg = "echo 'USER_POST_CREATE_RAN' > user_bg_marker.txt" "#, ); - snapshot_switch("user_post_create_executes", &repo, &["--create", "feature"]); + 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"); @@ -202,7 +202,7 @@ bg = "echo 'USER_POST_CREATE_RAN' > user_bg_marker.txt" let contents = fs::read_to_string(&marker_file).unwrap(); assert!( contents.contains("USER_POST_CREATE_RAN"), - "User post-create hook should have run in background" + "User post-start hook should have run in background" ); } @@ -214,11 +214,11 @@ fn test_background_hook_sees_worktrunk_foreground_env_var(repo: TestRepo) { // `pre-*` hooks run synchronously in the foreground `wt` process, so they // must NOT see the sentinel. repo.write_test_config( - r#"[post-create] -capture-bg = "echo \"bg=$WORKTRUNK_FOREGROUND\" > post_create_env.txt" + r#"[post-start] +capture-bg = "echo \"bg=$WORKTRUNK_FOREGROUND\" > post_start_env.txt" -[pre-create] -capture-fg = "echo \"fg=$WORKTRUNK_FOREGROUND\" > pre_create_env.txt" +[pre-start] +capture-fg = "echo \"fg=$WORKTRUNK_FOREGROUND\" > pre_start_env.txt" "#, ); @@ -230,37 +230,37 @@ capture-fg = "echo \"fg=$WORKTRUNK_FOREGROUND\" > pre_create_env.txt" let worktree_path = repo.root_path().parent().unwrap().join("repo.feature"); - // pre-create wrote its marker synchronously; post-create runs in the + // pre-start wrote its marker synchronously; post-start runs in the // background, so poll for the file. - let bg_marker = worktree_path.join("post_create_env.txt"); + 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-create (background) hook should see WORKTRUNK_FOREGROUND=-1, got {bg:?}" + "post-start (background) hook should see WORKTRUNK_FOREGROUND=-1, got {bg:?}" ); - let fg_marker = worktree_path.join("pre_create_env.txt"); + 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-create (foreground) hook should NOT see WORKTRUNK_FOREGROUND set, got {fg:?}" + "pre-start (foreground) hook should NOT see WORKTRUNK_FOREGROUND set, got {fg:?}" ); } #[rstest] -fn test_user_post_create_skipped_with_no_hooks(repo: TestRepo) { - // Write user config with post-create hook +fn test_user_post_start_skipped_with_no_hooks(repo: TestRepo) { + // Write user config with post-start hook repo.write_test_config( - r#"[post-create] + r#"[post-start] bg = "echo 'USER_BG' > user_bg_marker.txt" "#, ); snapshot_switch( - "user_post_create_skipped_no_hooks", + "user_post_start_skipped_no_hooks", &repo, &["--create", "feature", "--no-hooks"], ); @@ -272,7 +272,7 @@ bg = "echo 'USER_BG' > user_bg_marker.txt" let marker_file = worktree_path.join("user_bg_marker.txt"); assert!( !marker_file.exists(), - "User post-create hook should be skipped with --no-hooks" + "User post-start hook should be skipped with --no-hooks" ); } @@ -1371,7 +1371,7 @@ failing = "exit 1" fn test_user_hook_template_variables(repo: TestRepo) { // Write user config with hook using template variables repo.write_test_config( - r#"[pre-create] + r#"[pre-start] vars = "echo 'repo={{ repo }} branch={{ branch }}' > template_vars.txt" "#, ); @@ -1465,14 +1465,14 @@ fn test_hook_template_variables_from_subdirectory(repo: TestRepo) { /// 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_create(repo: TestRepo) { - // Create project config with unnamed post-create hook - repo.write_project_config(r#"post-create = "echo 'PROJECT_POST_START' > project_bg.txt""#); +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-create = "echo 'USER_POST_START' > user_bg.txt" + r#"post-start = "echo 'USER_POST_START' > user_bg.txt" "#, ); repo.write_test_approvals( @@ -1482,7 +1482,7 @@ approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"] ); snapshot_switch( - "user_and_project_unnamed_post_create", + "user_and_project_unnamed_post_start", &repo, &["--create", "feature"], ); @@ -1496,23 +1496,23 @@ approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"] // Both should have run assert!( worktree_path.join("user_bg.txt").exists(), - "User post-create should have run" + "User post-start should have run" ); assert!( worktree_path.join("project_bg.txt").exists(), - "Project post-create should have run" + "Project post-start should have run" ); } #[rstest] -fn test_user_and_project_post_create_both_run(repo: TestRepo) { - // Create project config with post-create hook - repo.write_project_config(r#"post-create = "echo 'PROJECT_POST_START' > project_bg.txt""#); +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-create] + r#"[post-start] bg = "echo 'USER_POST_START' > user_bg.txt" "#, ); @@ -1523,7 +1523,7 @@ approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"] ); snapshot_switch( - "user_and_project_post_create", + "user_and_project_post_start", &repo, &["--create", "feature"], ); @@ -1537,11 +1537,11 @@ approved-commands = ["echo 'PROJECT_POST_START' > project_bg.txt"] // Both should have run assert!( worktree_path.join("user_bg.txt").exists(), - "User post-create should have run" + "User post-start should have run" ); assert!( worktree_path.join("project_bg.txt").exists(), - "Project post-create should have run" + "Project post-start should have run" ); } @@ -1573,17 +1573,17 @@ fn test_standalone_hook_failure_omits_skip_hint(repo: TestRepo) { } #[rstest] -fn test_standalone_hook_pre_create(repo: TestRepo) { - // Write project config with pre-create hook - repo.write_project_config(r#"pre-create = "echo 'STANDALONE_PRE_CREATE' > hook_ran.txt""#); +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-create", "--yes"]); + cmd.args(["hook", "pre-start", "--yes"]); let output = cmd.output().unwrap(); - assert!(output.status.success(), "wt hook pre-create should succeed"); + 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); @@ -1593,9 +1593,9 @@ fn test_standalone_hook_pre_create(repo: TestRepo) { #[rstest] fn test_standalone_hook_start_alias_runs_silently(repo: TestRepo) { - // `wt hook pre-start` is a deprecated alias for `wt hook pre-create`. It + // `wt hook pre-start` is a deprecated alias for `wt hook pre-start`. It // still runs, and Phase 1 of the rename emits no warning (issue #2838). - repo.write_project_config(r#"pre-create = "echo 'START_ALIAS' > hook_ran.txt""#); + repo.write_project_config(r#"pre-start = "echo 'START_ALIAS' > hook_ran.txt""#); let mut cmd = crate::common::wt_command(); cmd.current_dir(repo.root_path()); @@ -1605,7 +1605,7 @@ fn test_standalone_hook_start_alias_runs_silently(repo: TestRepo) { let output = cmd.output().unwrap(); assert!( output.status.success(), - "wt hook pre-start should succeed (alias for pre-create)" + "wt hook pre-start should succeed (alias for pre-start)" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -1621,38 +1621,35 @@ fn test_standalone_hook_start_alias_runs_silently(repo: TestRepo) { } #[rstest] -fn test_standalone_hook_pre_create_fails_on_failure(repo: TestRepo) { - // pre-create hooks use FailFast like all other pre-* hooks — consistent with +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-create = "exit 1""#); + repo.write_project_config(r#"pre-start = "exit 1""#); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes"]) + .args(["hook", "pre-start", "--yes"]) .output() .unwrap(); assert!( !output.status.success(), - "wt hook pre-create should exit non-zero when the hook fails (fail-fast, like all pre-* hooks)" + "wt hook pre-start should exit non-zero when the hook fails (fail-fast, like all pre-* hooks)" ); } #[rstest] -fn test_standalone_hook_post_create(repo: TestRepo) { - // Write project config with post-create hook - repo.write_project_config(r#"post-create = "echo 'STANDALONE_POST_START' > hook_ran.txt""#); +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-create", "--yes"]); + cmd.args(["hook", "post-start", "--yes"]); let output = cmd.output().unwrap(); - assert!( - output.status.success(), - "wt hook post-create should succeed" - ); + 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"); @@ -1662,21 +1659,21 @@ fn test_standalone_hook_post_create(repo: TestRepo) { } #[rstest] -fn test_standalone_hook_post_create_foreground(repo: TestRepo) { - // Write project config with post-create hook that echoes to both file and stdout +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-create = "echo 'FOREGROUND_POST_START' && echo 'marker' > hook_ran.txt""#, + 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-create", "--yes", "--foreground"]); + cmd.args(["hook", "post-start", "--yes", "--foreground"]); let output = cmd.output().unwrap(); assert!( output.status.success(), - "wt hook post-create --foreground should succeed" + "wt hook post-start --foreground should succeed" ); // With --foreground, marker file should exist immediately (no waiting) @@ -1829,7 +1826,7 @@ fn test_standalone_hook_no_hooks_configured(repo: TestRepo) { let mut cmd = crate::common::wt_command(); cmd.current_dir(repo.root_path()); cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); - cmd.args(["hook", "pre-create", "--yes"]); + cmd.args(["hook", "pre-start", "--yes"]); let output = cmd.output().unwrap(); assert!( @@ -1840,7 +1837,7 @@ fn test_standalone_hook_no_hooks_configured(repo: TestRepo) { let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("No pre-create hooks configured"), + stderr.contains("No pre-start hooks configured"), "stderr should warn about missing hooks, got: {stderr}" ); } @@ -1869,12 +1866,12 @@ fn test_hook_dry_run_shows_expanded_command(repo: TestRepo) { /// --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-create = "echo 'SHOULD_NOT_RUN' > hook_ran.txt""#); + 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-create", "--dry-run"]); + cmd.args(["hook", "pre-start", "--dry-run"]); let output = cmd.output().unwrap(); assert!(output.status.success(), "dry-run should succeed"); @@ -1910,37 +1907,37 @@ fn test_hook_dry_run_named_hooks(repo: TestRepo) { } // ============================================================================ -// Background Hook Execution Tests (post-create, post-switch) +// 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-create = "echo HOOK_OUTPUT_MARKER; exit 1""#); + 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-create", "--yes"]); + 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-create should succeed (spawns in background)" + "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-create/{name}.log`. - let post_create_dir = log_dir + // 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-create"); - let cmd_log = fs::read_dir(&post_create_dir) - .unwrap_or_else(|e| panic!("reading {post_create_dir:?}: {e}")) + .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"); @@ -1962,7 +1959,7 @@ fn test_concurrent_hook_multiple_failures(repo: TestRepo) { // Map configs run as a concurrent group in one pipeline runner, // each command producing its own log file. repo.write_project_config( - r#"[post-create] + r#"[post-start] first = "echo FIRST_OUTPUT" second = "echo SECOND_OUTPUT" "#, @@ -1971,26 +1968,26 @@ 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-create", "--yes"]); + cmd.args(["hook", "post-start", "--yes"]); let output = cmd.output().unwrap(); // Background spawning always succeeds (spawn succeeded) assert!( output.status.success(), - "wt hook post-create should succeed (spawns in background)" + "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-create/{name}.log`. - let post_create_dir = log_dir + // 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-create"); - let log_files: Vec<_> = fs::read_dir(&post_create_dir) - .unwrap_or_else(|e| panic!("reading {post_create_dir:?}: {e}")) + .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(); @@ -2012,25 +2009,25 @@ second = "echo SECOND_OUTPUT" #[rstest] fn test_concurrent_hook_user_and_project(repo: TestRepo) { - // Write user config with post-create hook (using table format for named hook) + // Write user config with post-start hook (using table format for named hook) repo.write_test_config( - r#"[post-create] + r#"[post-start] user = "echo 'USER_HOOK' > user_hook_ran.txt" "#, ); - // Write project config with post-create hook - repo.write_project_config(r#"post-create = "echo 'PROJECT_HOOK' > project_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-create", "--yes"]); + cmd.args(["hook", "post-start", "--yes"]); let output = cmd.output().unwrap(); assert!( output.status.success(), - "wt hook post-create should succeed, stderr: {}", + "wt hook post-start should succeed, stderr: {}", String::from_utf8_lossy(&output.stderr) ); @@ -2074,7 +2071,7 @@ fn test_concurrent_hook_post_switch(repo: TestRepo) { fn test_concurrent_hook_with_name_filter(repo: TestRepo) { // Write project config with multiple named hooks repo.write_project_config( - r#"[post-create] + r#"[post-start] first = "echo 'FIRST' > first.txt" second = "echo 'SECOND' > second.txt" "#, @@ -2084,12 +2081,12 @@ second = "echo 'SECOND' > second.txt" let mut cmd = crate::common::wt_command(); cmd.current_dir(repo.root_path()); cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); - cmd.args(["hook", "post-create", "--yes", "first"]); + cmd.args(["hook", "post-start", "--yes", "first"]); let output = cmd.output().unwrap(); assert!( output.status.success(), - "wt hook post-create --name first should succeed, stderr: {}", + "wt hook post-start --name first should succeed, stderr: {}", String::from_utf8_lossy(&output.stderr) ); @@ -2108,7 +2105,7 @@ second = "echo 'SECOND' > second.txt" fn test_concurrent_hook_invalid_name_filter(repo: TestRepo) { // Write project config with named hooks repo.write_project_config( - r#"[post-create] + r#"[post-start] first = "echo 'FIRST'" "#, ); @@ -2117,12 +2114,12 @@ first = "echo 'FIRST'" let mut cmd = crate::common::wt_command(); cmd.current_dir(repo.root_path()); cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); - cmd.args(["hook", "post-create", "--yes", "nonexistent"]); + cmd.args(["hook", "post-start", "--yes", "nonexistent"]); let output = cmd.output().unwrap(); assert!( !output.status.success(), - "wt hook post-create --name nonexistent should fail" + "wt hook post-start --name nonexistent should fail" ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -2185,7 +2182,7 @@ first = "echo FIRST" 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-create] + r#"[pre-start] test = "echo '{{ target }}' > target_output.txt" "#, ); @@ -2194,7 +2191,7 @@ test = "echo '{{ target }}' > target_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--var", "target=CUSTOM_TARGET", @@ -2216,7 +2213,7 @@ test = "echo '{{ target }}' > target_output.txt" 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-create] + r#"[pre-start] test = "echo '{{ target }} {{ remote }}' > multi_var_output.txt" "#, ); @@ -2225,7 +2222,7 @@ test = "echo '{{ target }} {{ remote }}' > multi_var_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--var", "target=FIRST", @@ -2249,7 +2246,7 @@ test = "echo '{{ target }} {{ remote }}' > multi_var_output.txt" 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-create] + r#"[pre-start] test = "echo '{{ branch }}' > branch_output.txt" "#, ); @@ -2258,7 +2255,7 @@ test = "echo '{{ branch }}' > branch_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--var", "branch=CUSTOM_BRANCH_NAME", @@ -2280,7 +2277,7 @@ test = "echo '{{ branch }}' > branch_output.txt" fn test_var_flag_invalid_format_fails() { // Test that invalid KEY=VALUE format is rejected let output = std::process::Command::new(env!("CARGO_BIN_EXE_wt")) - .args(["hook", "pre-create", "--var", "no_equals_sign"]) + .args(["hook", "pre-start", "--var", "no_equals_sign"]) .output() .expect("Failed to run wt"); @@ -2298,14 +2295,14 @@ 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-create] + r#"[pre-start] test = "echo '{{ custom_var }}' > custom_var_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--var", "custom_var=hello"]) + .args(["hook", "pre-start", "--yes", "--var", "custom_var=hello"]) .output() .expect("Failed to run wt hook"); @@ -2327,7 +2324,7 @@ test = "echo '{{ custom_var }}' > custom_var_output.txt" 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-create] + r#"[pre-start] test = "echo '{{ target }}' > target_output.txt" "#, ); @@ -2336,7 +2333,7 @@ test = "echo '{{ target }}' > target_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--var", "target=FIRST", @@ -2360,14 +2357,14 @@ test = "echo '{{ target }}' > target_output.txt" 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-create] + r#"[pre-start] test = "echo '{{ branch }}' > shorthand_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--branch=SHORTHAND_BRANCH"]) + .args(["hook", "pre-start", "--yes", "--branch=SHORTHAND_BRANCH"]) .output() .expect("Failed to run wt hook"); @@ -2389,7 +2386,7 @@ test = "echo '{{ branch }}' > shorthand_output.txt" 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-create] + r#"[pre-start] test = "echo '{{ branch }} {{ target }}' > mixed_output.txt" "#, ); @@ -2398,7 +2395,7 @@ test = "echo '{{ branch }} {{ target }}' > mixed_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--branch=SHORT", "--var", @@ -2423,14 +2420,14 @@ fn test_var_shorthand_custom_variable(repo: TestRepo) { // injected into the template context, matching alias behavior. Hyphens in // variable names are canonicalized to underscores. repo.write_test_config( - r#"[pre-create] + r#"[pre-start] test = "echo '{{ my_env }}' > custom_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--my-env=staging"]) + .args(["hook", "pre-start", "--yes", "--my-env=staging"]) .output() .expect("Failed to run wt hook"); @@ -2453,14 +2450,14 @@ 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-create] + r#"[pre-start] test = "echo '{{ args }}' > args_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--unused-var=value"]) + .args(["hook", "pre-start", "--yes", "--unused-var=value"]) .output() .expect("Failed to run wt hook"); @@ -2482,14 +2479,14 @@ 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-create] + r#"[pre-start] test = "echo '{{ my_env }}:{{ args }}' > combined_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--my-env=staging"]) + .args(["hook", "pre-start", "--yes", "--my-env=staging"]) .output() .expect("Failed to run wt hook"); @@ -2503,14 +2500,14 @@ test = "echo '{{ my_env }}:{{ args }}' > combined_output.txt" fn test_post_double_dash_forwards_to_args(repo: TestRepo) { // Tokens after `--` forward verbatim into `{{ args }}`. repo.write_test_config( - r#"[pre-create] + r#"[pre-start] test = "echo '{{ args }}' > dashdash_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--", "--fast", "extra"]) + .args(["hook", "pre-start", "--yes", "--", "--fast", "extra"]) .output() .expect("Failed to run wt hook"); @@ -2532,14 +2529,14 @@ 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-create] + r#"[pre-start] test = "echo '{{ my_env }}' > deprecated_output.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--var", "my_env=staging"]) + .args(["hook", "pre-start", "--yes", "--var", "my_env=staging"]) .output() .expect("Failed to run wt hook"); @@ -2560,14 +2557,14 @@ 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-create] + r#"[pre-start] test = "echo '{{ args[0] | default('none') }}:{{ args | length }}' > args_seq.txt" "#, ); let output = repo .wt_command() - .args(["hook", "pre-create", "--yes", "--", "first", "second"]) + .args(["hook", "pre-start", "--yes", "--", "first", "second"]) .output() .expect("Failed to run wt hook"); @@ -2583,7 +2580,7 @@ fn test_mixed_var_shorthand_and_forwarded_args(repo: TestRepo) { // + post-`--` tokens forward — all coexist in one invocation without // cross-contamination. repo.write_test_config( - r#"[pre-create] + r#"[pre-start] test = "echo '{{ my_env }}|{{ override }}|{{ args }}' > mixed_output.txt" "#, ); @@ -2592,7 +2589,7 @@ test = "echo '{{ my_env }}|{{ override }}|{{ args }}' > mixed_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--my-env=prod", "--var", @@ -2644,7 +2641,7 @@ fn test_var_shorthand_does_not_leak_into_hook_show() { 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-create] + r#"[pre-start] test = "echo '{{ main_worktree }}' > alias_output.txt" "#, ); @@ -2653,7 +2650,7 @@ test = "echo '{{ main_worktree }}' > alias_output.txt" .wt_command() .args([ "hook", - "pre-create", + "pre-start", "--yes", "--var", "main_worktree=/custom/path", @@ -2682,7 +2679,7 @@ 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 repo.write_test_config( - r#"[pre-create] + r#"[pre-start] vscode = "echo '1' >> hook_order.txt" claude = "echo '2' >> hook_order.txt" copy = "echo '3' >> hook_order.txt" @@ -3122,12 +3119,12 @@ check = "test -d {{ cwd }} && echo 'cwd_exists=true' > ../cwd_check.txt || echo // ============================================================================ #[rstest] -fn test_user_post_create_pipeline_serial_ordering(repo: TestRepo) { +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-create = [ + r#"post-start = [ "echo SETUP_DONE > pipeline_marker.txt", { bg = "cat pipeline_marker.txt > bg_saw_marker.txt" } ] @@ -3135,7 +3132,7 @@ fn test_user_post_create_pipeline_serial_ordering(repo: TestRepo) { ); snapshot_switch( - "user_post_create_pipeline_ordering", + "user_post_start_pipeline_ordering", &repo, &["--create", "feature"], ); @@ -3152,10 +3149,10 @@ fn test_user_post_create_pipeline_serial_ordering(repo: TestRepo) { } #[rstest] -fn test_user_post_create_pipeline_failure_skips_later_steps(repo: TestRepo) { +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-create = [ + r#"post-start = [ "exit 1", { bg = "echo SHOULD_NOT_RUN > should_not_exist.txt" } ] @@ -3163,7 +3160,7 @@ fn test_user_post_create_pipeline_failure_skips_later_steps(repo: TestRepo) { ); snapshot_switch( - "user_post_create_pipeline_failure", + "user_post_start_pipeline_failure", &repo, &["--create", "feature"], ); @@ -3180,12 +3177,12 @@ fn test_user_post_create_pipeline_failure_skips_later_steps(repo: TestRepo) { } #[rstest] -fn test_user_post_create_pipeline_lazy_vars_foreground(repo: TestRepo) { +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 lazy re-expansion path // in run_hook_with_filter. repo.write_test_config( - r#"post-create = [ + r#"post-start = [ "git config worktrunk.state.main.vars.name '{{ branch | sanitize }}-postgres'", { db = "echo {{ vars.name }} > lazy_expanded.txt" } ] @@ -3197,7 +3194,7 @@ fn test_user_post_create_pipeline_lazy_vars_foreground(repo: TestRepo) { let mut cmd = crate::common::wt_command(); cmd.current_dir(repo.root_path()); cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); - cmd.args(["hook", "post-create", "--yes", "--foreground"]); + cmd.args(["hook", "post-start", "--yes", "--foreground"]); let output = cmd.output().expect("Failed to run foreground hook"); @@ -3223,13 +3220,13 @@ fn test_user_post_create_pipeline_lazy_vars_foreground(repo: TestRepo) { } #[rstest] -fn test_user_post_create_pipeline_lazy_vars_background(repo: TestRepo) { +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-create = [ + r#"post-start = [ "git config worktrunk.state.{{ branch }}.vars.name '{{ branch | sanitize }}-postgres'", { db = "echo {{ vars.name }} > lazy_bg_expanded.txt" } ] @@ -3237,7 +3234,7 @@ fn test_user_post_create_pipeline_lazy_vars_background(repo: TestRepo) { ); snapshot_switch( - "user_post_create_pipeline_lazy_vars_bg", + "user_post_start_pipeline_lazy_vars_bg", &repo, &["--create", "feature"], ); @@ -3254,17 +3251,17 @@ fn test_user_post_create_pipeline_lazy_vars_background(repo: TestRepo) { } #[rstest] -fn test_user_post_create_pipeline_concurrent_all_run(repo: TestRepo) { +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-create = [ + r#"post-start = [ { a = "echo AAA > concurrent_a.txt", b = "echo BBB > concurrent_b.txt" } ] "#, ); snapshot_switch( - "user_post_create_pipeline_concurrent_all", + "user_post_start_pipeline_concurrent_all", &repo, &["--create", "feature"], ); @@ -3288,12 +3285,12 @@ fn test_user_post_create_pipeline_concurrent_all_run(repo: TestRepo) { } #[rstest] -fn test_user_post_create_pipeline_concurrent_partial_failure(repo: TestRepo) { +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-create = [ + r#"post-start = [ { fail = "exit 1", ok = "echo SURVIVED > concurrent_survivor.txt" }, "echo SHOULD_NOT_RUN > after_concurrent.txt" ] @@ -3301,7 +3298,7 @@ fn test_user_post_create_pipeline_concurrent_partial_failure(repo: TestRepo) { ); snapshot_switch( - "user_post_create_pipeline_concurrent_failure", + "user_post_start_pipeline_concurrent_failure", &repo, &["--create", "feature"], ); @@ -3327,13 +3324,13 @@ fn test_user_post_create_pipeline_concurrent_partial_failure(repo: TestRepo) { } #[rstest] -fn test_user_post_create_pipeline_shell_escaping(repo: TestRepo) { +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-create = [ + r#"post-start = [ "git config worktrunk.state.{{ branch }}.vars.tricky 'hello world $HOME \"quotes\"'", { check = "echo {{ vars.tricky }} > escaped_output.txt" } ] @@ -3344,7 +3341,7 @@ fn test_user_post_create_pipeline_shell_escaping(repo: TestRepo) { let mut cmd = crate::common::wt_command(); cmd.current_dir(repo.root_path()); cmd.env("WORKTRUNK_CONFIG_PATH", repo.test_config_path()); - cmd.args(["hook", "post-create", "--yes", "--foreground"]); + cmd.args(["hook", "post-start", "--yes", "--foreground"]); let output = cmd.output().expect("Failed to run foreground hook"); assert!( @@ -3377,12 +3374,12 @@ fn test_user_post_create_pipeline_shell_escaping(repo: TestRepo) { // ============================================================================ #[rstest] -fn test_user_post_create_pipeline_hook_name_per_step(repo: TestRepo) { +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-create = [ + r#"post-start = [ { step_one = "echo {{ hook_name }} > step_one_name.txt" }, { step_two = "echo {{ hook_name }} > step_two_name.txt" } ] @@ -3390,7 +3387,7 @@ fn test_user_post_create_pipeline_hook_name_per_step(repo: TestRepo) { ); snapshot_switch( - "user_post_create_pipeline_hook_name_per_step", + "user_post_start_pipeline_hook_name_per_step", &repo, &["--create", "feature"], ); @@ -3495,12 +3492,12 @@ fn test_user_post_remove_pipeline_serial_ordering(mut repo: TestRepo) { #[rstest] fn test_standalone_hook_name_filtered_lazy_template(repo: TestRepo) { // A pipeline step that uses {{ vars.X }} should expand correctly when - // name-filtered via `wt hook post-create db`. Before the fix, the flat + // 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-create = [ + r#"post-start = [ { setup = "echo setup" }, { db = "echo {{ vars.name }} > lazy_filtered.txt" } ] @@ -3515,7 +3512,7 @@ fn test_standalone_hook_name_filtered_lazy_template(repo: TestRepo) { // since name filtering bypasses the pipeline runner. let settings = setup_snapshot_settings(&repo); settings.bind(|| { - let mut cmd = make_snapshot_cmd(&repo, "hook", &["post-create", "db"], None); + let mut cmd = make_snapshot_cmd(&repo, "hook", &["post-start", "db"], None); assert_cmd_snapshot!("standalone_hook_name_filtered_lazy_template", cmd); }); @@ -3570,7 +3567,7 @@ fn test_foreground_hook_passes_directive_file(repo: TestRepo) { // installed" hint instead. repo.write_test_config(&format!( r#" -[pre-create] +[pre-start] setup = "'{wt_toml}' switch --create hook-created --no-hooks" "#, )); @@ -3579,8 +3576,8 @@ setup = "'{wt_toml}' switch --create hook-created --no-hooks" let mut cmd = repo.wt_command(); configure_directive_files(&mut cmd, &cd_path, &exec_path); - // Run the pre-create hook manually in foreground - cmd.args(["hook", "pre-create", "setup"]); + // Run the pre-start hook manually in foreground + cmd.args(["hook", "pre-start", "setup"]); let output = cmd.output().unwrap(); assert!( @@ -3728,12 +3725,12 @@ noop = "true" /// 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_create_verbose_shows_per_hook_output` doesn't hit. +/// `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-create = "echo user-hook""#); - repo.write_project_config(r#"post-create = "echo project-hook""#); - repo.commit("add post-create"); + 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"] diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_accept.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_accept.snap index 371caeb766..e1af7a28db 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_accept.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_accept.snap @@ -3,11 +3,11 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'test command' ❯ Allow and remember? [y/N] y -◎ Running pre-create project hook @ _REPO_.test-approve +◎ Running pre-start project hook @ _REPO_.test-approve   echo 'test command' test command ✓ Created branch test-approve from main and worktree @ _REPO_.test-approve diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_decline.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_decline.snap index 31abb5fc35..8b92d82383 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_decline.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_decline.snap @@ -3,7 +3,7 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'test command' ❯ Allow and remember? [y/N] n diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_accept.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_accept.snap index 433ad17132..2fecbf9f12 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_accept.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_accept.snap @@ -3,19 +3,19 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 2 commands: -○ pre-create first: +○ pre-start first:   echo 'First command' -○ pre-create third: +○ pre-start third:   echo 'Third command' ❯ Allow and remember? [y/N] y -◎ Running pre-create project:first @ _REPO_.test-mixed-accept +◎ Running pre-start project:first @ _REPO_.test-mixed-accept   echo 'First command' First command -◎ Running pre-create project:second @ _REPO_.test-mixed-accept +◎ Running pre-start project:second @ _REPO_.test-mixed-accept   echo 'Second command' Second command -◎ Running pre-create project:third @ _REPO_.test-mixed-accept +◎ Running pre-start project:third @ _REPO_.test-mixed-accept   echo 'Third command' Third command ✓ Created branch test-mixed-accept from main and worktree @ _REPO_.test-mixed-accept diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_decline.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_decline.snap index f0df94aaa7..acc3810ccb 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_decline.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_mixed_approved_unapproved_decline.snap @@ -3,9 +3,9 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 2 commands: -○ pre-create first: +○ pre-start first:   echo 'First command' -○ pre-create third: +○ pre-start third:   echo 'Third command' ❯ Allow and remember? [y/N] n diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_multiple_commands.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_multiple_commands.snap index 8708212d8b..b1f00710b4 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_multiple_commands.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_multiple_commands.snap @@ -3,21 +3,21 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 3 commands: -○ pre-create first: +○ pre-start first:   echo 'First command' -○ pre-create second: +○ pre-start second:   echo 'Second command' -○ pre-create third: +○ pre-start third:   echo 'Third command' ❯ Allow and remember? [y/N] y -◎ Running pre-create project:first @ _REPO_.test-multi +◎ Running pre-start project:first @ _REPO_.test-multi   echo 'First command' First command -◎ Running pre-create project:second @ _REPO_.test-multi +◎ Running pre-start project:second @ _REPO_.test-multi   echo 'Second command' Second command -◎ Running pre-create project:third @ _REPO_.test-multi +◎ Running pre-start project:third @ _REPO_.test-multi   echo 'Third command' Third command ✓ Created branch test-multi from main and worktree @ _REPO_.test-multi diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_named_commands.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_named_commands.snap index d15a15dbb2..70df985299 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_named_commands.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_named_commands.snap @@ -3,21 +3,21 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 3 commands: -○ pre-create install: +○ pre-start install:   echo 'Installing dependencies...' -○ pre-create build: +○ pre-start build:   echo 'Building project...' -○ pre-create test: +○ pre-start test:   echo 'Running tests...' ❯ Allow and remember? [y/N] y -◎ Running pre-create project:install @ _REPO_.test-named +◎ Running pre-start project:install @ _REPO_.test-named   echo 'Installing dependencies...' Installing dependencies... -◎ Running pre-create project:build @ _REPO_.test-named +◎ Running pre-start project:build @ _REPO_.test-named   echo 'Building project...' Building project... -◎ Running pre-create project:test @ _REPO_.test-named +◎ Running pre-start project:test @ _REPO_.test-named   echo 'Running tests...' Running tests... ✓ Created branch test-named from main and worktree @ _REPO_.test-named diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap index 1b28669e3a..38062520e2 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap @@ -3,13 +3,13 @@ source: tests/integration_tests/approval_pty.rs expression: "&output" --- ▲ repo needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'test command' ❯ Allow and remember? [y/N] y ▲ Failed to save command approval: Failed to open lock file: Permission denied (os error 13) ↳ Approval will be requested again next time. -◎ Running pre-create project hook @ _REPO_.test-permission +◎ Running pre-start project hook @ _REPO_.test-permission   echo 'test command' test command ✓ Created branch test-permission from main and worktree @ _REPO_.test-permission diff --git a/tests/snapshots/integration__integration_tests__approval_ui__already_approved_skip_prompt.snap b/tests/snapshots/integration__integration_tests__approval_ui__already_approved_skip_prompt.snap index e09740dcfe..4496d4c830 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__already_approved_skip_prompt.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__already_approved_skip_prompt.snap @@ -57,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.test-approved +◎ Running pre-start project hook @ _REPO_.test-approved   echo 'approved' > output.txt ✓ Created branch test-approved from main and worktree @ _REPO_.test-approved ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__approval_ui__approval_fails_in_non_tty.snap b/tests/snapshots/integration__integration_tests__approval_ui__approval_fails_in_non_tty.snap index 187b82a259..cc1b1bca23 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__approval_fails_in_non_tty.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__approval_fails_in_non_tty.snap @@ -58,7 +58,7 @@ exit_code: 1 ----- stderr ----- ▲ origin needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'test command' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approval_ui__approval_mixed_approved_unapproved.snap b/tests/snapshots/integration__integration_tests__approval_ui__approval_mixed_approved_unapproved.snap index 195234e6a5..3b4fb5eae4 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__approval_mixed_approved_unapproved.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__approval_mixed_approved_unapproved.snap @@ -6,12 +6,12 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update ▲ repo needs approval to execute 2 commands: -○ pre-create first: +○ pre-start first:   echo 'First command' -○ pre-create third: +○ pre-start third:   echo 'Third command' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approval_ui__approval_multiple_commands.snap b/tests/snapshots/integration__integration_tests__approval_ui__approval_multiple_commands.snap index bdd14b62db..7531b3d15c 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__approval_multiple_commands.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__approval_multiple_commands.snap @@ -6,16 +6,16 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update ▲ origin needs approval to execute 4 commands: -○ pre-create branch: +○ pre-start branch:   echo 'Branch: {{ branch }}' -○ pre-create worktree: +○ pre-start worktree:   echo 'Worktree: {{ worktree_path }}' -○ pre-create repo: +○ pre-start repo:   echo 'Repo: {{ repo }}' -○ pre-create pwd: +○ pre-start pwd:   cd {{ worktree_path }} && pwd ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approval_ui__approval_named_commands.snap b/tests/snapshots/integration__integration_tests__approval_ui__approval_named_commands.snap index fbe3128678..2cd78fd1fe 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__approval_named_commands.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__approval_named_commands.snap @@ -6,14 +6,14 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update ▲ origin needs approval to execute 3 commands: -○ pre-create install: +○ pre-start install:   echo 'Installing dependencies...' -○ pre-create build: +○ pre-start build:   echo 'Building project...' -○ pre-create test: +○ pre-start test:   echo 'Running tests...' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approval_ui__approval_single_command.snap b/tests/snapshots/integration__integration_tests__approval_ui__approval_single_command.snap index fe8a4037df..35034f56cd 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__approval_single_command.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__approval_single_command.snap @@ -7,7 +7,7 @@ exit_code: 1 ----- stderr ----- ▲ origin needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'Worktree path: {{ worktree_path }}' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approval_ui__decline_approval_skips_only_unapproved.snap b/tests/snapshots/integration__integration_tests__approval_ui__decline_approval_skips_only_unapproved.snap index 195234e6a5..3b4fb5eae4 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__decline_approval_skips_only_unapproved.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__decline_approval_skips_only_unapproved.snap @@ -6,12 +6,12 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update ▲ repo needs approval to execute 2 commands: -○ pre-create first: +○ pre-start first:   echo 'First command' -○ pre-create third: +○ pre-start third:   echo 'Third command' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approval_ui__yes_bypasses_tty_check.snap b/tests/snapshots/integration__integration_tests__approval_ui__yes_bypasses_tty_check.snap index c14744e1eb..8ff28129eb 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__yes_bypasses_tty_check.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__yes_bypasses_tty_check.snap @@ -58,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.test-yes-tty +◎ Running pre-start project hook @ _REPO_.test-yes-tty   echo 'test command' test command ✓ Created branch test-yes-tty from main and worktree @ _REPO_.test-yes-tty diff --git a/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_first_run.snap b/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_first_run.snap index da81989bc0..70368040d8 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_first_run.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_first_run.snap @@ -58,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.test-yes +◎ Running pre-start project hook @ _REPO_.test-yes   echo 'test command' > output.txt ✓ Created branch test-yes from main and worktree @ _REPO_.test-yes ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_second_run.snap b/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_second_run.snap index a779096092..ff5aa65a1b 100644 --- a/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_second_run.snap +++ b/tests/snapshots/integration__integration_tests__approval_ui__yes_does_not_save_approvals_second_run.snap @@ -7,7 +7,7 @@ exit_code: 1 ----- stderr ----- ▲ origin needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'test command' > output.txt ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approvals__add_approvals_all_none_approved.snap b/tests/snapshots/integration__integration_tests__approvals__add_approvals_all_none_approved.snap index 9031e34c23..40dfe8248e 100644 --- a/tests/snapshots/integration__integration_tests__approvals__add_approvals_all_none_approved.snap +++ b/tests/snapshots/integration__integration_tests__approvals__add_approvals_all_none_approved.snap @@ -59,7 +59,7 @@ exit_code: 1 ----- stderr ----- ▲ origin needs approval to execute 1 command: -○ pre-create: +○ pre-start:   echo 'test' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__approvals__add_approvals_bare_repo_config_in_primary_worktree.snap b/tests/snapshots/integration__integration_tests__approvals__add_approvals_bare_repo_config_in_primary_worktree.snap index 0cdc2eb3f8..66758aae68 100644 --- a/tests/snapshots/integration__integration_tests__approvals__add_approvals_bare_repo_config_in_primary_worktree.snap +++ b/tests/snapshots/integration__integration_tests__approvals__add_approvals_bare_repo_config_in_primary_worktree.snap @@ -52,7 +52,7 @@ exit_code: 1 ----- stderr ----- ▲ repo needs approval to execute 1 command: -○ pre-create: +○ pre-start: echo 'hello' ✗ Cannot prompt for approval in non-interactive environment ↳ To skip prompts in CI/CD, add --yes; to pre-approve commands, run wt config approvals add diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_deprecation_details.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_deprecation_details.snap index 8b4d5a73b7..53cab2de16 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_deprecation_details.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_deprecation_details.snap @@ -60,14 +60,14 @@ exit_code: 0 ↳ To apply: wt config update ○ Proposed diff:   diff --git a/config.toml/current b/config.toml/migrated -  index da40ce6..12d38d2 100644 +  index dee4883..5eef8a6 100644   --- a/config.toml/current   +++ b/config.toml/migrated   @@ -1,2 +1,2 @@   -worktree-path = "../{{ main_worktree }}.{{ branch }}" -  -pre-create = "ln -sf {{ repo_root }}/node_modules" +  -pre-start = "ln -sf {{ repo_root }}/node_modules"   +worktree-path = "../{{ repo }}.{{ branch }}" -  +pre-create = "ln -sf {{ repo_path }}/node_modules" +  +pre-start = "ln -sf {{ repo_path }}/node_modules" ↳ Optional system config not found @ /etc/xdg/worktrunk/config.toml PROJECT CONFIG @ _REPO_/.config/wt.toml diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_pre_hook_table_form_deprecation.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_pre_hook_table_form_deprecation.snap index ffc037a6a0..3034ed8e67 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_pre_hook_table_form_deprecation.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_pre_hook_table_form_deprecation.snap @@ -59,11 +59,11 @@ exit_code: 0 PROJECT CONFIG @ _REPO_/.config/wt.toml ○ Identifier: ../origin -▲ Project config: table form for pre-create, pre-merge is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start, pre-merge is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To apply: wt config update ○ Proposed diff:   diff --git a/wt.toml/current b/wt.toml/migrated -  index a85327e..319e745 100644 +  index 3a61835..dc2a222 100644   --- a/wt.toml/current   +++ b/wt.toml/migrated   @@ -1,7 +1,11 @@ @@ -74,11 +74,11 @@ exit_code: 0   +[[pre-merge]]   lint = "cargo clippy"    -  -[pre-create] -  +[[pre-create]] +  -[pre-start] +  +[[pre-start]]   install = "npm ci"   + -  +[[pre-create]] +  +[[pre-start]]   env = "cp .env.example .env" SHELL INTEGRATION diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap index 176867c787..27cf558194 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap @@ -55,15 +55,7 @@ success: true exit_code: 0 ----- stdout ----- USER CONFIG @ ~/.config/worktrunk/config.toml -↳ To apply: wt config update -○ Proposed diff: -  diff --git a/config.toml/current b/config.toml/migrated -  index 9969ecd..a536bc0 100644 -  --- a/config.toml/current -  +++ b/config.toml/migrated -  @@ -1 +1 @@ -  -pre-start = "npm install" -  +pre-create = "npm install" +  pre-start = "npm install" ↳ Optional system config not found @ /etc/xdg/worktrunk/config.toml PROJECT CONFIG @ _REPO_/.config/wt.toml diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_with_project_config.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_with_project_config.snap index ec94aa5539..6e50159652 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_with_project_config.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_with_project_config.snap @@ -60,9 +60,9 @@ exit_code: 0 PROJECT CONFIG @ _REPO_/.config/wt.toml ○ Identifier: ../origin -  pre-create = "npm install" +  pre-start = "npm install"   -  [post-create] +  [post-start]   server = "npm run dev" SHELL INTEGRATION diff --git a/tests/snapshots/integration__integration_tests__config_show__config_update_applies_template_var_migration.snap b/tests/snapshots/integration__integration_tests__config_show__config_update_applies_template_var_migration.snap index fefaf88266..b61ed41f32 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_update_applies_template_var_migration.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_update_applies_template_var_migration.snap @@ -62,12 +62,12 @@ exit_code: 0 ▲ User config: template variable main_worktree is deprecated in favor of repo ○ Proposed diff:   diff --git a/test-config.toml/current b/test-config.toml/migrated -  index 61ddfbb..0a92575 100644 +  index 7cc556b..b3535c5 100644   --- a/test-config.toml/current   +++ b/test-config.toml/migrated   @@ -1,2 +1,2 @@   -worktree-path = "../{{ main_worktree }}.{{ branch }}" -  -pre-create = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules" +  -pre-start = "ln -sf {{ repo_root }}/node_modules {{ worktree }}/node_modules"   +worktree-path = "../{{ repo }}.{{ branch }}" -  +pre-create = "ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modules" +  +pre-start = "ln -sf {{ repo_path }}/node_modules {{ worktree_path }}/node_modules" ✓ Updated user config diff --git a/tests/snapshots/integration__integration_tests__config_state__logs_get_json_with_files.snap b/tests/snapshots/integration__integration_tests__config_state__logs_get_json_with_files.snap index b4be5ddd04..801b6c6155 100644 --- a/tests/snapshots/integration__integration_tests__config_state__logs_get_json_with_files.snap +++ b/tests/snapshots/integration__integration_tests__config_state__logs_get_json_with_files.snap @@ -22,11 +22,11 @@ expression: "String::from_utf8_lossy(&output.stdout)" "hook_output": [ { "branch": "main", - "file": "main/user/post-create/server.log", - "hook_type": "post-create", + "file": "main/user/post-start/server.log", + "hook_type": "post-start", "modified_at": "", "name": "server", - "path": "_REPO_/.git/wt/logs/main/user/post-create/server.log", + "path": "_REPO_/.git/wt/logs/main/user/post-start/server.log", "size": "", "source": "user" } diff --git a/tests/snapshots/integration__integration_tests__config_state__state_get_comprehensive.snap b/tests/snapshots/integration__integration_tests__config_state__state_get_comprehensive.snap index 2b8cda83f8..ba876b2ccc 100644 --- a/tests/snapshots/integration__integration_tests__config_state__state_get_comprehensive.snap +++ b/tests/snapshots/integration__integration_tests__config_state__state_get_comprehensive.snap @@ -40,10 +40,10 @@ expression: "String::from_utf8_lossy(&output.stdout)"   (none) HOOK OUTPUT @ - File Size Age - ──────────────────────────────── ──── ────── - bugfix/internal/remove.log 13B future - feature/user/post-create/npm.log 10B future + File Size Age + ─────────────────────────────── ──── ────── + bugfix/internal/remove.log 13B future + feature/user/post-start/npm.log 10B future DIAGNOSTIC @   (none) diff --git a/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_accept.snap b/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_accept.snap index 9868072087..4c275aeb66 100644 --- a/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_accept.snap +++ b/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_accept.snap @@ -6,14 +6,14 @@ expression: "&output" ▲ User config: template variable main_worktree is deprecated in favor of repo ○ Proposed diff:   diff --git a/test-config.toml/current b/test-config.toml/migrated -  index da40ce6..12d38d2 100644 +  index dee4883..5eef8a6 100644   --- a/test-config.toml/current   +++ b/test-config.toml/migrated   @@ -1,2 +1,2 @@   -worktree-path = "../{{ main_worktree }}.{{ branch }}" -  -pre-create = "ln -sf {{ repo_root }}/node_modules" +  -pre-start = "ln -sf {{ repo_root }}/node_modules"   +worktree-path = "../{{ repo }}.{{ branch }}" -  +pre-create = "ln -sf {{ repo_path }}/node_modules" +  +pre-start = "ln -sf {{ repo_path }}/node_modules" ❯ Apply updates? [y/N/?] y ✓ Updated user config diff --git a/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_decline.snap b/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_decline.snap index c5ec3f0cb5..b8bb8e69ec 100644 --- a/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_decline.snap +++ b/tests/snapshots/integration__integration_tests__config_update_pty__config_update_prompt_decline.snap @@ -6,14 +6,14 @@ expression: "&output" ▲ User config: template variable main_worktree is deprecated in favor of repo ○ Proposed diff:   diff --git a/test-config.toml/current b/test-config.toml/migrated -  index da40ce6..12d38d2 100644 +  index dee4883..5eef8a6 100644   --- a/test-config.toml/current   +++ b/test-config.toml/migrated   @@ -1,2 +1,2 @@   -worktree-path = "../{{ main_worktree }}.{{ branch }}" -  -pre-create = "ln -sf {{ repo_root }}/node_modules" +  -pre-start = "ln -sf {{ repo_root }}/node_modules"   +worktree-path = "../{{ repo }}.{{ branch }}" -  +pre-create = "ln -sf {{ repo_path }}/node_modules" +  +pre-start = "ln -sf {{ repo_path }}/node_modules" ❯ Apply updates? [y/N/?] n ○ Update cancelled diff --git a/tests/snapshots/integration__integration_tests__help__help_config_create.snap b/tests/snapshots/integration__integration_tests__help__help_config_create.snap index dc79095b3a..36b1411942 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_create.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_create.snap @@ -236,26 +236,26 @@ Creates ~/.config/worktrunk/config.toml with the following content:   # list.full = true   # merge.squash = false   # remove.delete-branch = false -  # pre-create.env = "cp .env.example .env" +  # pre-start.env = "cp .env.example .env"   # step.copy-ignored.exclude = [".repo-local-cache/"]   # aliases.deploy = "make deploy BRANCH={{ branch }}"   # -  # Hooks support all three hook forms (https://worktrunk.dev/hook/#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-create.server = "..."` and a `[projects."github.com/user/repo".post-create]` table the same way: +  # Hooks support all three hook forms (https://worktrunk.dev/hook/#hook-forms). A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats `projects."github.com/user/repo".post-start.server = "..."` and a `[projects."github.com/user/repo".post-start]` table the same way:   #   # # Single command   # [projects."github.com/user/repo"] -  # post-create = "mise trust" +  # post-start = "mise trust"   #   # # Multiple commands, running concurrently -  # [projects."github.com/user/repo".post-create] +  # [projects."github.com/user/repo".post-start]   # mise = "mise trust"   # server = "npm run dev"   #   # # Pipeline: steps run in sequence -  # [[projects."github.com/user/repo".post-create]] +  # [[projects."github.com/user/repo".post-start]]   # install = "npm ci"   # -  # [[projects."github.com/user/repo".post-create]] +  # [[projects."github.com/user/repo".post-start]]   # build = "npm run build"   # server = "npm run dev"   # @@ -395,8 +395,8 @@ With --project, creates .config/wt.toml in the current repositor   #   # Project hooks apply to this repository only. See `wt hook` (https://worktrunk.dev/hook/) for hook types, execution order, and examples.   # -  # pre-create = "npm ci" -  # post-create = "npm run dev" +  # pre-start = "npm ci" +  # post-start = "npm run dev"   # pre-merge = "npm test"   #   # ## Dev server URL diff --git a/tests/snapshots/integration__integration_tests__help__help_config_long.snap b/tests/snapshots/integration__integration_tests__help__help_config_long.snap index 7496c09ad4..0b4f210b19 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -108,7 +108,7 @@ Organizations can also deploy a system-wide config file for shared defaults — Project config — shared team settings:   # .config/wt.toml -  [pre-create] +  [pre-start]   deps = "npm ci"     [pre-merge] @@ -284,26 +284,26 @@ Scalar values (like worktree-path) replace the global value; everything   list.full = true   merge.squash = false   remove.delete-branch = false -  pre-create.env = "cp .env.example .env" +  pre-start.env = "cp .env.example .env"   step.copy-ignored.exclude = [".repo-local-cache/"]   aliases.deploy = "make deploy BRANCH={{ branch }}" -Hooks support all three hook forms. A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats projects."github.com/user/repo".post-create.server = "..." and a [projects."github.com/user/repo".post-create] table the same way: +Hooks support all three hook forms. A table runs multiple commands concurrently; an array-of-tables pipeline runs steps in sequence. The dotted-key examples below are equivalent to the table forms — TOML treats projects."github.com/user/repo".post-start.server = "..." and a [projects."github.com/user/repo".post-start] table the same way:   # Single command   [projects."github.com/user/repo"] -  post-create = "mise trust" +  post-start = "mise trust"     # Multiple commands, running concurrently -  [projects."github.com/user/repo".post-create] +  [projects."github.com/user/repo".post-start]   mise = "mise trust"   server = "npm run dev"     # Pipeline: steps run in sequence -  [[projects."github.com/user/repo".post-create]] +  [[projects."github.com/user/repo".post-start]]   install = "npm ci"   -  [[projects."github.com/user/repo".post-create]] +  [[projects."github.com/user/repo".post-start]]   build = "npm run build"   server = "npm run dev" @@ -434,8 +434,8 @@ To create a starter file with commented-out examples, run wt config create - Project hooks apply to this repository only. See wt hook for hook types, execution order, and examples. -  pre-create = "npm ci" -  post-create = "npm run dev" +  pre-start = "npm ci" +  post-start = "npm run dev"   pre-merge = "npm test" Dev server URL diff --git a/tests/snapshots/integration__integration_tests__help__help_config_state_logs.snap b/tests/snapshots/integration__integration_tests__help__help_config_state_logs.snap index 0b9b1b6675..d7b0058e63 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_state_logs.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_state_logs.snap @@ -98,7 +98,7 @@ Hook output lives in per-branch subtrees under .git/wt/logs/{branch}/: Background hooks {branch}/{source}/{hook-type}/{name}.log Background removal {branch}/internal/remove.log -All post-* hooks (post-create, post-switch, post-commit, post-merge) run in the background and produce log files. Source is user or project. Branch and hook names are sanitized for filesystem safety (invalid characters → -; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with wt config state logs clear. +All post-* hooks (post-start, post-switch, post-commit, post-merge) run in the background and produce log files. Source is user or project. Branch and hook names are sanitized for filesystem safety (invalid characters → -; short collision-avoidance hash appended). Same operation on same branch overwrites the previous log. Removing a branch clears its subtree; orphans from deleted branches can be swept with wt config state logs clear. Diagnostic files @@ -126,8 +126,8 @@ List all log files: Query the command log:   tail -5 .git/wt/logs/commands.jsonl | jq . -Path to one hook log (e.g. the post-create server hook for the current branch): -  wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-create" and (.name | startswith("server"))) | .path' +Path to one hook log (e.g. the post-start server hook for the current branch): +  wt config state logs --format=json | jq -r '.hook_output[] | select(.source == "user" and .hook_type == "post-start" and (.name | startswith("server"))) | .path' Logs for a specific branch:   wt config state logs --format=json | jq '.hook_output[] | select(.branch | startswith("feature"))' diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index 7eeb2bf4bb..8ac22d88f2 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -144,8 +144,8 @@ If the branch already has a worktree, wt switch changes directories to i 1. Runs pre-switch hooks, blocking until complete 2. Creates worktree at configured path 3. Switches to new directory -4. Runs pre-create hooks, blocking until complete -5. Spawns post-create and post-switch hooks in the background +4. Runs pre-start hooks, blocking until complete +5. Spawns post-start and post-switch hooks in the background   wt switch feature # Existing branch → creates worktree   wt switch --create feature # New branch and worktree diff --git a/tests/snapshots/integration__integration_tests__help__nested_subcommand_hook_pre_create.snap b/tests/snapshots/integration__integration_tests__help__nested_subcommand_hook_pre_start.snap similarity index 89% rename from tests/snapshots/integration__integration_tests__help__nested_subcommand_hook_pre_create.snap rename to tests/snapshots/integration__integration_tests__help__nested_subcommand_hook_pre_start.snap index 1783bdd19c..abf2e45388 100644 --- a/tests/snapshots/integration__integration_tests__help__nested_subcommand_hook_pre_create.snap +++ b/tests/snapshots/integration__integration_tests__help__nested_subcommand_hook_pre_start.snap @@ -3,7 +3,7 @@ source: tests/integration_tests/help.rs info: program: wt args: - - pre-create + - pre-start env: CLICOLOR_FORCE: "1" COLUMNS: "500" @@ -38,10 +38,10 @@ exit_code: 2 ----- stdout ----- ----- stderr ----- -error: unrecognized subcommand 'pre-create' +error: unrecognized subcommand 'pre-start' Usage: wt [OPTIONS] [COMMAND] For more information, try '--help'. - tip: perhaps wt hook pre-create? + tip: perhaps wt hook pre-start? diff --git a/tests/snapshots/integration__integration_tests__hook_show__hook_show_merges_user_project_hooks.snap b/tests/snapshots/integration__integration_tests__hook_show__hook_show_merges_user_project_hooks.snap index 9cbc556d4c..179ca8eeaa 100644 --- a/tests/snapshots/integration__integration_tests__hook_show__hook_show_merges_user_project_hooks.snap +++ b/tests/snapshots/integration__integration_tests__hook_show__hook_show_merges_user_project_hooks.snap @@ -55,9 +55,9 @@ success: true exit_code: 0 ----- stdout ----- USER HOOKS @ ~/.config/worktrunk/config.toml -○ post-create global-hook: +○ post-start global-hook:   echo global -○ post-create project-hook: +○ post-start project-hook:   echo per-project PROJECT HOOKS @ _REPO_/.config/wt.toml diff --git a/tests/snapshots/integration__integration_tests__hook_show__hook_show_with_both_configs.snap b/tests/snapshots/integration__integration_tests__hook_show__hook_show_with_both_configs.snap index b97333a574..338e557d4a 100644 --- a/tests/snapshots/integration__integration_tests__hook_show__hook_show_with_both_configs.snap +++ b/tests/snapshots/integration__integration_tests__hook_show__hook_show_with_both_configs.snap @@ -58,7 +58,7 @@ exit_code: 0 ↳ (none configured) PROJECT HOOKS @ _REPO_/.config/wt.toml -❯ post-create deps: (requires approval) +❯ post-start deps: (requires approval)   npm install ❯ pre-merge build: (requires approval)   cargo build diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_mixed_user_pipeline_project_flat.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_mixed_user_pipeline_project_flat.snap deleted file mode 100644 index 76dda7e31a..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_mixed_user_pipeline_project_flat.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: …, user_bg (user); proj (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiple_background.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiple_background.snap deleted file mode 100644 index 2be275ebe3..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiple_background.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: task1 & task2 (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_pipeline_template_vars.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_pipeline_template_vars.snap deleted file mode 100644 index 45960b19e7..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_pipeline_template_vars.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: …, check (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_project_pipeline.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_project_pipeline.snap deleted file mode 100644 index 3342dc060c..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_project_pipeline.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: …, task1 & task2 (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_separate_logs.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_separate_logs.snap deleted file mode 100644 index aa5572e033..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_separate_logs.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: task1 & task2 & task3 (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_background.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_background.snap deleted file mode 100644 index 4a6248e647..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_background.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_target_variable.snap b/tests/snapshots/integration__integration_tests__post_create_commands__post_create_target_variable.snap deleted file mode 100644 index 4a6248e647..0000000000 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_target_variable.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/post_create_commands.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__both_pre_and_post_create.snap b/tests/snapshots/integration__integration_tests__post_start_commands__both_pre_and_post_start.snap similarity index 89% rename from tests/snapshots/integration__integration_tests__post_create_commands__both_pre_and_post_create.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__both_pre_and_post_start.snap index 1fafa17549..39c9737c7a 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__both_pre_and_post_create.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__both_pre_and_post_start.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -57,10 +57,10 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   echo 'Setup done' > setup.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: server (project) @ _REPO_.feature +◎ Running post-start: server (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__execute_with_post_create.snap b/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap similarity index 94% rename from tests/snapshots/integration__integration_tests__post_create_commands__execute_with_post_create.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap index bc7ca87752..70407b37ee 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__execute_with_post_create.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -63,6 +63,6 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature +◎ Running post-start: project @ _REPO_.feature ◎ Executing (--execute) @ _REPO_.feature:   echo 'Execute flag' > execute.txt diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__invalid_toml.snap b/tests/snapshots/integration__integration_tests__post_start_commands__invalid_toml.snap similarity index 90% rename from tests/snapshots/integration__integration_tests__post_create_commands__invalid_toml.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__invalid_toml.snap index 0488ee4d68..af7213541f 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__invalid_toml.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__invalid_toml.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -59,8 +59,8 @@ exit_code: 1 ----- stderr ----- ✗ Failed to load project config   Project config at _REPO_/.config/wt.toml failed to parse: -  TOML parse error at line 1, column 29 +  TOML parse error at line 1, column 28   | -  1 | pre-create = [invalid syntax -  | ^ +  1 | pre-start = [invalid syntax +  | ^   unclosed array, expected `]` diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_base_variables.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_base_variables.snap similarity index 80% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_base_variables.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_base_variables.snap index 7659db9277..1b61b0a155 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_base_variables.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_base_variables.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -59,11 +59,11 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update -◎ Running pre-create project:base @ _REPO_.feature +◎ Running pre-start project:base @ _REPO_.feature   echo 'Base: main' > base_info.txt -◎ Running pre-create project:base_path @ _REPO_.feature +◎ Running pre-start project:base_path @ _REPO_.feature   echo 'Base Path: _REPO_' >> base_info.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_create_with_command.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_complex_shell.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_create_with_command.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_complex_shell.snap index 4a6248e647..9e4fdf7a2f 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_create_with_command.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_complex_shell.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_complex_shell.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_create_with_command.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_complex_shell.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_create_with_command.snap index 4a6248e647..9e4fdf7a2f 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_complex_shell.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_create_with_command.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_default_branch_template.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_default_branch_template.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_default_branch_template.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_default_branch_template.snap index cb217a766d..0198742e22 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_default_branch_template.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_default_branch_template.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -58,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   echo 'Default: main' > default.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_failing_command.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_failing_command.snap similarity index 85% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_failing_command.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_failing_command.snap index cb1a8d83c3..504e574b39 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_failing_command.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_failing_command.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -57,7 +57,7 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   exit 1 -✗ pre-create command failed: exit status: 1 -↳ To skip pre-create hooks, re-run with --no-hooks +✗ pre-start command failed: exit status: 1 +↳ To skip pre-start hooks, re-run with --no-hooks diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_git_variables_template.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_git_variables_template.snap similarity index 72% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_git_variables_template.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_git_variables_template.snap index f97fc19356..2e3e0a2855 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_git_variables_template.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_git_variables_template.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -58,15 +58,15 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update -◎ Running pre-create project:commit @ _REPO_.feature -  echo 'Commit: 5df3ac9912c6b9de24c353a5fc6f04017a174f61' > git_vars.txt -◎ Running pre-create project:short @ _REPO_.feature -  echo 'Short: 5df3ac9' >> git_vars.txt -◎ Running pre-create project:remote @ _REPO_.feature +◎ Running pre-start project:commit @ _REPO_.feature +  echo 'Commit: 72ad3f366b7d9a0fad6f311caa63139e2dc02f0e' > git_vars.txt +◎ Running pre-start project:short @ _REPO_.feature +  echo 'Short: 72ad3f3' >> git_vars.txt +◎ Running pre-start project:remote @ _REPO_.feature   echo 'Remote: origin' >> git_vars.txt -◎ Running pre-create project:worktree_name @ _REPO_.feature +◎ Running pre-start project:worktree_name @ _REPO_.feature   echo 'Worktree Name: repo.feature' >> git_vars.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_invalid_command.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_invalid_command.snap new file mode 100644 index 0000000000..9e4fdf7a2f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_invalid_command.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_log_captures_output.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_log_captures_output.snap new file mode 100644 index 0000000000..9e4fdf7a2f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_log_captures_output.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_mixed_user_pipeline_project_flat.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_mixed_user_pipeline_project_flat.snap new file mode 100644 index 0000000000..f10af8f33e --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_mixed_user_pipeline_project_flat.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: …, user_bg (user); proj (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiline_control_structure.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiline_control_structure.snap similarity index 94% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiline_control_structure.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiline_control_structure.snap index feed7d3413..ba3a4af7d8 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiline_control_structure.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiline_control_structure.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -57,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   if [ ! -f test.txt ]; then    echo 'File does not exist' > result.txt   else diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiline_with_newlines.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiline_with_newlines.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiline_with_newlines.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiline_with_newlines.snap index 8354018bf1..03bb723f61 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_multiline_with_newlines.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiline_with_newlines.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -60,4 +60,4 @@ exit_code: 0 ✓ Created branch feature from main and worktree @ _REPO_.feature ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_log_captures_output.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiple_background.snap similarity index 92% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_log_captures_output.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiple_background.snap index 4a6248e647..719ad718ee 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_log_captures_output.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_multiple_background.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature +◎ Running post-start: task1 & task2 (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_named_commands.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_named_commands.snap similarity index 79% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_named_commands.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_named_commands.snap index 6ea21f10c4..79e2867e18 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_named_commands.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_named_commands.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -57,12 +57,12 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update -◎ Running pre-create project:install @ _REPO_.feature +◎ Running pre-start project:install @ _REPO_.feature   echo 'Installing deps' Installing deps -◎ Running pre-create project:setup @ _REPO_.feature +◎ Running pre-start project:setup @ _REPO_.feature   echo 'Running setup' Running setup ✓ Created branch feature from main and worktree @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_no_config.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_no_config.snap similarity index 97% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_no_config.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_no_config.snap index df2ac08b8c..5f9f4f9dba 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_no_config.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_no_config.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_invalid_command.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_pipeline_template_vars.snap similarity index 92% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_invalid_command.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_pipeline_template_vars.snap index 4a6248e647..57147bbc56 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_invalid_command.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_pipeline_template_vars.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: project @ _REPO_.feature +◎ Running post-start: …, check (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_project_pipeline.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_project_pipeline.snap new file mode 100644 index 0000000000..a99b2fad94 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_project_pipeline.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: …, task1 & task2 (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_separate_logs.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_separate_logs.snap new file mode 100644 index 0000000000..88a18b6d4c --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_separate_logs.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: task1 & task2 & task3 (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_background.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_background.snap new file mode 100644 index 0000000000..9e4fdf7a2f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_background.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_command.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_command.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_command.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_command.snap index 87050eacdf..2548276c91 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_single_command.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_single_command.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -57,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   echo 'Setup complete' Setup complete ✓ Created branch feature from main and worktree @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_skip_existing.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_skip_existing.snap similarity index 97% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_skip_existing.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_skip_existing.snap index e97fc91e53..0da82d5b23 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_skip_existing.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_skip_existing.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__post_start_target_variable.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_target_variable.snap new file mode 100644 index 0000000000..9e4fdf7a2f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_target_variable.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/post_start_commands.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_template_expansion.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_template_expansion.snap similarity index 75% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_template_expansion.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_template_expansion.snap index 09561d4453..d2dbca33fe 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_template_expansion.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_template_expansion.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -57,17 +57,17 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ Project config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ Project config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update -◎ Running pre-create project:repo @ _REPO_.feature-test +◎ Running pre-start project:repo @ _REPO_.feature-test   echo 'Repo: repo' > info.txt -◎ Running pre-create project:branch @ _REPO_.feature-test +◎ Running pre-start project:branch @ _REPO_.feature-test   echo 'Branch: feature/test' >> info.txt -◎ Running pre-create project:hash_port @ _REPO_.feature-test +◎ Running pre-start project:hash_port @ _REPO_.feature-test   echo 'Port: 18064' >> info.txt -◎ Running pre-create project:worktree @ _REPO_.feature-test +◎ Running pre-start project:worktree @ _REPO_.feature-test   echo 'Worktree: _REPO_.feature-test' >> info.txt -◎ Running pre-create project:root @ _REPO_.feature-test +◎ Running pre-start project:root @ _REPO_.feature-test   echo 'Root: _REPO_' >> info.txt ✓ Created branch feature/test from main and worktree @ _REPO_.feature-test ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_upstream_conditional.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_upstream_conditional.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_upstream_conditional.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_upstream_conditional.snap index 21299e0af9..15d35d0b5f 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_upstream_conditional.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_upstream_conditional.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -58,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   echo 'no-upstream' > upstream.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_upstream_template.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_upstream_template.snap similarity index 93% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_upstream_template.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_upstream_template.snap index 7420738954..cd7ef5a64b 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_upstream_template.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_upstream_template.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -58,6 +58,6 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -✗ Failed to expand project pre-create hook: undefined value @ line 1 +✗ Failed to expand project pre-start hook: undefined value @ line 1   echo 'Upstream: {{ upstream }}' > upstream.txt ↳ Available variables: args, base, base_worktree_path, branch, commit, cwd, default_branch, hook_type, main_worktree, main_worktree_path, primary_worktree_path, remote, remote_url, repo, repo_path, repo_root, short_commit, target, target_worktree_path, worktree, worktree_name, worktree_path diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_verbose_output.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_verbose_output.snap similarity index 91% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_verbose_output.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_verbose_output.snap index f5a0160d19..80b120d73e 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_verbose_output.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_verbose_output.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -74,8 +74,8 @@ exit_code: 0   branch = feature   worktree_path = _REPO_.feature   worktree_name = repo.feature -  commit = cc56115a2d40689b4609d28787b6c4c0f8911148 -  short_commit = cc56115 +  commit = d545a78b148af245c616612b474fd2049705c00c +  short_commit = d545a78   upstream = (unset)   base = main   base_worktree_path = _REPO_ @@ -91,6 +91,6 @@ exit_code: 0   remote = origin   remote_url = ../origin.git   cwd = _REPO_.feature -  hook_type = post-create +  hook_type = post-start   hook_name = setup -◎ Running post-create: setup (project) @ _REPO_.feature +◎ Running post-start: setup (project) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_verbose_template_expansion.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_verbose_template_expansion.snap similarity index 91% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_create_verbose_template_expansion.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_start_verbose_template_expansion.snap index 593827fe9d..39b559bd45 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_create_verbose_template_expansion.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_start_verbose_template_expansion.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: @@ -70,8 +70,8 @@ exit_code: 0   branch = verbose-hooks   worktree_path = _REPO_.verbose-hooks   worktree_name = repo.verbose-hooks -  commit = 10d3d6e5762be0da3395f897e4656e429b1e5e94 -  short_commit = 10d3d6e +  commit = 7900f355a9d429e8ac86370140fe4622e7d27e33 +  short_commit = 7900f35   upstream = (unset)   base = main   base_worktree_path = _REPO_ @@ -87,9 +87,9 @@ exit_code: 0   remote = origin   remote_url = ../origin.git   cwd = _REPO_.verbose-hooks -  hook_type = pre-create +  hook_type = pre-start   hook_name = setup -◎ Running pre-create project:setup @ _REPO_.verbose-hooks +◎ Running pre-start project:setup @ _REPO_.verbose-hooks   echo 'Setting up verbose-hooks in _REPO_.verbose-hooks' Setting up verbose-hooks in _REPO_.verbose-hooks ✓ Created branch verbose-hooks from main and worktree @ _REPO_.verbose-hooks diff --git a/tests/snapshots/integration__integration_tests__post_create_commands__post_switch_target_variable_existing.snap b/tests/snapshots/integration__integration_tests__post_start_commands__post_switch_target_variable_existing.snap similarity index 97% rename from tests/snapshots/integration__integration_tests__post_create_commands__post_switch_target_variable_existing.snap rename to tests/snapshots/integration__integration_tests__post_start_commands__post_switch_target_variable_existing.snap index 525b480818..5c7958ae21 100644 --- a/tests/snapshots/integration__integration_tests__post_create_commands__post_switch_target_variable_existing.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__post_switch_target_variable_existing.snap @@ -1,5 +1,5 @@ --- -source: tests/integration_tests/post_create_commands.rs +source: tests/integration_tests/post_start_commands.rs info: program: wt args: diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_wrapper_preserves_progress_messages.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_wrapper_preserves_progress_messages.snap index 417755e506..976bf773f7 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_wrapper_preserves_progress_messages.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_wrapper_preserves_progress_messages.snap @@ -5,4 +5,4 @@ expression: "&output.combined" ✓ Created branch fish-bg from main and worktree @ _REPO_.fish-bg ↳ To customize worktree locations, run wt config create -◎ Running post-create: project +◎ Running post-start: project diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_approval_prompt.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_approval_prompt.snap index 77340c40d3..16af8bb7a7 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_approval_prompt.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_approval_prompt.snap @@ -5,11 +5,11 @@ expression: prompt_only n ▲ repo needs approval to execute 3 commands: -○ pre-create install: +○ pre-start install: echo 'Installing dependencies...' -○ pre-create build: +○ pre-start build: echo 'Building project...' -○ pre-create test: +○ pre-start test: echo 'Running tests...' ❯ Allow and remember? [y/N] diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_create.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_start.snap similarity index 74% rename from tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_create.snap rename to tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_start.snap index 98a26bc74f..60a26d8d43 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_create.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__readme_example_hooks_pre_start.snap @@ -3,11 +3,11 @@ source: tests/integration_tests/shell_wrapper.rs expression: "&output.combined" --- -◎ Running pre-create project:install +◎ Running pre-start project:install   uv sync Resolved 24 packages in 145ms Installed 24 packages in 1.2s ✓ Created branch feature-x from main and worktree @ _REPO_.feature-x ↳ To customize worktree locations, run wt config create -◎ Running post-create: dev (project) +◎ Running post-start: dev (project) diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_hooks_zsh.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_hooks_zsh.snap index d1f9527d3d..3ceb1698aa 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_hooks_zsh.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_hooks_zsh.snap @@ -3,12 +3,12 @@ source: tests/integration_tests/shell_wrapper.rs expression: "&output.combined" --- -◎ Running pre-create project:install +◎ Running pre-start project:install   echo 'Installing dependencies...' Installing dependencies... -◎ Running pre-create project:build +◎ Running pre-start project:build   echo 'Building project...' Building project... ✓ Created branch feature-hooks from main and worktree @ _REPO_.feature-hooks ↳ To customize worktree locations, run wt config create -◎ Running post-create: server & watch (project) +◎ Running post-start: server & watch (project) diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_create_command_no_directive_leak.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_start_command_no_directive_leak.snap similarity index 85% rename from tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_create_command_no_directive_leak.snap rename to tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_start_command_no_directive_leak.snap index 3007eb60ab..f200e69c69 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_create_command_no_directive_leak.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_post_start_command_no_directive_leak.snap @@ -5,4 +5,4 @@ expression: "&output.combined" ✓ Created branch feature-with-hooks from main and worktree @ _REPO_.feature-with-hooks ↳ To customize worktree locations, run wt config create -◎ Running post-create: project +◎ Running post-start: project diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__wrapper_preserves_progress_messages.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__wrapper_preserves_progress_messages.snap index 70890b2213..25d9666f15 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__wrapper_preserves_progress_messages.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__wrapper_preserves_progress_messages.snap @@ -5,4 +5,4 @@ expression: "&output.combined" ✓ Created branch feature-bg from main and worktree @ _REPO_.feature-bg ↳ To customize worktree locations, run wt config create -◎ Running post-create: project +◎ Running post-start: project diff --git a/tests/snapshots/integration__integration_tests__switch__switch_combined_hooks.snap b/tests/snapshots/integration__integration_tests__switch__switch_combined_hooks.snap index f74cabb0f0..7475155672 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_combined_hooks.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_combined_hooks.snap @@ -62,4 +62,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-switch: project; post-create: project @ _REPO_.combined-hooks-test +◎ Running post-switch: project; post-start: project @ _REPO_.combined-hooks-test diff --git a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_skips_post_create.snap b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_skips_post_start.snap similarity index 92% rename from tests/snapshots/integration__integration_tests__switch__switch_no_hooks_skips_post_create.snap rename to tests/snapshots/integration__integration_tests__switch__switch_no_hooks_skips_post_start.snap index 2c9767d4ff..8ccbde6040 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_skips_post_create.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_skips_post_start.snap @@ -5,7 +5,7 @@ info: args: - switch - "--create" - - no-post-create + - no-post-start - "--no-hooks" env: APPDATA: "[TEST_CONFIG_HOME]" @@ -58,7 +58,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -✓ Created branch no-post-create from main and worktree @ _REPO_.no-post-create +✓ Created branch no-post-start from main and worktree @ _REPO_.no-post-start ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install diff --git a/tests/snapshots/integration__integration_tests__user_hooks__background_hook_env_var_visible.snap b/tests/snapshots/integration__integration_tests__user_hooks__background_hook_env_var_visible.snap index 8e89fbe2f4..25dfd7c2f0 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__background_hook_env_var_visible.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__background_hook_env_var_visible.snap @@ -57,10 +57,10 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create user:capture-fg @ _REPO_.feature -  echo "fg=$WORKTRUNK_FOREGROUND" > pre_create_env.txt +◎ Running pre-start user:capture-fg @ _REPO_.feature +  echo "fg=$WORKTRUNK_FOREGROUND" > pre_start_env.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: capture-bg (user) @ _REPO_.feature +◎ Running post-start: capture-bg (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__hook_verbose_background_dedup.snap b/tests/snapshots/integration__integration_tests__user_hooks__hook_verbose_background_dedup.snap index bc93aaff0c..dd82808395 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__hook_verbose_background_dedup.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__hook_verbose_background_dedup.snap @@ -66,11 +66,11 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -○ Expanding user post-create hook +○ Expanding user post-start hook   echo user-hook   →   echo user-hook -○ Expanding project post-create hook +○ Expanding project post-start hook   echo project-hook   →   echo project-hook @@ -78,8 +78,8 @@ exit_code: 0   branch = feature   worktree_path = _REPO_.feature   worktree_name = repo.feature -  commit = 5fd290273dcb433c49ba3d0d37601e1d034a113d -  short_commit = 5fd2902 +  commit = 26c70caa73d841981f190bafd2a2a6e1d4eed34e +  short_commit = 26c70ca   upstream = (unset)   base = main   base_worktree_path = _REPO_ @@ -95,6 +95,6 @@ exit_code: 0   remote = origin   remote_url = ../origin.git   cwd = _REPO_.feature -  hook_type = post-create +  hook_type = post-start   hook_name = (unset) -◎ Running post-create: user; project @ _REPO_.feature +◎ Running post-start: user; project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__standalone_hook_name_filtered_lazy_template.snap b/tests/snapshots/integration__integration_tests__user_hooks__standalone_hook_name_filtered_lazy_template.snap index 6f55cfb1bf..499f0d3636 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__standalone_hook_name_filtered_lazy_template.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__standalone_hook_name_filtered_lazy_template.snap @@ -4,7 +4,7 @@ info: program: wt args: - hook - - post-create + - post-start - db env: APPDATA: "[TEST_CONFIG_HOME]" @@ -57,4 +57,4 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running post-create: db (user) +◎ Running post-start: db (user) diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_start.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_start.snap new file mode 100644 index 0000000000..fe023c4299 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_start.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/user_hooks.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: bg (user); project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_unnamed_post_create.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_unnamed_post_start.snap similarity index 95% rename from tests/snapshots/integration__integration_tests__user_hooks__user_and_project_unnamed_post_create.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_and_project_unnamed_post_start.snap index f20fbd0c01..a3bef767a0 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_unnamed_post_create.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_unnamed_post_start.snap @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: user; project @ _REPO_.feature +◎ Running post-start: user; project @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_hook_template_vars.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_hook_template_vars.snap index a8ba290abd..980f3ddbe7 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_hook_template_vars.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_hook_template_vars.snap @@ -57,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create user:vars @ _REPO_.feature +◎ Running pre-start user:vars @ _REPO_.feature   echo 'repo=repo branch=feature' > template_vars.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_before_project.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_before_project.snap index 662135e4c2..ef390ee393 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_before_project.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_before_project.snap @@ -57,9 +57,9 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create user:log @ _REPO_.feature +◎ Running pre-start user:log @ _REPO_.feature   echo 'USER_HOOK' >> hook_order.txt -◎ Running pre-create project hook @ _REPO_.feature +◎ Running pre-start project hook @ _REPO_.feature   echo 'PROJECT_HOOK' >> hook_order.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_no_approval.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_no_approval.snap index d4534b8586..3e85388318 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_no_approval.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_no_approval.snap @@ -57,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create user:setup @ _REPO_.feature +◎ Running pre-start user:setup @ _REPO_.feature   echo 'NO_APPROVAL_NEEDED' > no_approval.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_preserve_order.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_preserve_order.snap index 7665272e20..8249586bad 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_preserve_order.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_hooks_preserve_order.snap @@ -57,15 +57,15 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -▲ User config: table form for pre-create is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. +▲ User config: table form for pre-start is deprecated in favor of the pipeline form. We're unifying pre-hooks, post-hooks, and aliases so that list form always runs serially and table form always runs in parallel — migrate now to keep the current serial behavior once the table form is repurposed. ↳ To see details, run wt config show; to apply updates, run wt config update -◎ Running pre-create user:vscode @ _REPO_.feature +◎ Running pre-start user:vscode @ _REPO_.feature   echo '1' >> hook_order.txt -◎ Running pre-create user:claude @ _REPO_.feature +◎ Running pre-start user:claude @ _REPO_.feature   echo '2' >> hook_order.txt -◎ Running pre-create user:copy @ _REPO_.feature +◎ Running pre-start user:copy @ _REPO_.feature   echo '3' >> hook_order.txt -◎ Running pre-create user:submodule @ _REPO_.feature +◎ Running pre-start user:submodule @ _REPO_.feature   echo '4' >> hook_order.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_failure.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_failure.snap deleted file mode 100644 index 534e4c8005..0000000000 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_failure.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/user_hooks.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: fail & ok, … (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_hook_name_per_step.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_hook_name_per_step.snap deleted file mode 100644 index fda3ab5b77..0000000000 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_hook_name_per_step.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/user_hooks.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: step_one, step_two (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_ordering.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_ordering.snap deleted file mode 100644 index 11209577a1..0000000000 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_ordering.snap +++ /dev/null @@ -1,64 +0,0 @@ ---- -source: tests/integration_tests/user_hooks.rs -info: - program: wt - args: - - switch - - "--create" - - feature - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- - ------ stderr ----- -✓ Created branch feature from main and worktree @ _REPO_.feature -↳ To customize worktree locations, run wt config create -▲ Cannot change directory — shell integration not installed -↳ To enable automatic cd, run wt config shell install -◎ Running post-create: …, bg (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_executes.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_executes.snap similarity index 95% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_create_executes.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_executes.snap index 8dcfd620d2..bb4e31fc06 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_executes.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_executes.snap @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: bg (user) @ _REPO_.feature +◎ Running post-start: bg (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_failure.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_failure.snap similarity index 86% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_create_failure.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_failure.snap index 902b8fadfc..6c66d92aab 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_failure.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_failure.snap @@ -57,7 +57,7 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- -◎ Running pre-create user:failing @ _REPO_.feature +◎ Running pre-start user:failing @ _REPO_.feature   exit 1 -✗ pre-create command failed: failing: exit status: 1 -↳ To skip pre-create hooks, re-run with --no-hooks +✗ pre-start command failed: failing: exit status: 1 +↳ To skip pre-start hooks, re-run with --no-hooks diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_create.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_all.snap similarity index 95% rename from tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_create.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_all.snap index 14a3a28b55..c908e901d0 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_and_project_post_create.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_all.snap @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: bg (user); project @ _REPO_.feature +◎ Running post-start: a & b (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_all.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_failure.snap similarity index 94% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_all.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_failure.snap index 6e52464327..6a9c453a2c 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_concurrent_all.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_concurrent_failure.snap @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: a & b (user) @ _REPO_.feature +◎ Running post-start: fail & ok, … (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_lazy_vars_bg.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_failure.snap similarity index 95% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_lazy_vars_bg.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_failure.snap index 01067ff608..57d48c6a43 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_lazy_vars_bg.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_failure.snap @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: …, db (user) @ _REPO_.feature +◎ Running post-start: …, bg (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_hook_name_per_step.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_hook_name_per_step.snap new file mode 100644 index 0000000000..3ee8923c30 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_hook_name_per_step.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/user_hooks.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: step_one, step_two (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_failure.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_lazy_vars_bg.snap similarity index 95% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_failure.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_lazy_vars_bg.snap index 11209577a1..6d5b3ec341 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_pipeline_failure.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_lazy_vars_bg.snap @@ -61,4 +61,4 @@ exit_code: 0 ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install -◎ Running post-create: …, bg (user) @ _REPO_.feature +◎ Running post-start: …, db (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_ordering.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_ordering.snap new file mode 100644 index 0000000000..57d48c6a43 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_pipeline_ordering.snap @@ -0,0 +1,64 @@ +--- +source: tests/integration_tests/user_hooks.rs +info: + program: wt + args: + - switch + - "--create" + - feature + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch feature from main and worktree @ _REPO_.feature +↳ To customize worktree locations, run wt config create +▲ Cannot change directory — shell integration not installed +↳ To enable automatic cd, run wt config shell install +◎ Running post-start: …, bg (user) @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_create_skipped_no_hooks.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_skipped_no_hooks.snap similarity index 100% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_create_skipped_no_hooks.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_post_start_skipped_no_hooks.snap diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_pre_create_executes.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_pre_start_executes.snap similarity index 96% rename from tests/snapshots/integration__integration_tests__user_hooks__user_pre_create_executes.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_pre_start_executes.snap index 9ef513c568..34e64a2909 100644 --- a/tests/snapshots/integration__integration_tests__user_hooks__user_pre_create_executes.snap +++ b/tests/snapshots/integration__integration_tests__user_hooks__user_pre_start_executes.snap @@ -57,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Running pre-create user:log @ _REPO_.feature +◎ Running pre-start user:log @ _REPO_.feature   echo 'USER_PRE_CREATE_RAN' > user_hook_marker.txt ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create From 8b5d5f6855f844d8e09769af88b636645352139e Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 09:44:52 -0700 Subject: [PATCH 13/34] chore(clawpatch): add threat-model specs for review surfaces (#2859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds eight clawpatch feature specs and refines feat_custom_network_boundary to be activity-based (matching the Network Access reword in #2850). Config-only — no code change. --- .../feat_custom_approvals_storage.json | 69 +++++++++++++++++ .../features/feat_custom_hook_logs.json | 68 ++++++++++++++++ .../feat_custom_network_boundary.json | 68 ++++++++++++++++ .../features/feat_custom_performance.json | 77 +++++++++++++++++++ .../clawpatch/features/feat_custom_prune.json | 70 +++++++++++++++++ .../feat_custom_shell_integration.json | 69 +++++++++++++++++ .../features/feat_custom_signal_handling.json | 68 ++++++++++++++++ .../features/feat_custom_switch_resolve.json | 18 ++++- .../feat_custom_template_expansion.json | 72 +++++++++++++++++ 9 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 .config/clawpatch/features/feat_custom_approvals_storage.json create mode 100644 .config/clawpatch/features/feat_custom_hook_logs.json create mode 100644 .config/clawpatch/features/feat_custom_network_boundary.json create mode 100644 .config/clawpatch/features/feat_custom_performance.json create mode 100644 .config/clawpatch/features/feat_custom_prune.json create mode 100644 .config/clawpatch/features/feat_custom_shell_integration.json create mode 100644 .config/clawpatch/features/feat_custom_signal_handling.json create mode 100644 .config/clawpatch/features/feat_custom_template_expansion.json diff --git a/.config/clawpatch/features/feat_custom_approvals_storage.json b/.config/clawpatch/features/feat_custom_approvals_storage.json new file mode 100644 index 0000000000..fcb9ac5d0b --- /dev/null +++ b/.config/clawpatch/features/feat_custom_approvals_storage.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_approvals_storage", + "title": "Approvals storage: the approvals.toml trust store", + "summary": "`approvals.toml` records which project commands the user has approved; the approval gate (feat_custom_approval_gate) consults it to decide what may run without a prompt. This feature covers the integrity of the store itself: path resolution, loading, parsing, saving, and the legacy copy from config.toml. A parse that fails open, a save that partially writes, or a path-resolution bug that reads the wrong file would let an unapproved command run or silently drop the user's approvals. `Approvals::load_from_config_file` and `copy_approved_commands_to_approvals_file` must fail closed: an error aborts, never degrades to 'nothing approved' or 'everything approved'.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/config/approvals.rs", + "symbol": "Approvals", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "src/config/approvals.rs", + "reason": "Approvals struct: load/save/path resolution for approvals.toml" + } + ], + "contextFiles": [ + { + "path": "src/commands/command_approval.rs", + "reason": "the approval gate that consumes the store" + }, + { + "path": "src/config/deprecation.rs", + "reason": "copy_approved_commands_to_approvals_file: legacy migration into the store, must fail closed" + }, + { + "path": "src/config/mod.rs", + "reason": "config path resolution" + }, + { + "path": "CLAUDE.md", + "reason": "Project Commands Run Only After Approval" + } + ], + "tests": [ + { + "path": "tests/integration_tests/user_hooks.rs", + "command": "cargo test --test integration user_hooks" + }, + { + "path": "tests/integration_tests/custom.rs", + "command": "cargo test --test integration custom" + } + ], + "tags": [ + "rust", + "security", + "approval-gate", + "serialization" + ], + "trustBoundaries": [ + "filesystem", + "permissions", + "serialization" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_hook_logs.json b/.config/clawpatch/features/feat_custom_hook_logs.json new file mode 100644 index 0000000000..dc41e3ab97 --- /dev/null +++ b/.config/clawpatch/features/feat_custom_hook_logs.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_hook_logs", + "title": "Hook output logs: filename sanitization and log-path layout", + "summary": "Hook stdout/stderr is persisted under `.git/wt/logs/` with per-branch and repo-wide paths. Branch names and hook names flow into filesystem paths through `sanitize_for_filename`; a sanitization gap (path separators, `..`, absolute prefixes, reserved Windows device names) means a crafted branch name could write or truncate files outside the logs tree. The top-level file-vs-directory split must stay consistent or `wt config state` mis-walks it. See CLAUDE.md > Hook Output Logs.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/commands/process.rs", + "symbol": "HookLog", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "src/commands/process.rs", + "reason": "HookLog spec: log path construction and sanitize_for_filename call sites" + }, + { + "path": "src/path.rs", + "reason": "sanitize_for_filename definition: the sanitization boundary" + }, + { + "path": "src/commands/config/state.rs", + "reason": "log-layout invariant: the file-vs-directory split walked by `wt config state`" + } + ], + "contextFiles": [ + { + "path": "CLAUDE.md", + "reason": "Hook Output Logs section: log layout and sanitize_for_filename rule" + }, + { + "path": "src/commands/hook_commands.rs", + "reason": "hook command resolution that produces the names written to log paths" + } + ], + "tests": [ + { + "path": "tests/integration_tests/user_hooks.rs", + "command": "cargo test --test integration user_hooks" + }, + { + "path": "tests/integration_tests/hook_show.rs", + "command": "cargo test --test integration hook_show" + } + ], + "tags": [ + "rust", + "filesystem", + "path-traversal", + "logging" + ], + "trustBoundaries": [ + "filesystem", + "user-input" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_network_boundary.json b/.config/clawpatch/features/feat_custom_network_boundary.json new file mode 100644 index 0000000000..a5ec01f254 --- /dev/null +++ b/.config/clawpatch/features/feat_custom_network_boundary.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_network_boundary", + "title": "Local-first network-access boundary", + "summary": "Worktrunk is local-first: the network is touched only when the user asked for it. The audit keys on the calling command, not a fixed blocklist — an egress (`git fetch`, `git ls-remote`, `gh`, `glab`, `az`, HTTP) is a violation when it sits in a fast command or a synchronous hot path (a shell prompt) that cannot absorb the latency, or in a background TTL-cache refresh. A foreground command the user runs and waits on may reach the wire for the activity it was asked to perform. Sanctioned activities: CI status (`wt list --full`, `wt list statusline`), branch-summary and commit-message generation (`commit.generation` commands), PR/MR resolution (`wt switch pr:`/`mr:`), and the `wt config show --full` version check; plus the one detection-helper exception — the first `Repository::default_branch()` per repo may fall through to `git ls-remote`, caching into `worktrunk.default-branch`. Silent lookup fallthroughs (alias dispatch, hook context build, recovery) must stay local. See CLAUDE.md > Network Access.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/git/repository/config.rs", + "symbol": "default_branch", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "src/git/repository/config.rs", + "reason": "default_branch(): the one sanctioned ls-remote fallback and its worktrunk.default-branch cache" + }, + { + "path": "src/commands/statusline.rs", + "reason": "`wt list statusline`: runs on every prompt but is not a fast command — Claude Code consumes its output asynchronously — so its CI-status egress is sanctioned; audit only that it adds no other unrequested network work" + } + ], + "contextFiles": [ + { + "path": "src/git/remote_ref/mod.rs", + "reason": "PR/MR fetch: the legitimate user-invoked egress, must not run on background paths" + }, + { + "path": "src/shell_exec.rs", + "reason": "Cmd: where external commands (git, gh, glab) spawn" + }, + { + "path": "src/commands/alias.rs", + "reason": "alias dispatch must resolve locally, no fallthrough to the wire" + }, + { + "path": "CLAUDE.md", + "reason": "Network Access policy and the default_branch detection-helper exception" + } + ], + "tests": [ + { + "path": "tests/integration_tests/switch.rs", + "command": "cargo test --test integration switch" + } + ], + "tags": [ + "rust", + "network-policy", + "performance", + "audit" + ], + "trustBoundaries": [ + "network", + "process-exec" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-20T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_performance.json b/.config/clawpatch/features/feat_custom_performance.json new file mode 100644 index 0000000000..fce69b8efc --- /dev/null +++ b/.config/clawpatch/features/feat_custom_performance.json @@ -0,0 +1,77 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_performance", + "title": "Startup latency and parallel status collection", + "summary": "`wt` runs on the interactive path: `wt list` and `wt statusline` execute constantly, statusline on every shell prompt, so time-to-first-output (TTFP) and the parallelism of worktree-status collection are first-class concerns. The collection layer fans per-worktree git queries out across worker threads; the parallelism must be correct (independent work not serialized, no over-spawn beyond the logical CPU count, cached Repository values reused rather than re-queried) and must stream results rather than buffer. Findings here are `performance` and `concurrency` category, code-level anti-patterns (needless work, blocking I/O on the hot path, missing parallelism); the benchmarks measure the actual latency. Pair with `cargo bench --bench time_to_first_output` / `--bench list` and `cargo run -p wt-perf -- timeline` for evidence.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/commands/list/collect/mod.rs", + "symbol": "collect", + "route": null, + "command": "wt list" + } + ], + "ownedFiles": [ + { + "path": "src/commands/list/collect/mod.rs", + "reason": "collect: status-collection orchestration and result streaming" + }, + { + "path": "src/commands/list/collect/execution.rs", + "reason": "parallel work-item dispatch across worker threads" + }, + { + "path": "src/commands/list/collect/tasks.rs", + "reason": "per-worktree git query tasks: the units fanned out in parallel" + } + ], + "contextFiles": [ + { + "path": "src/main.rs", + "reason": "process startup path before first output (TTFP)" + }, + { + "path": "src/git/repository/mod.rs", + "reason": "RepoCache: cached values that must be reused, not re-queried per task" + }, + { + "path": "src/trace/emit.rs", + "reason": "[wt-trace] timing instrumentation" + }, + { + "path": "CLAUDE.md", + "reason": "Benchmarks & Traces; Repository Caching" + }, + { + "path": "benches/CLAUDE.md", + "reason": "benchmark filter map and expected numbers" + } + ], + "tests": [ + { + "path": "benches/time_to_first_output.rs", + "command": "cargo bench --bench time_to_first_output" + }, + { + "path": "tests/integration_tests/list.rs", + "command": "cargo test --test integration list" + } + ], + "tags": [ + "rust", + "performance", + "concurrency", + "startup" + ], + "trustBoundaries": [], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_prune.json b/.config/clawpatch/features/feat_custom_prune.json new file mode 100644 index 0000000000..509d689a4c --- /dev/null +++ b/.config/clawpatch/features/feat_custom_prune.json @@ -0,0 +1,70 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_prune", + "title": "wt step prune: worktree pruning and concurrent .git/config safety", + "summary": "`wt step prune` scans worktrees and orphan branches and removes stale ones. As a destructive operation it must require explicit consent for force-removal, must not discard uncommitted or untracked work, and runs pre/post-remove hooks only after the approval gate clears them. Concurrent `git worktree` mutations race on `.git/config` (the Windows prune race, issue #2801) so pruning must tolerate or serialize that. Complements feat_custom_remove, which covers `wt remove` and the picker. See CLAUDE.md > Data Safety.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/commands/step/prune.rs", + "symbol": "step_prune", + "route": null, + "command": "wt step prune" + } + ], + "ownedFiles": [ + { + "path": "src/commands/step/prune.rs", + "reason": "prune entrypoint: candidate selection, age checks, removal, hook approval" + } + ], + "contextFiles": [ + { + "path": "src/git/repository/worktrees.rs", + "reason": "worktree listing and `git worktree remove`" + }, + { + "path": "src/remove_dir.rs", + "reason": "directory removal invoked by prune" + }, + { + "path": "src/commands/command_approval.rs", + "reason": "approval gate for pre/post-remove hooks" + }, + { + "path": "src/commands/hook_plan.rs", + "reason": "ApprovedHookPlan: prune is a gate-then-mutate operation" + }, + { + "path": "CLAUDE.md", + "reason": "Data Safety invariants" + } + ], + "tests": [ + { + "path": "tests/integration_tests/step_prune.rs", + "command": "cargo test --test integration step_prune" + } + ], + "tags": [ + "rust", + "data-safety", + "destructive", + "concurrency", + "approval-gate" + ], + "trustBoundaries": [ + "filesystem", + "concurrency", + "process-exec" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_shell_integration.json b/.config/clawpatch/features/feat_custom_shell_integration.json new file mode 100644 index 0000000000..0760e922c9 --- /dev/null +++ b/.config/clawpatch/features/feat_custom_shell_integration.json @@ -0,0 +1,69 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_shell_integration", + "title": "Shell integration: generated wrapper code and rc-file edits", + "summary": "`wt config shell init` emits a shell function that the user `eval`s into an interactive shell, and `wt config shell install` edits rc files in place. The emitted wrapper embeds `argv[0]`-derived data (`binary_name`) and the configured command name; anything unescaped that reaches the eval'd output is code injection into the user's shell. rc-file edits must be idempotent and must not corrupt or duplicate an existing managed block. Trust boundary: process-exec (code the shell will run) and filesystem (user rc files).", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/commands/configure_shell.rs", + "symbol": "handle_configure_shell", + "route": null, + "command": "wt config shell" + } + ], + "ownedFiles": [ + { + "path": "src/commands/configure_shell.rs", + "reason": "rc-file scan/edit and managed-block detection" + }, + { + "path": "src/output/shell_integration.rs", + "reason": "shell-integration prompts and install/uninstall result rendering" + }, + { + "path": "src/invocation.rs", + "reason": "binary_name/invocation_path: argv[0]-derived values embedded in the emitted wrapper" + } + ], + "contextFiles": [ + { + "path": "src/cli/config.rs", + "reason": "`wt config shell` subcommand wiring" + }, + { + "path": "src/commands/init.rs", + "reason": "init-time shell-integration setup" + } + ], + "tests": [ + { + "path": "tests/integration_tests/configure_shell.rs", + "command": "cargo test --test integration configure_shell" + }, + { + "path": "tests/integration_tests/shell_wrapper.rs", + "command": "cargo test --test integration shell_wrapper" + } + ], + "tags": [ + "rust", + "security", + "shell-integration", + "code-injection" + ], + "trustBoundaries": [ + "process-exec", + "filesystem", + "user-input" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_signal_handling.json b/.config/clawpatch/features/feat_custom_signal_handling.json new file mode 100644 index 0000000000..6ecb478064 --- /dev/null +++ b/.config/clawpatch/features/feat_custom_signal_handling.json @@ -0,0 +1,68 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_signal_handling", + "title": "Signal handling: Ctrl-C aborts every child-process loop", + "summary": "wt installs a signal_hook SIGINT/SIGTERM handler that forwards signals to child process groups, so wt itself does not die from the user's Ctrl-C, only the current child does. Every foreground loop that runs multiple child processes (`wt step for-each`, hook pipelines, alias steps, concurrent groups) MUST abort on a signal-derived child exit instead of charging into the next iteration: otherwise one Ctrl-C against `wt merge` runs the remaining hook steps, with FailureStrategy::Warn silently swallowing each interrupt. Detect via `err.interrupt_exit_code()` (the worktrunk::git::ErrorExt trait); the check happens before any FailureStrategy branch. The structured channel is WorktrunkError::ChildProcessExited { signal }, never sniff code >= 128. See CLAUDE.md > Signal Handling.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/commands/command_executor.rs", + "symbol": "handle_command_error", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "src/commands/command_executor.rs", + "reason": "handle_command_error: enforces signal-abort for hook and alias pipelines, foreground and concurrent groups" + }, + { + "path": "src/commands/for_each.rs", + "reason": "wt step for-each: per-worktree loop must break on a signal-derived child exit" + }, + { + "path": "src/git/error.rs", + "reason": "ErrorExt::interrupt_exit_code and the WorktrunkError::ChildProcessExited signal channel" + } + ], + "contextFiles": [ + { + "path": "src/shell_exec.rs", + "reason": "Cmd: spawns children into the process groups the signal handler forwards to" + }, + { + "path": "src/commands/command_approval.rs", + "reason": "FailureStrategy::Warn must not swallow signal-derived errors" + }, + { + "path": "CLAUDE.md", + "reason": "Signal Handling policy: every child-process loop aborts on a signal" + } + ], + "tests": [ + { + "path": "tests/integration_tests/user_hooks.rs", + "command": "cargo test --test integration user_hooks" + } + ], + "tags": [ + "rust", + "signal-handling", + "concurrency", + "interrupt" + ], + "trustBoundaries": [ + "process-exec", + "concurrency" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} diff --git a/.config/clawpatch/features/feat_custom_switch_resolve.json b/.config/clawpatch/features/feat_custom_switch_resolve.json index 1b9a134add..469282f448 100644 --- a/.config/clawpatch/features/feat_custom_switch_resolve.json +++ b/.config/clawpatch/features/feat_custom_switch_resolve.json @@ -31,6 +31,22 @@ "path": "src/git/remote_ref/mod.rs", "reason": "remote ref parsing (PR/MR URLs)" }, + { + "path": "src/git/remote_ref/github.rs", + "reason": "GitHub PR fetch and owner/repo URL derivation from the forge remote" + }, + { + "path": "src/git/remote_ref/gitlab.rs", + "reason": "GitLab MR fetch and project URL parsing" + }, + { + "path": "src/git/remote_ref/gitea.rs", + "reason": "Gitea PR fetch and URL parsing" + }, + { + "path": "src/git/remote_ref/azure.rs", + "reason": "Azure DevOps PR fetch and source-branch validation" + }, { "path": "src/git/repository/branch.rs", "reason": "branch name handling" @@ -101,5 +117,5 @@ } ], "createdAt": "2026-05-16T21:00:00.000Z", - "updatedAt": "2026-05-17T05:12:27.656Z" + "updatedAt": "2026-05-19T00:00:00.000Z" } diff --git a/.config/clawpatch/features/feat_custom_template_expansion.json b/.config/clawpatch/features/feat_custom_template_expansion.json new file mode 100644 index 0000000000..6bba628a68 --- /dev/null +++ b/.config/clawpatch/features/feat_custom_template_expansion.json @@ -0,0 +1,72 @@ +{ + "schemaVersion": 1, + "featureId": "feat_custom_template_expansion", + "title": "MiniJinja template expansion of project-supplied commands", + "summary": "Hook commands, alias steps, and `-x/--execute` bodies are MiniJinja templates rendered at runtime with a context of worktree/repo variables. The templates come from project config (a repo the user may have just cloned) and from CLI args. Risks: a rendering or escaping bug that mis-expands a variable into a command, undeclared-variable handling that silently produces an empty or wrong command, and the ShellArgs object's quoting. Expansion happens before the approval gate sees the final string, so a mismatch between what is approved and what is rendered would bypass approval. Detection must use minijinja::undeclared_variables, never substring or regex on `{{ }}`.", + "kind": "library", + "source": "manual", + "confidence": "high", + "entrypoints": [ + { + "path": "src/commands/hook_commands.rs", + "symbol": "expand_command_template", + "route": null, + "command": null + } + ], + "ownedFiles": [ + { + "path": "src/commands/hook_commands.rs", + "reason": "expand_command_template: runtime render of hook command templates" + }, + { + "path": "src/config/expansion.rs", + "reason": "variable catalog, ShellArgs minijinja object, shell-arg quoting" + }, + { + "path": "src/commands/alias.rs", + "reason": "alias step template rendering" + } + ], + "contextFiles": [ + { + "path": "src/config/project.rs", + "reason": "project config that supplies the templates" + }, + { + "path": "src/commands/command_approval.rs", + "reason": "approval gate: must see the same string the renderer produces" + }, + { + "path": "src/cli/mod.rs", + "reason": "-x/--execute bodies expanded as templates" + } + ], + "tests": [ + { + "path": "tests/integration_tests/step_alias.rs", + "command": "cargo test --test integration step_alias" + }, + { + "path": "tests/integration_tests/user_hooks.rs", + "command": "cargo test --test integration user_hooks" + } + ], + "tags": [ + "rust", + "user-input", + "template-injection", + "minijinja" + ], + "trustBoundaries": [ + "user-input", + "process-exec" + ], + "status": "pending", + "lock": null, + "findingIds": [], + "patchAttemptIds": [], + "analysisHistory": [], + "createdAt": "2026-05-19T00:00:00.000Z", + "updatedAt": "2026-05-19T00:00:00.000Z" +} From f582c8b0c845b758a08f525eebce52ca860e190c Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 09:47:18 -0700 Subject: [PATCH 14/34] refactor(switch): unify the picker and argument-path switch pipelines (#2858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `wt switch ` and the interactive picker (`wt switch` with no argument) each ran the same switch sequence as separate, parallel code. [#2845](https://github.com/max-sixty/worktrunk/pull/2845) made the two paths *behave* identically; this makes the *code* identical too — a single `SwitchPipeline` that both entry points build and `.run()`. `SwitchPipeline::run` (in `src/commands/worktree/switch.rs`) owns the whole sequence: the bare-repo worktree-path fix-up, pre-switch hooks, source-identity capture, `plan_switch` → `approve_switch_hooks` → `validate_switch_templates` → `execute_switch`, output, background hooks, and `--execute`. Each caller now only resolves a branch identifier and loads config. The picker-vs-argument differences (`--execute`, the shell-integration offer, source-identity capture) are struct field values, not divergent branches. ## Bug fixed The duplication hid a real bug: the picker passed `yes = true` to `run_pre_switch_hooks`, **auto-approving project `pre-switch` hooks without a prompt** — unapproved code from a freshly cloned `.config/wt.toml` running silently. Every other hook the picker runs (`post-switch`, `pre-create`, `post-create`) already went through the approval prompt. With one shared `run_pre_switch_hooks` call gated by the pipeline's single `verify`/`yes` pair, the picker (which has no `--yes`) now prompts for project `pre-switch` hooks like `wt switch ` does — and the two paths can't drift on hook approval again. ## Reviewer notes - One intentional reorder on the argument path: `offer_bare_repo_worktree_path_fix` now runs before `run_pre_switch_hooks` (the picker already used this order). The fix only mutates `worktree-path` config, which pre-switch hooks never read — behavior-neutral. - `capture_switch_source` runs inside `run()` after pre-switch hooks — the same relative position as the old `run_switch`. - Eight now-internal helpers were narrowed from `pub`/`pub(crate)` to private; `worktree/mod.rs` re-exports trimmed accordingly. ## Testing Pure refactor — the existing `test_switch_*`, `test_switch_format_json_*`, and `test_switch_picker_*` suites cover behavior preservation. `test_switch_picker_pre_switch_hook_requires_approval` is a new regression test for the bug fix (verified to fail under the bug). --------- Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 + src/commands/picker/mod.rs | 125 ++---- src/commands/worktree/mod.rs | 11 +- src/commands/worktree/switch.rs | 546 ++++++++++++++--------- tests/integration_tests/switch_picker.rs | 51 +++ 5 files changed, 418 insertions(+), 317 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5ab40e11..4504777289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- **Picker prompts for approval before running project `pre-switch` hooks**: Selecting a worktree in the interactive picker (`wt switch` with no argument) ran a project-defined `pre-switch` hook from `.config/wt.toml` without the approval prompt that gates every other hook — unapproved code from a freshly cloned repo executing silently. The picker now routes `pre-switch` hooks through the same approval gate as `wt switch ` and as its own `post-switch`/`pre-start`/`post-start` hooks. ([#2858](https://github.com/max-sixty/worktrunk/pull/2858)) + - **Interactive picker switches with `cd = false`**: With `[switch] cd = false` (or `wt switch --no-cd`), opening the picker (`wt switch` with no branch argument) and selecting a worktree printed the branch name and exited — no switch, no hooks, and `Alt-c` created nothing. The picker now runs the same switch pipeline as `wt switch `, suppressing only the cd directive: `pre-switch`/`post-switch` hooks fire and `Alt-c` creates the worktree. `--format=json` works in the picker too, and replaces the old print-only output for scripting — it both switches and prints a structured result (`action`, `branch`, `path`) to stdout. ([#2837](https://github.com/max-sixty/worktrunk/issues/2837)) - **`wt remove` reaps a wedged fsmonitor daemon**: When `core.fsmonitor=true`, git runs a per-worktree `git fsmonitor--daemon`. Removal already sent `git fsmonitor--daemon stop`, but `stop` is an IPC request to the daemon itself, so a daemon that had stopped answering its socket ignored it and then leaked forever once its worktree was gone (dozens could accumulate, and one wedged daemon hangs `wt list`). Removal now resolves the daemon's PID from its IPC socket and force-terminates it (SIGTERM, brief wait, SIGKILL) when `stop` doesn't take. The signal only ever targets the daemon whose socket resolves to the worktree being removed. diff --git a/src/commands/picker/mod.rs b/src/commands/picker/mod.rs index 56ae451381..932b83732c 100644 --- a/src/commands/picker/mod.rs +++ b/src/commands/picker/mod.rs @@ -115,15 +115,9 @@ use super::hooks::HookAnnouncer; use super::list::collect; use super::list::progressive::RenderTarget; use super::repository_ext::{RemoveTarget, RepositoryCliExt}; -use super::template_vars::TemplateVars; -use super::worktree::{ - RemoveResult, SwitchBranchInfo, SwitchResult, approve_switch_hooks, emit_switch_json, - execute_switch, offer_bare_repo_worktree_path_fix, path_mismatch, plan_switch, - run_pre_switch_hooks, spawn_switch_background_hooks, switch_hook_project_config, - validate_switch_templates, -}; +use super::worktree::{RemoveResult, SwitchPipeline}; use crate::cli::SwitchFormat; -use crate::output::{handle_remove_output, handle_switch_output}; +use crate::output::handle_remove_output; use worktrunk::git::{BranchDeletionMode, delete_branch_if_safe}; use items::{PreviewCache, WorktreeSkimItem}; @@ -802,101 +796,42 @@ pub fn handle_picker( let query = out.query.trim().to_string(); let identifier = resolve_identifier(&action, query, selected_name)?; - // Load config — reuse recovered repo if we recovered earlier + // Load config — reuse the recovered repo if we recovered earlier. let repo = if is_recovered { repo.clone() } else { Repository::current().context("Failed to switch worktree")? }; - // Clone user out so `offer_bare_repo_worktree_path_fix` can mutate - // locally. Project config is loaded on demand by downstream - // `run_pre_switch_hooks` / `plan_switch`. + // Clone user config out — `SwitchPipeline` takes `&mut UserConfig` (the + // bare-repo path-fix offer and the shell-integration offer record onto + // it). Project config is loaded on demand inside the pipeline. let mut config = repo.user_config().clone(); - offer_bare_repo_worktree_path_fix(&repo, &mut config)?; - // Run pre-switch hooks before branch resolution or worktree creation. - // {{ branch }} receives the raw user input (before resolution). - // Skip when recovered — the source worktree is gone, nothing to run hooks against. - if !is_recovered { - run_pre_switch_hooks(&repo, &config, &identifier, true)?; - } - - // Switch to existing worktree or create new one - let plan = plan_switch(&repo, &identifier, should_create, None, false, &config)?; - // Resolve the config the post-switch hooks will run against (the - // new/destination worktree's, or for `--create` the base ref's) so the - // approval prompt lists the exact commands `execute_switch` will run. - let hook_project_config = switch_hook_project_config(&repo, &plan)?; - let (hooks_approved, hook_plan) = approve_switch_hooks( - &repo, - &config, - &plan, - false, - true, - hook_project_config.as_ref(), - )?; - - // Pre-flight: validate all templates before mutation (worktree creation). - // Without this, picker-create would have the half-state risk that - // `wt switch --create` already guards against — a broken template would - // fail after `git worktree add`, leaving the worktree behind. - validate_switch_templates( - &repo, - &config, - &plan, - None, - &[], - hooks_approved, - hook_project_config.as_ref(), - )?; - - let (result, branch_info) = - execute_switch(&repo, plan, &config, false, hooks_approved, &hook_plan)?; - - // --format=json: structured result to stdout, identical to the - // argument path. All switch behavior above proceeds normally. - emit_switch_json(format, &result, &branch_info)?; - - // Compute path mismatch lazily (deferred from plan_switch for existing worktrees). - // Skip for detached HEAD worktrees (branch is None). - let branch_info = match &result { - SwitchResult::Existing { path } | SwitchResult::AlreadyAt(path) => { - let expected_path = branch_info - .branch - .as_deref() - .and_then(|b| path_mismatch(&repo, b, path, &config)); - SwitchBranchInfo { - expected_path, - ..branch_info - } - } - _ => branch_info, - }; - - // Show success message; emit cd directive if shell integration is active - // When recovered from a deleted worktree, fall back to repo_path(). - let fallback_path = repo.repo_path()?.to_path_buf(); - let cwd = std::env::current_dir().unwrap_or(fallback_path.clone()); - let source_root = repo.current_worktree().root().unwrap_or(fallback_path); - let hooks_display_path = - handle_switch_output(&result, &branch_info, change_dir, Some(&source_root), &cwd)?; - - // Spawn background hooks after success message. Picker doesn't capture - // pre-switch source identity, so existing-switch `base` vars stay - // unset; result-derived `base` (creates) and `target` flow as usual. - if hooks_approved { - let template_vars = TemplateVars::for_post_switch(&result, &branch_info, "", ""); - let extra_vars = template_vars.as_extra_vars(); - spawn_switch_background_hooks( - &config, - &result, - branch_info.branch.as_deref(), - false, - &extra_vars, - hooks_display_path.as_deref(), - &hook_plan, - )?; + // Run the switch — the same `SwitchPipeline` as `wt switch `, + // so hooks, approval, and output cannot drift from the argument path. + // The picker has no `--execute`, offers no shell integration, and does + // not capture pre-switch source identity (`capture_source: false` — an + // existing switch's `{{ base }}` / `{{ base_worktree_path }}` stay + // unset; result-derived `base` for creates and `target` still flow). + SwitchPipeline { + repo: &repo, + config: &mut config, + identifier: &identifier, + create: should_create, + base: None, + clobber: false, + verify: true, + yes: false, + change_dir, + format, + is_recovered, + suggestion_ctx: None, + capture_source: false, + execute: None, + execute_args: &[], + shell_integration_binary: None, } + .run()?; } Ok(()) diff --git a/src/commands/worktree/mod.rs b/src/commands/worktree/mod.rs index 0f1c0cd1f7..55c4caf09e 100644 --- a/src/commands/worktree/mod.rs +++ b/src/commands/worktree/mod.rs @@ -91,18 +91,11 @@ mod types; // Re-export public types and functions pub use finish::{FinishAfterMergeArgs, finish_after_merge}; pub use push::{PushKind, PushOutcome, PushResult, handle_no_ff_merge, handle_push}; -#[cfg(unix)] -pub use resolve::offer_bare_repo_worktree_path_fix; pub use resolve::{ compute_worktree_path, is_worktree_at_expected_path, path_mismatch, resolve_worktree_arg, worktree_display_name, }; -pub use switch::{SwitchOptions, run_switch}; #[cfg(unix)] -pub(crate) use switch::{ - approve_switch_hooks, emit_switch_json, run_pre_switch_hooks, spawn_switch_background_hooks, - switch_hook_project_config, validate_switch_templates, -}; -#[cfg(unix)] -pub use switch::{execute_switch, plan_switch}; +pub(crate) use switch::SwitchPipeline; +pub use switch::{SwitchOptions, run_switch}; pub use types::{MergeOperations, OperationMode, RemoveResult, SwitchBranchInfo, SwitchResult}; diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 934b99cf1d..c0d4715c65 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -1,7 +1,8 @@ //! Worktree switch operations. //! -//! Planning and executing worktree switches, plus the `wt switch` entry point -//! that wires hooks, approvals, output, and shell integration around them. +//! Planning and executing worktree switches, plus [`SwitchPipeline`] — the +//! full switch sequence (bare-repo fix-up, hooks, approval, execution, output) +//! shared by the `wt switch` argument path and the interactive picker. use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -784,7 +785,7 @@ fn setup_fork_branch( /// /// Warnings (remote branch shadow, --base without --create, invalid default branch) /// are printed during planning since they're informational, not blocking. -pub fn plan_switch( +fn plan_switch( repo: &Repository, branch: &str, create: bool, @@ -871,7 +872,7 @@ pub fn plan_switch( /// `SwitchBranchInfo` has `expected_path: None` — callers fill it in after /// first output to avoid computing path mismatch on the hot path. /// For `SwitchPlan::Create`, creates the worktree and runs hooks. -pub fn execute_switch( +fn execute_switch( repo: &Repository, plan: SwitchPlan, config: &UserConfig, @@ -1274,10 +1275,8 @@ impl SwitchJsonOutput { /// Emit the structured `--format=json` result to stdout when requested. /// -/// Shared by the argument path and the interactive picker so `wt switch -/// --format=json` produces identical output regardless of how the branch -/// was chosen. A no-op for `SwitchFormat::Text`. -pub(crate) fn emit_switch_json( +/// A no-op for `SwitchFormat::Text`. +fn emit_switch_json( format: SwitchFormat, result: &SwitchResult, branch_info: &SwitchBranchInfo, @@ -1317,7 +1316,7 @@ pub struct SwitchOptions<'a> { /// Directional vars: /// - `base` / `base_worktree_path`: current (source) branch and worktree /// - `target` / `target_worktree_path`: destination branch and worktree (if it exists) -pub(crate) fn run_pre_switch_hooks( +fn run_pre_switch_hooks( repo: &Repository, config: &UserConfig, target_branch: &str, @@ -1434,7 +1433,7 @@ fn base_ref_for_create( /// May `git fetch` a `pr:`/`mr:` fork head ref — `wt switch pr:`/`mr:` is the /// user explicitly invoking network work, so fetching at the approval gate is /// in keeping; `execute_switch` re-fetches (idempotent). -pub(crate) fn switch_hook_project_config( +fn switch_hook_project_config( repo: &Repository, plan: &SwitchPlan, ) -> anyhow::Result> { @@ -1481,7 +1480,7 @@ fn hook_repo_for_worktree(worktree_path: &Path) -> anyhow::Result { /// Returns `(hooks_approved, plan)`. `hooks_approved` is `false` and the plan /// empty when `!verify` or the user declined; the covered switch hooks /// (`pre-start` / `post-start` / `post-switch`) execute only from `plan`. -pub(crate) fn approve_switch_hooks( +fn approve_switch_hooks( repo: &Repository, config: &UserConfig, plan: &SwitchPlan, @@ -1521,7 +1520,7 @@ pub(crate) fn approve_switch_hooks( } /// Spawn post-switch (and post-start for creates) background hooks. -pub(crate) fn spawn_switch_background_hooks( +fn spawn_switch_background_hooks( config: &UserConfig, result: &SwitchResult, branch: Option<&str>, @@ -1587,6 +1586,313 @@ fn capture_switch_source(repo: &Repository, is_recovered: bool) -> (String, Stri (source_branch, source_path) } +/// The full switch sequence shared by the argument path ([`run_switch`]) and +/// the interactive picker. +/// +/// Each caller only resolves a branch identifier and loads config; everything +/// else runs in [`SwitchPipeline::run`] — the bare-repo path-fix offer, +/// pre-switch hooks, source-identity capture, `plan_switch` → +/// `approve_switch_hooks` → `validate_switch_templates` → `execute_switch` → +/// output → background hooks → `--execute`. One sequence, so the two entry +/// points cannot drift. In particular the single `verify` / `yes` pair gates +/// every hook, so the picker and the argument path cannot diverge on hook +/// approval — the picker once auto-approved `pre-switch` hooks because it kept +/// its own copy of that call. +/// +/// The picker-vs-argument differences are field values, not separate code: the +/// picker passes `verify: true`, `yes: false`, `capture_source: false`, +/// `suggestion_ctx: None`, `execute: None`, and `shell_integration_binary: +/// None`. +pub(crate) struct SwitchPipeline<'a> { + pub repo: &'a Repository, + /// Mutable because the bare-repo path-fix offer + /// (`offer_bare_repo_worktree_path_fix`) and the shell-integration offer + /// (`prompt_shell_integration`) record onto it; every other step reborrows + /// it shared. + pub config: &'a mut UserConfig, + /// Branch identifier — a CLI argument or the picker's selection. Symbolic + /// forms (`-`, `@`, `pr:`/`mr:`) are resolved downstream by `plan_switch`. + pub identifier: &'a str, + pub create: bool, + pub base: Option<&'a str>, + pub clobber: bool, + pub verify: bool, + /// `--yes`: skip approval prompts and force past clobber checks. + pub yes: bool, + pub change_dir: bool, + pub format: SwitchFormat, + /// True when `current_or_recover` recovered from a deleted CWD. Suppresses + /// pre-switch hooks (no source worktree to run them against) and source + /// capture. + pub is_recovered: bool, + /// Error-enrichment context for a failed `plan_switch`, so the hint + /// suggests the full `wt switch … --execute=… -- …`. `None` for the + /// picker, which has no `--execute`. + pub suggestion_ctx: Option, + /// Whether to capture the source worktree's branch/root before the switch, + /// for post-switch `{{ base }}` / `{{ base_worktree_path }}`. The argument + /// path captures; the picker does not — it does not track where the user + /// came from, so an existing switch's base vars stay unset. + pub capture_source: bool, + /// `--execute` command and its trailing args. `None` / empty for the picker. + pub execute: Option<&'a str>, + pub execute_args: &'a [String], + /// Binary name for the shell-integration offer. `Some` only on the argument + /// path; the picker does not offer shell integration. + pub shell_integration_binary: Option<&'a str>, +} + +impl SwitchPipeline<'_> { + /// Plan, approve, execute, and report the switch, then spawn its + /// background hooks and run any `--execute` command. + pub(crate) fn run(self) -> anyhow::Result<()> { + let Self { + repo, + config, + identifier, + create, + base, + clobber, + verify, + yes, + change_dir, + format, + is_recovered, + suggestion_ctx, + capture_source, + execute, + execute_args, + shell_integration_binary, + } = self; + + // Offer to fix worktree-path for bare repos with hidden directory names + // (.git, .bare) before anything reads worktree-path config. + offer_bare_repo_worktree_path_fix(repo, config)?; + + // Run pre-switch hooks before branch resolution or worktree creation. + // {{ branch }} receives the raw user input (before resolution). Skip + // when recovered — the source worktree is gone, nothing to run hooks + // against. `yes` is the single switch-wide flag, so the picker (no + // `--yes`) and the argument path gate `pre-switch` hooks identically. + if verify && !is_recovered { + run_pre_switch_hooks(repo, config, identifier, yes)?; + } + + // Capture source (base) worktree identity BEFORE the switch, for + // post-switch {{ base }} / {{ base_worktree_path }}. Done here — after + // pre-switch hooks, before plan / approve / validate, none of which + // move the current worktree. The picker passes `capture_source: false`; + // it does not track where the user came from. + let (source_branch, source_path) = if capture_source { + capture_switch_source(repo, is_recovered) + } else { + (String::new(), String::new()) + }; + + // Validate and resolve the target branch. + let plan = plan_switch(repo, identifier, create, base, clobber, config).map_err(|err| { + match suggestion_ctx { + Some(ref ctx) => match err.downcast::() { + Ok(git_err) => GitError::WithSwitchSuggestion { + source: Box::new(git_err), + ctx: ctx.clone(), + } + .into(), + Err(err) => err, + }, + None => err, + } + })?; + + // Resolve the `.config/wt.toml` the post-switch hooks will run against — + // the new/destination worktree's (for `--create`, the base ref's, read + // via `git show` since the worktree doesn't exist yet). Computed once so + // the approval prompt and the template pre-flight below agree with what + // `execute_switch` / `spawn_switch_background_hooks` resolve at run time. + // (May `git fetch` a `pr:`/`mr:` fork head — explicit network work.) + // + // Skip entirely when `--no-hooks` / `--no-verify` is in effect: with + // hooks disabled, the result is never used, and resolving it could fetch + // from a PR ref or read a primary `.config/wt.toml` the user asked us + // not to touch. + let hook_project_config = if verify { + switch_hook_project_config(repo, &plan)? + } else { + None + }; + + // "Approve at the Gate": collect and approve hooks upfront. Approval + // happens once at the command entry point. If the user declines, skip + // hooks but continue with the worktree operation. + let (hooks_approved, hook_plan) = approve_switch_hooks( + repo, + config, + &plan, + yes, + verify, + hook_project_config.as_ref(), + )?; + + // Pre-flight: validate all templates before mutation (worktree + // creation). Catches syntax errors and undefined variables early so a + // broken template doesn't leave behind a half-created worktree that + // blocks re-running. + validate_switch_templates( + repo, + config, + &plan, + execute, + execute_args, + hooks_approved, + hook_project_config.as_ref(), + )?; + + // Execute the validated plan. + let (result, branch_info) = + execute_switch(repo, plan, config, yes, hooks_approved, &hook_plan)?; + + // --format=json: write structured result to stdout. All behavior + // (hooks, --execute, shell integration) proceeds normally — format only + // affects output. + emit_switch_json(format, &result, &branch_info)?; + + // Early exit for benchmarking time-to-first-output. + if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { + return Ok(()); + } + + // Compute path mismatch lazily (deferred from plan_switch for existing + // worktrees). Skip detached HEAD worktrees (branch is None) — no branch + // to compute the expected path from. + let branch_info = match &result { + SwitchResult::Existing { path } | SwitchResult::AlreadyAt(path) => { + let expected_path = branch_info + .branch + .as_deref() + .and_then(|b| path_mismatch(repo, b, path, config)); + SwitchBranchInfo { + expected_path, + ..branch_info + } + } + _ => branch_info, + }; + + // Show success message (temporal locality: immediately after the + // worktree operation). Returns the path to display in hooks when the + // user's shell won't be in the worktree, and shows the worktree-path + // hint on first --create (before the shell integration warning). + // + // When the user's CWD has been deleted, `std::env::current_dir()` + // fails — fall back to `repo_path()` (the main worktree root). + // `current_worktree().root()` resolves against the Repository's + // discovery path, which is alive even after recovery, but we keep the + // same fallback for any pathological case where rev-parse fails. + let fallback_path = repo.repo_path()?.to_path_buf(); + let cwd = std::env::current_dir().unwrap_or(fallback_path.clone()); + let source_root = repo.current_worktree().root().unwrap_or(fallback_path); + let hooks_display_path = + handle_switch_output(&result, &branch_info, change_dir, Some(&source_root), &cwd)?; + + // Offer shell integration if not already installed/active (only shows + // the prompt/hint when shell integration isn't working). With + // --execute, show hints only — don't interrupt with a prompt. Skip when + // change_dir is false (the user opted out of cd, so shell integration + // is irrelevant) and on the picker path (no `binary_name`). + // Best-effort: don't fail the switch if the offer fails. + if let Some(binary_name) = shell_integration_binary + && change_dir + && !is_shell_integration_active() + { + let skip_prompt = execute.is_some(); + let _ = prompt_shell_integration(repo, config, binary_name, skip_prompt); + } + + // Build template vars for base/target context (used by both hooks and + // --execute). "base" is the source worktree the user switched from (all + // switches), or the branch they branched from (creates). "target" + // matches the bare vars (the destination) — kept symmetric with + // pre-switch. + let template_vars = + TemplateVars::for_post_switch(&result, &branch_info, &source_branch, &source_path); + let extra_vars = template_vars.as_extra_vars(); + + // Spawn background hooks after the success message. + // - post-switch: runs on ALL switches (shows "@ path" when the shell + // won't be there) + // - post-start: runs only when creating a NEW worktree + if hooks_approved { + spawn_switch_background_hooks( + config, + &result, + branch_info.branch.as_deref(), + yes, + &extra_vars, + hooks_display_path.as_deref(), + &hook_plan, + )?; + } + + // Execute the user command after post-start hooks have been spawned. + // Note: execute_args requires execute via clap's `requires` attribute. + if let Some(cmd) = execute { + // Build template context for expansion (includes base vars when + // creating). + let ctx = CommandContext::new( + repo, + config, + branch_info.branch.as_deref(), + result.path(), + yes, + ); + let template_vars = build_hook_context(&ctx, &extra_vars, None)?; + let vars: HashMap<&str, &str> = template_vars + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + // The `--execute` payload is parsed by the active directive shell: + // the PowerShell wrapper `Invoke-Expression`s the EXEC directive + // file, every other wrapper (and the direct `sh -c` non-integration + // path) is POSIX. Escape interpolated values for whichever it is — + // the one place the escaping is shell-aware (hooks/aliases stay + // POSIX). See `worktrunk::shell_exec::directive_shell_escape_mode`. + let escape_mode = directive_shell_escape_mode(); + + // Expand template variables in command, escaped for the directive shell. + let expanded_cmd = expand_template(cmd, &vars, escape_mode, repo, "--execute command")?; + + // Append any trailing args (after --) to the execute command. + // Each arg is template-expanded literally, then escaped for the + // directive shell so the wrapper parses it as one literal argument. + let full_cmd = if execute_args.is_empty() { + expanded_cmd + } else { + let expanded_args: Result, _> = execute_args + .iter() + .map(|arg| { + expand_template( + arg, + &vars, + ShellEscapeMode::Literal, + repo, + "--execute argument", + ) + }) + .collect(); + let escaped_args: Vec<_> = expanded_args? + .iter() + .map(|arg| shell_escape_for(escape_mode, arg)) + .collect(); + format!("{} {}", expanded_cmd, escaped_args.join(" ")) + }; + execute_user_command(&full_cmd, hooks_display_path.as_deref())?; + } + + Ok(()) + } +} + /// Handle the switch command. pub fn run_switch( opts: SwitchOptions<'_>, @@ -1626,211 +1932,25 @@ pub fn run_switch( } }); - // Run pre-switch hooks before branch resolution or worktree creation. - // {{ branch }} receives the raw user input (before resolution). - // Skip when recovered — the source worktree is gone, nothing to run hooks against. - if verify && !is_recovered { - run_pre_switch_hooks(&repo, config, branch, yes)?; - } - - // Offer to fix worktree-path for bare repos with hidden directory names (.git, .bare). - offer_bare_repo_worktree_path_fix(&repo, config)?; - - // Validate and resolve the target branch. - let plan = plan_switch(&repo, branch, create, base, clobber, config).map_err(|err| { - match suggestion_ctx { - Some(ref ctx) => match err.downcast::() { - Ok(git_err) => GitError::WithSwitchSuggestion { - source: Box::new(git_err), - ctx: ctx.clone(), - } - .into(), - Err(err) => err, - }, - None => err, - } - })?; - - // Resolve the `.config/wt.toml` the post-switch hooks will run against — - // the new/destination worktree's (for `--create`, the base ref's, read via - // `git show` since the worktree doesn't exist yet). Computed once so the - // approval prompt and the template pre-flight below agree with what - // `execute_switch` / `spawn_switch_background_hooks` resolve at run time. - // (May `git fetch` a `pr:`/`mr:` fork head — explicit network work.) - // - // Skip entirely when `--no-hooks` / `--no-verify` is in effect: with hooks - // disabled, the result is never used, and resolving it could fetch from a - // PR ref or read a primary `.config/wt.toml` the user asked us not to - // touch. - let hook_project_config = if verify { - switch_hook_project_config(&repo, &plan)? - } else { - None - }; - - // "Approve at the Gate": collect and approve hooks upfront - // This ensures approval happens once at the command entry point - // If user declines, skip hooks but continue with worktree operation - let (hooks_approved, hook_plan) = approve_switch_hooks( - &repo, + SwitchPipeline { + repo: &repo, config, - &plan, - yes, + identifier: branch, + create, + base, + clobber, verify, - hook_project_config.as_ref(), - )?; - - // Pre-flight: validate all templates before mutation (worktree creation). - // Catches syntax errors and undefined variables early so a broken template - // doesn't leave behind a half-created worktree that blocks re-running. - validate_switch_templates( - &repo, - config, - &plan, + yes, + change_dir, + format, + is_recovered, + suggestion_ctx, + capture_source: true, execute, execute_args, - hooks_approved, - hook_project_config.as_ref(), - )?; - - // Capture source (base) worktree identity BEFORE the switch, so post-switch - // hooks can reference where the user came from via {{ base }} / {{ base_worktree_path }}. - let (source_branch, source_path) = capture_switch_source(&repo, is_recovered); - - // Execute the validated plan - let (result, branch_info) = - execute_switch(&repo, plan, config, yes, hooks_approved, &hook_plan)?; - - // --format=json: write structured result to stdout. All behavior (hooks, - // --execute, shell integration) proceeds normally — format only affects output. - emit_switch_json(format, &result, &branch_info)?; - - // Early exit for benchmarking time-to-first-output - if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() { - return Ok(()); - } - - // Compute path mismatch lazily (deferred from plan_switch for existing worktrees). - // Skip for detached HEAD worktrees (branch is None) — no branch to compute expected path from. - let branch_info = match &result { - SwitchResult::Existing { path } | SwitchResult::AlreadyAt(path) => { - let expected_path = branch_info - .branch - .as_deref() - .and_then(|b| path_mismatch(&repo, b, path, config)); - SwitchBranchInfo { - expected_path, - ..branch_info - } - } - _ => branch_info, - }; - - // Show success message (temporal locality: immediately after worktree operation) - // Returns path to display in hooks when user's shell won't be in the worktree - // Also shows worktree-path hint on first --create (before shell integration warning) - // - // When the user's CWD has been deleted, `std::env::current_dir()` fails — - // fall back to `repo_path()` (the main worktree root). `current_worktree() - // .root()` resolves against the Repository's discovery path, which is alive - // even after recovery, but we keep the same fallback for any pathological - // case where rev-parse fails. - let fallback_path = repo.repo_path()?.to_path_buf(); - let cwd = std::env::current_dir().unwrap_or(fallback_path.clone()); - let source_root = repo.current_worktree().root().unwrap_or(fallback_path); - let hooks_display_path = - handle_switch_output(&result, &branch_info, change_dir, Some(&source_root), &cwd)?; - - // Offer shell integration if not already installed/active - // (only shows prompt/hint when shell integration isn't working) - // With --execute: show hints only (don't interrupt with prompt) - // Skip when change_dir is false — user opted out of cd, so shell integration is irrelevant - // Best-effort: don't fail switch if offer fails - if change_dir && !is_shell_integration_active() { - let skip_prompt = execute.is_some(); - let _ = prompt_shell_integration(&repo, config, binary_name, skip_prompt); + shell_integration_binary: Some(binary_name), } - - // Build template vars for base/target context (used by both hooks and - // --execute). "base" is the source worktree the user switched from (all - // switches), or the branch they branched from (creates). "target" matches - // the bare vars (the destination) — kept symmetric with pre-switch. - let template_vars = - TemplateVars::for_post_switch(&result, &branch_info, &source_branch, &source_path); - let extra_vars = template_vars.as_extra_vars(); - - // Spawn background hooks after success message - // - post-switch: runs on ALL switches (shows "@ path" when shell won't be there) - // - post-start: runs only when creating a NEW worktree - // Batch hooks into a single message when both types are present - if hooks_approved { - spawn_switch_background_hooks( - config, - &result, - branch_info.branch.as_deref(), - yes, - &extra_vars, - hooks_display_path.as_deref(), - &hook_plan, - )?; - } - - // Execute user command after post-start hooks have been spawned - // Note: execute_args requires execute via clap's `requires` attribute - if let Some(cmd) = execute { - // Build template context for expansion (includes base vars when creating) - let ctx = CommandContext::new( - &repo, - config, - branch_info.branch.as_deref(), - result.path(), - yes, - ); - let template_vars = build_hook_context(&ctx, &extra_vars, None)?; - let vars: HashMap<&str, &str> = template_vars - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - - // The `--execute` payload is parsed by the active directive shell: the - // PowerShell wrapper `Invoke-Expression`s the EXEC directive file, every - // other wrapper (and the direct `sh -c` non-integration path) is POSIX. - // Escape interpolated values for whichever it is — the one place the - // escaping is shell-aware (hooks/aliases stay POSIX). See - // `worktrunk::shell_exec::directive_shell_escape_mode`. - let escape_mode = directive_shell_escape_mode(); - - // Expand template variables in command, escaped for the directive shell. - let expanded_cmd = expand_template(cmd, &vars, escape_mode, &repo, "--execute command")?; - - // Append any trailing args (after --) to the execute command. - // Each arg is template-expanded literally, then escaped for the - // directive shell so the wrapper parses it as one literal argument. - let full_cmd = if execute_args.is_empty() { - expanded_cmd - } else { - let expanded_args: Result, _> = execute_args - .iter() - .map(|arg| { - expand_template( - arg, - &vars, - ShellEscapeMode::Literal, - &repo, - "--execute argument", - ) - }) - .collect(); - let escaped_args: Vec<_> = expanded_args? - .iter() - .map(|arg| shell_escape_for(escape_mode, arg)) - .collect(); - format!("{} {}", expanded_cmd, escaped_args.join(" ")) - }; - execute_user_command(&full_cmd, hooks_display_path.as_deref())?; - } - - Ok(()) + .run() } /// Validate all templates that will be expanded after worktree creation. @@ -1867,7 +1987,7 @@ pub fn run_switch( /// - `--execute` command template (if present) /// - `--execute` trailing arg templates (if present) /// - Hook templates (pre-start, post-start, post-switch) from user and project config -pub(crate) fn validate_switch_templates( +fn validate_switch_templates( repo: &Repository, config: &UserConfig, plan: &SwitchPlan, diff --git a/tests/integration_tests/switch_picker.rs b/tests/integration_tests/switch_picker.rs index 68b8210e95..f18446876d 100644 --- a/tests/integration_tests/switch_picker.rs +++ b/tests/integration_tests/switch_picker.rs @@ -1235,3 +1235,54 @@ fn test_switch_picker_no_cd_switches_without_cd_directive(mut repo: TestRepo) { cd_content ); } + +/// A project `pre-switch` hook must pass through the approval gate when the +/// picker switches — the picker has no `--yes`, so an unapproved project +/// command is shown for approval, never auto-run. +/// +/// Regression: the picker previously passed `yes = true` to +/// `run_pre_switch_hooks`, silently executing project `pre-switch` commands +/// without a prompt — inconsistent with every other hook the picker gates, and +/// a hole in "Project Commands Run Only After Approval". Here the hook is +/// declined at the prompt; it must not run, and the switch must still succeed. +#[rstest] +fn test_switch_picker_pre_switch_hook_requires_approval(mut repo: TestRepo) { + repo.remove_fixture_worktrees(); + repo.run_git(&["remote", "remove", "origin"]); + repo.add_worktree("target-branch"); + + // Project `pre-switch` hook (in `.config/wt.toml`, so it routes through the + // approval gate) that touches a marker outside the worktree if it runs. + let marker_dir = tempfile::tempdir().unwrap(); + let marker = marker_dir.path().join("pre-switch-ran"); + repo.write_project_config(&format!( + "pre-switch = {:?}\n", + format!("touch {}", marker.display()) + )); + + let env_vars = repo.test_env_vars(); + // Select target-branch, press Enter, then decline the approval prompt. + let result = exec_in_pty_with_input_expectations( + wt_bin().to_str().unwrap(), + &["switch"], + repo.root_path(), + &env_vars, + &[ + // Preview-pane gate: see test_switch_picker_emits_cd_directive_by_default. + ("target", Some("target-branch has no uncommitted changes")), + ("\r", Some("needs approval")), // Enter; wait for the approval prompt + ("n\n", None), // decline + ], + ); + + let screen = result.screen(); + assert_eq!( + result.exit_code, 0, + "switch should still succeed after declining the pre-switch hook.\nScreen:\n{screen}" + ); + assert!( + screen.contains("needs approval"), + "picker must prompt before running a project pre-switch hook.\nScreen:\n{screen}" + ); + assert!(!marker.exists(), "a declined pre-switch hook must not run"); +} From d73ea84d4839b24c021c27796338d485f0297333 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 10:40:51 -0700 Subject: [PATCH 15/34] feat(switch): deprecate shell command lines in --execute (#2852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What A future release will switch `wt switch --execute` (`-x`) to an argv input model: a single program name, with arguments after `--` passed verbatim, run via `execvp` with no implicit shell. That is a breaking change to a protected CLI interface, so it ships as a two-release deprecation — **this PR is the warn phase.** `validate_switch_templates` now emits a deprecation warning when the `-x` value is not a single program token — it contains shell syntax, multiple words, or `{{ }}` template markup. The hint shows the concrete, copy-pasteable migration: ``` ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line ↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hi && ls' ``` `sh` is itself a single program token, so the suggested form works today, does not warn, and survives the cutover. A single program name — including a path — stays silent. The `-x` help examples in `src/cli/mod.rs` move to the argv-compatible form (`-x code -- '{{ worktree_path }}'`) so the docs no longer recommend a form that warns. ## Why no PATH check The classifier is purely structural — it decides whether the value is one bare program token, nothing more. It deliberately does not check whether a bare name resolves to a real executable. An earlier iteration did, to also warn on `-x my-alias`, but a PATH lookup at pre-flight is environment-sensitive, runs in the source worktree rather than where `-x` will execute, and cannot distinguish a shell alias from a typo or an uninstalled tool. A bare alias/function `-x` is left to fail loudly (`execvp` → `ENOENT`) at the cutover rather than guessed at here. The warning still catches every multi-word / shell-syntax form, which is what users actually write. ## Testing `cargo run -- hook pre-merge --yes` — 3799 tests pass; clippy, fmt, and pre-commit hooks green. New: a unit test for the token classifier and an integration test covering the warn and no-warn cases. Most of the 32-file diff is snapshot regeneration — the warning is new stderr output on existing `-x` tests. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/content/switch.md | 4 +- skills/worktrunk/reference/switch.md | 4 +- src/cli/mod.rs | 4 +- src/commands/worktree/switch.rs | 118 ++++++++++++++++++ tests/integration_tests/shell_wrapper.rs | 17 +-- tests/integration_tests/switch.rs | 56 +++++++++ ...irectives__switch_exec_scrubbed_warns.snap | 14 +++ ...ch_legacy_directive_file_with_execute.snap | 14 +++ ...gration_tests__help__help_switch_long.snap | 2 +- ...art_commands__execute_with_post_start.snap | 2 + ...sts__fish_multiline_command_execution.snap | 2 + ...unix_tests__switch_with_execute_bash.snap} | 0 ..._unix_tests__switch_with_execute_fish.snap | 10 ++ ...r__unix_tests__switch_with_execute_nu.snap | 10 ++ ...__switch_with_execute_through_wrapper.snap | 2 + ...__unix_tests__switch_with_execute_zsh.snap | 10 ++ ...__switch__switch_execute_creates_file.snap | 14 +++ ...switch_execute_deprecation_compatible.snap | 66 ++++++++++ ...itch_execute_deprecation_fish_wrapper.snap | 69 ++++++++++ ...itch_execute_deprecation_shell_syntax.snap | 69 ++++++++++ ...tch_execute_deprecation_trailing_args.snap | 70 +++++++++++ ...ests__switch__switch_execute_existing.snap | 14 +++ ...tests__switch__switch_execute_failure.snap | 14 +++ ...sts__switch__switch_execute_multiline.snap | 16 +++ ...tests__switch__switch_execute_success.snap | 14 +++ ..._switch__switch_execute_template_base.snap | 14 +++ ..._execute_template_base_without_create.snap | 14 +++ ...witch__switch_execute_template_branch.snap | 14 +++ ..._switch_execute_template_shell_escape.snap | 14 +++ ...__switch_execute_template_with_filter.snap | 14 +++ ...switch_execute_template_worktree_path.snap | 14 +++ ...ch_execute_verbose_multiline_template.snap | 17 +++ ...itch__switch_execute_verbose_template.snap | 14 +++ ...ch__switch_internal_execute_exit_code.snap | 14 +++ ...tch_internal_execute_output_then_exit.snap | 15 +++ ..._switch__switch_internal_with_execute.snap | 15 +++ ...h__switch_no_hooks_execute_still_runs.snap | 14 +++ ...sts__switch__switch_no_hooks_existing.snap | 14 +++ 38 files changed, 788 insertions(+), 14 deletions(-) rename tests/snapshots/{integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute.snap => integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_bash.snap} (100%) create mode 100644 tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_fish.snap create mode 100644 tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_nu.snap create mode 100644 tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_zsh.snap create mode 100644 tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_compatible.snap create mode 100644 tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap create mode 100644 tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap create mode 100644 tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap diff --git a/docs/content/switch.md b/docs/content/switch.md index 129987b786..b45a4c4744 100644 --- a/docs/content/switch.md +++ b/docs/content/switch.md @@ -172,8 +172,8 @@ Usage: wt switch [OPTIONS] wsc feature -- 'Fix GH #322' runs claude 'Fix GH #322', starting Claude with a prompt. - Template example: -x 'code {{ worktree_path }}' opens VS Code at the worktree, -x 'tmux - new -s {{ branch | sanitize }}' starts a tmux session named after the branch. + Template example: -x code -- '{{ worktree_path }}' opens VS Code at the worktree, -x tmux + -- new -s '{{ branch | sanitize }}' starts a tmux session named after the branch. --clobber Remove stale paths at target diff --git a/skills/worktrunk/reference/switch.md b/skills/worktrunk/reference/switch.md index b8106691a6..3c7e5fc4f2 100644 --- a/skills/worktrunk/reference/switch.md +++ b/skills/worktrunk/reference/switch.md @@ -164,8 +164,8 @@ Options: are passed to the command, so wsc feature -- 'Fix GH #322' runs claude 'Fix GH #322', starting Claude with a prompt. - Template example: -x 'code {{ worktree_path }}' opens VS Code at the worktree, -x 'tmux - new -s {{ branch | sanitize }}' starts a tmux session named after the branch. + Template example: -x code -- '{{ worktree_path }}' opens VS Code at the worktree, -x tmux + -- new -s '{{ branch | sanitize }}' starts a tmux session named after the branch. --clobber Remove stale paths at target diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e26a114220..c5b1db1647 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -316,8 +316,8 @@ pub(crate) struct SwitchArgs { /// `wsc feature -- 'Fix GH #322'` runs `claude 'Fix GH #322'`, /// starting Claude with a prompt. /// - /// Template example: `-x 'code {{ worktree_path }}'` opens VS Code - /// at the worktree, `-x 'tmux new -s {{ branch | sanitize }}'` starts + /// Template example: `-x code -- '{{ worktree_path }}'` opens VS Code + /// at the worktree, `-x tmux -- new -s '{{ branch | sanitize }}'` starts /// a tmux session named after the branch. #[arg(short = 'x', long, requires = "branch")] pub(crate) execute: Option, diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index c0d4715c65..56c1c7bd94 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -1953,6 +1953,84 @@ pub fn run_switch( .run() } +/// Whether `value` is a single clean program-name token — the form `--execute` +/// keeps accepting unchanged once it switches to the argv input model. +/// +/// First character `[A-Za-z0-9._/@]`, the rest additionally `+`/`-`. On +/// Windows, `\` and `:` are also allowed so native paths (`C:\dir\tool`) are +/// not flagged; on POSIX they are shell metacharacters and stay excluded. +/// This rejects a leading `-`/`+` (an option-like `argv[0]` resolves +/// differently), whitespace, `{{ }}` template markup, and every shell +/// metacharacter — any of which means the value is not a bare program name. +fn is_clean_program_token(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + let first_ok = first.is_ascii_alphanumeric() + || matches!(first, '.' | '_' | '/' | '@') + || (cfg!(windows) && first == '\\'); + first_ok + && chars.all(|c| { + c.is_ascii_alphanumeric() + || matches!(c, '.' | '_' | '/' | '@' | '+' | '-') + || (cfg!(windows) && matches!(c, '\\' | ':')) + }) +} + +/// Warn when a `--execute` value will change behavior under the upcoming argv +/// input model. +/// +/// A future release runs `-x` as a single program (with arguments after `--`), +/// not a shell command line. Warn now for any value that is not a single +/// program token — shell syntax, multiple words, or `{{ }}` markup (flagged +/// conservatively, even when it would expand to a clean name). +/// +/// A single program token — including a path — is unaffected and stays silent. +/// A bare name that is really a shell alias/function/builtin is not detectable +/// here without the user's shell, so it is left to fail loudly at the cutover +/// rather than guessed at. Informational only — it never blocks the switch. +/// +/// The migration hint reconstructs the command line that runs today — the +/// `-x` value with its trailing args appended, the way `run_switch` joins +/// them — and wraps it for whichever shell the active wrapper evaluates the +/// payload with (`sh` / `fish` / `pwsh`). That keeps the suggestion +/// behavior-preserving on fish/PowerShell, not just POSIX sh, and complete +/// when trailing args are present. +fn warn_if_execute_form_deprecated(cmd: &str, execute_args: &[String]) { + if is_clean_program_token(cmd) { + return; + } + let mode = directive_shell_escape_mode(); + let (shell, flag) = match mode { + ShellEscapeMode::PowerShell => ("pwsh", "-Command"), + ShellEscapeMode::Fish => ("fish", "-c"), + _ => ("sh", "-c"), + }; + let command_line = if execute_args.is_empty() { + cmd.to_string() + } else { + let escaped: Vec = execute_args + .iter() + .map(|arg| shell_escape_for(mode, arg)) + .collect(); + format!("{} {}", cmd, escaped.join(" ")) + }; + let suggested = shell_escape_for(mode, &command_line); + eprintln!( + "{}", + warning_message(cformat!( + "--execute will change in a future release: it will run a single program, with arguments after --, not a shell command line" + )) + ); + eprintln!( + "{}", + hint_message(cformat!( + "To run this command line unchanged, pass it to a shell: --execute {shell} -- {flag} {suggested}" + )) + ); +} + /// Validate all templates that will be expanded after worktree creation. /// /// Catches syntax errors and undefined variable references *before* the @@ -2012,6 +2090,7 @@ fn validate_switch_templates( "--execute argument", )?; } + warn_if_execute_form_deprecated(cmd, execute_args); } // Validate hook templates only when hooks will actually run @@ -2056,6 +2135,45 @@ mod tests { use super::*; use worktrunk::testing::TestRepo; + #[test] + fn is_clean_program_token_matches_only_bare_names() { + // Bare program names — unchanged under the argv input model. + for ok in [ + "git", + "claude", + "node18", + "my-tool", + "tool.sh", + "/usr/bin/env", + "./build", + "_x", + "@scope/pkg", + ] { + assert!(is_clean_program_token(ok), "expected clean token: {ok:?}"); + } + // Not bare names — empty, whitespace, shell syntax, template markup, + // or an option-like leading character. + for bad in [ + "", + "npm run dev", + "a && b", + "echo $HOME", + "code {{ worktree_path }}", + "a|b", + "-flag", + "+x", + ] { + assert!(!is_clean_program_token(bad), "expected non-token: {bad:?}"); + } + // A native Windows path is a clean token only when targeting Windows; + // on POSIX, `\` and `:` are shell metacharacters. + assert_eq!( + is_clean_program_token(r"C:\Tools\foo.exe"), + cfg!(windows), + "Windows path classification should follow the target OS" + ); + } + #[test] fn capture_switch_source_returns_empty_when_recovered() { // When recovered from a deleted CWD, post-switch hooks must see empty diff --git a/tests/integration_tests/shell_wrapper.rs b/tests/integration_tests/shell_wrapper.rs index c518d03ef5..8c36ff02b1 100644 --- a/tests/integration_tests/shell_wrapper.rs +++ b/tests/integration_tests/shell_wrapper.rs @@ -952,7 +952,8 @@ mod unix_tests { #[case("fish")] #[case("nu")] fn test_wrapper_switch_with_execute(#[case] shell: &str, repo: TestRepo) { - // Use --yes to skip approval prompt in tests + // `echo` with `executed` as a trailing arg, run through each wrapper. + // `--yes` skips the approval prompt. let output = exec_through_wrapper( shell, &repo, @@ -960,9 +961,11 @@ mod unix_tests { &[ "--create", "test-exec", - "--execute", - "echo executed", "--yes", + "--execute", + "echo", + "--", + "executed", ], ); @@ -976,11 +979,11 @@ mod unix_tests { shell ); - // Consolidated snapshot - output should be identical across all shells + // Per-shell snapshot: the `Executing (--execute):` line escapes the + // trailing arg for the directive shell, so fish differs from the + // POSIX wrappers — output is no longer identical across shells. shell_wrapper_settings().bind(|| { - insta::allow_duplicates! { - assert_snapshot!("switch_with_execute", &output.combined); - } + assert_snapshot!(format!("switch_with_execute_{shell}"), &output.combined); }); } diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index c19147d3b6..9556d10560 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -668,6 +668,62 @@ fn test_switch_with_execute_trailing_args_metachars_fish(repo: TestRepo) { ); }); } + +/// A `--execute` value that is a shell command line rather than a single +/// program name gets a deprecation warning for the upcoming argv input model; +/// a single program name stays silent. +#[rstest] +fn test_switch_execute_argv_deprecation_warning(repo: TestRepo) { + let (cd_path, exec_path, _guard) = directive_files(); + let settings = setup_snapshot_settings(&repo); + settings.bind(|| { + // Shell syntax / multiple words — not a single program name. + let mut cmd = make_snapshot_cmd( + &repo, + "switch", + &["--create", "dep-shell", "--execute", "echo hi && ls"], + None, + ); + configure_directive_files(&mut cmd, &cd_path, &exec_path); + assert_cmd_snapshot!("switch_execute_deprecation_shell_syntax", cmd); + + // A single program name — unaffected, no warning. + let mut cmd = make_snapshot_cmd( + &repo, + "switch", + &["--create", "dep-ok", "--execute", "git"], + None, + ); + configure_directive_files(&mut cmd, &cd_path, &exec_path); + assert_cmd_snapshot!("switch_execute_deprecation_compatible", cmd); + + // Trailing args are folded into the suggested command line, not dropped. + let mut cmd = make_snapshot_cmd( + &repo, + "switch", + &["--create", "dep-args", "--execute", "npm run", "--", "test"], + None, + ); + configure_directive_files(&mut cmd, &cd_path, &exec_path); + assert_cmd_snapshot!("switch_execute_deprecation_trailing_args", cmd); + + // Under a fish wrapper the suggestion wraps in `fish`, not POSIX `sh`. + let mut cmd = make_snapshot_cmd( + &repo, + "switch", + &[ + "--create", + "dep-fish", + "--execute", + "set -lx FOO bar; echo $FOO", + ], + None, + ); + configure_directive_files(&mut cmd, &cd_path, &exec_path); + cmd.env("WORKTRUNK_SHELL", "fish"); + assert_cmd_snapshot!("switch_execute_deprecation_fish_wrapper", cmd); + }); +} // Error tests #[rstest] fn test_switch_error_missing_worktree_directory(mut repo: TestRepo) { diff --git a/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap b/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap index 3b10e6fa21..bdf0516064 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -34,13 +40,19 @@ info: WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo should-not-run' ✓ Created branch scrub-test from main and worktree @ _REPO_.scrub-test ↳ To customize worktree locations, run wt config create ▲ --execute disabled inside project alias / hook bodies for safety; skipping echo should-not-run diff --git a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap index ff8bf406d9..94abc58584 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -34,13 +40,19 @@ info: WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hello' ✓ Created branch exec-legacy from main and worktree @ _REPO_.exec-legacy ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap index 8ac22d88f2..b7b34f023f 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -75,7 +75,7 @@ Usage: wt switch [OPTIONS] [BRANCH] [--  Then wsc feature-branch creates the worktree and launches Claude Code. Arguments after -- are passed to the command, so wsc feature -- 'Fix GH #322' runs claude 'Fix GH #322', starting Claude with a prompt. - Template example: -x 'code {{ worktree_path }}' opens VS Code at the worktree, -x 'tmux new -s {{ branch | sanitize }}' starts a tmux session named after the branch. + Template example: -x code -- '{{ worktree_path }}' opens VS Code at the worktree, -x tmux -- new -s '{{ branch | sanitize }}' starts a tmux session named after the branch. --clobber Remove stale paths at target diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap b/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap index 70407b37ee..67dddc252b 100644 --- a/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap @@ -59,6 +59,8 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''Execute flag'\'' > execute.txt' ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap index ca3e620db6..d184375554 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap @@ -3,6 +3,8 @@ source: tests/integration_tests/shell_wrapper.rs expression: "&output.combined" --- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute fish -- -c 'echo \'line 1\'; echo \'line 2\'; echo \'line 3\'' ✓ Created branch fish-multiline from main and worktree @ _REPO_.fish-multiline ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_bash.snap similarity index 100% rename from tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute.snap rename to tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_bash.snap diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_fish.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_fish.snap new file mode 100644 index 0000000000..057720d705 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_fish.snap @@ -0,0 +1,10 @@ +--- +source: tests/integration_tests/shell_wrapper.rs +expression: "&output.combined" +--- + +✓ Created branch test-exec from main and worktree @ _REPO_.test-exec +↳ To customize worktree locations, run wt config create +◎ Executing (--execute): +  echo 'executed' +executed diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_nu.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_nu.snap new file mode 100644 index 0000000000..e07d7519cc --- /dev/null +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_nu.snap @@ -0,0 +1,10 @@ +--- +source: tests/integration_tests/shell_wrapper.rs +expression: "&output.combined" +--- + +✓ Created branch test-exec from main and worktree @ _REPO_.test-exec +↳ To customize worktree locations, run wt config create +◎ Executing (--execute): +  echo executed +executed diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap index e07d7519cc..cdca1d39cb 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap @@ -3,6 +3,8 @@ source: tests/integration_tests/shell_wrapper.rs expression: "&output.combined" --- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo executed' ✓ Created branch test-exec from main and worktree @ _REPO_.test-exec ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_zsh.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_zsh.snap new file mode 100644 index 0000000000..e07d7519cc --- /dev/null +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_zsh.snap @@ -0,0 +1,10 @@ +--- +source: tests/integration_tests/shell_wrapper.rs +expression: "&output.combined" +--- + +✓ Created branch test-exec from main and worktree @ _REPO_.test-exec +↳ To customize worktree locations, run wt config create +◎ Executing (--execute): +  echo executed +executed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap index bca2ae910c..acc1eb447d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -47,6 +59,8 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''test content'\'' > test.txt' ✓ Created branch file-test from main and worktree @ _REPO_.file-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_compatible.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_compatible.snap new file mode 100644 index 0000000000..8971c821e9 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_compatible.snap @@ -0,0 +1,66 @@ +--- +source: tests/integration_tests/switch.rs +info: + program: wt + args: + - switch + - "--create" + - dep-ok + - "--execute" + - git + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" + WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Created branch dep-ok from main and worktree @ _REPO_.dep-ok +◎ Executing (--execute): +  git diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap new file mode 100644 index 0000000000..c56ed6d435 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap @@ -0,0 +1,69 @@ +--- +source: tests/integration_tests/switch.rs +info: + program: wt + args: + - switch + - "--create" + - dep-fish + - "--execute" + - set -lx FOO bar; echo $FOO + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" + WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" + WORKTRUNK_SHELL: fish + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute fish -- -c 'set -lx FOO bar; echo $FOO' +✓ Created branch dep-fish from main and worktree @ _REPO_.dep-fish +◎ Executing (--execute): +  set -lx FOO bar; echo $FOO diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap new file mode 100644 index 0000000000..34037c9be2 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap @@ -0,0 +1,69 @@ +--- +source: tests/integration_tests/switch.rs +info: + program: wt + args: + - switch + - "--create" + - dep-shell + - "--execute" + - echo hi && ls + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" + WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hi && ls' +✓ Created branch dep-shell from main and worktree @ _REPO_.dep-shell +↳ To customize worktree locations, run wt config create +◎ Executing (--execute): +  echo hi && ls diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap new file mode 100644 index 0000000000..bf6de2fe85 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap @@ -0,0 +1,70 @@ +--- +source: tests/integration_tests/switch.rs +info: + program: wt + args: + - switch + - "--create" + - dep-args + - "--execute" + - npm run + - "--" + - test + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "500" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" + GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" + GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" + MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" + NO_COLOR: "" + OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" + PATH: "[PATH]" + PSModulePath: "" + RUST_LOG: warn + SHELL: "" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" + WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" + WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" + WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" + WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" + WORKTRUNK_TEST_NUSHELL_ENV: "0" + WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" + WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'npm run test' +✓ Created branch dep-args from main and worktree @ _REPO_.dep-args +◎ Executing (--execute): +  npm run test diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap index 9b58f85b30..a5c3edefd7 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap @@ -13,13 +13,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,13 +38,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -46,6 +58,8 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''existing worktree'\'' > existing.txt' ▲ Worktree for existing-exec @ _REPO_.existing-exec, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install ◎ Executing (--execute) @ _REPO_.existing-exec: diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap index 1d3dc7dcdf..a93111ddf5 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: false @@ -47,6 +59,8 @@ exit_code: 1 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'exit 1' ✓ Created branch fail-test from main and worktree @ _REPO_.fail-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap index 0d1c2ef316..e0d58350a1 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -50,6 +62,10 @@ line2 line3 ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''line1'\'' +echo '\''line2'\'' +echo '\''line3'\''' ✓ Created branch multiline-test from main and worktree @ _REPO_.multiline-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap index 8fa4d2f6c1..b78c74e54a 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 test output ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''test output'\''' ✓ Created branch exec-test from main and worktree @ _REPO_.exec-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap index 88775394e5..d1ef79cc0a 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap @@ -16,13 +16,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -35,13 +41,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -50,6 +62,8 @@ exit_code: 0 base=main ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''base={{ base }}'\''' ✓ Created branch from-main from main and worktree @ _REPO_.from-main ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap index 4d57aea06a..e849a937ea 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap @@ -13,13 +13,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,13 +38,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -47,6 +59,8 @@ exit_code: 0 base=main ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''base={{ base }}'\''' ▲ Worktree for existing @ _REPO_.existing, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install ◎ Executing (--execute) @ _REPO_.existing: diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap index ec16a03893..be59b9fd7d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 branch=template-test ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''branch={{ branch }}'\''' ✓ Created branch template-test from main and worktree @ _REPO_.template-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap index 78fd99559c..ac68496904 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 feat;id ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo {{ branch }}' ✓ Created branch feat;id from main and worktree @ '_REPO_.feat;id' ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap index d10235096b..059fbc36aa 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 sanitized=feature-with-slash ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''sanitized={{ branch | sanitize }}'\''' ✓ Created branch feature/with-slash from main and worktree @ _REPO_.feature-with-slash ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap index e54bd271c1..2353df985b 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 path=_REPO_.path-test ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''path={{ worktree_path }}'\''' ✓ Created branch path-test from main and worktree @ _REPO_.path-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap index 6f75a50e04..643eecf6d3 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap @@ -15,13 +15,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -34,13 +40,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -54,6 +66,11 @@ repo=repo   {{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}   →   _REPO_/../repo.multiline-test +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c '{% if branch %} +echo '\''branch={{ branch }}'\'' +echo '\''repo={{ repo }}'\'' +{% endif %}' ✓ Created branch multiline-test from main and worktree @ _REPO_.multiline-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap index 371513a47d..da7f6bae20 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap @@ -15,13 +15,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -34,13 +40,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -53,6 +65,8 @@ branch=verbose-test   {{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}   →   _REPO_/../repo.verbose-test +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''branch={{ branch }}'\''' ✓ Created branch verbose-test from main and worktree @ _REPO_.verbose-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap index 9a1468a87b..5ec7bad334 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -35,13 +41,19 @@ info: WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -49,6 +61,8 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'exit 42' ✓ Created branch exit-code-test from main and worktree @ _REPO_.exit-code-test ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap index d728c83821..017fbc3c38 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -35,13 +41,19 @@ info: WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -49,6 +61,9 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''doing work'\'' +exit 7' ✓ Created branch output-exit-test from main and worktree @ _REPO_.output-exit-test ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap b/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap index 53fdcfc2fe..96f6889900 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -35,13 +41,19 @@ info: WORKTRUNK_DIRECTIVE_CD_FILE: "[DIRECTIVE_CD_FILE]" WORKTRUNK_DIRECTIVE_EXEC_FILE: "[DIRECTIVE_EXEC_FILE]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -49,6 +61,9 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''line1'\'' +echo '\''line2'\''' ✓ Created branch exec-internal from main and worktree @ _REPO_.exec-internal ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap index ab262a605a..083f3dfb1d 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap @@ -15,13 +15,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -34,13 +40,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -49,6 +61,8 @@ exit_code: 0 execute command runs ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''execute command runs'\''' ✓ Created branch no-hooks-test from main and worktree @ _REPO_.no-hooks-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap index 2c524b310b..3ff221ff38 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap @@ -14,13 +14,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -33,13 +39,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -48,6 +60,8 @@ exit_code: 0 execute still runs ----- stderr ----- +▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line +↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''execute still runs'\''' ▲ Worktree for existing-no-hooks @ _REPO_.existing-no-hooks, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install ◎ Executing (--execute) @ _REPO_.existing-no-hooks: From 2773cfd847d049cb9af1960a66e7af1669cd7d15 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 13:21:49 -0700 Subject: [PATCH 16/34] refactor(config): require an explicit path for config mutations (#2862) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Approvals` and `UserConfig` mutation methods took an `Option<&Path>`: `None` meant "resolve the global config path", `Some(p)` meant "use `p`". `None` was a footgun. A unit test that passed `None` silently wrote to the developer's real `~/.config/worktrunk/`: it passed wherever `$HOME` was writable and failed only in a sandbox with no writable config dir. That is the class of bug behind the recent `nix-flake` failure (#2853). This drops the `Option`. All ten mutation methods (`Approvals::{approve_command, approve_commands, revoke_project, clear_all, with_locked_mutation}` and `UserConfig::{set_skip_shell_integration_prompt, set_skip_commit_generation_prompt, set_project_worktree_path, set_commit_generation_command, with_locked_mutation}`) now take `&Path`. Production callers resolve the path explicitly through two new helpers: `require_approvals_path()` and `require_config_path()`, the `Result`-returning counterparts of `approvals_path()` / `config_path()`, matching the `require_*` accessor convention. A test can no longer reach the real config dir without typing the path; the resolver is no longer a silent default. `HookPlan::approve` also skips `Approvals::load()` on the `--yes` path. That path approves every project command unconditionally and persists nothing, so the load was dead work. Skipping it removes a useless I/O and keeps `wt merge --yes` working when `approvals.toml` is malformed. `tests/CLAUDE.md` gains a "Config Isolation for In-Process Unit Tests" section; the `approvals_path()` doc is updated to describe the lib-crate-only `#[cfg(test)]` guard. ## Testing Full pre-merge gate green (3811 tests, fmt, clippy, doctests). The three `hook_plan` tests pass under a non-writable-`HOME` sandbox repro of the build-sandbox condition. One coverage note: `require_approvals_path()` / `require_config_path()` each have an error branch (`ok_or_else(|| ConfigError("Cannot determine …"))`) that fires only when no config directory is resolvable. CI always sets `$HOME` / `WORKTRUNK_*_PATH`, so that branch is unreachable in tests, and `codecov/patch` may flag those two lines. The same closure existed before this change, inside `with_locked_mutation`; the refactor moves it into the new helpers, where it counts as patched lines. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- src/commands/command_approval.rs | 7 +- src/commands/config/approvals.rs | 6 +- src/commands/hook_plan.rs | 34 ++++--- src/commands/worktree/resolve.rs | 2 +- src/config/approvals.rs | 108 ++++++++++++----------- src/config/mod.rs | 4 +- src/config/user/mod.rs | 4 +- src/config/user/mutation.rs | 27 ++---- src/config/user/path.rs | 13 +++ src/config/user/tests.rs | 20 ++--- src/output/commit_generation.rs | 12 ++- src/output/shell_integration.rs | 5 +- tests/CLAUDE.md | 45 ++++++++++ tests/integration_tests/approval_save.rs | 56 ++++++------ tests/integration_tests/approvals.rs | 14 +-- 15 files changed, 213 insertions(+), 144 deletions(-) diff --git a/src/commands/command_approval.rs b/src/commands/command_approval.rs index 004445e73b..1967de43f7 100644 --- a/src/commands/command_approval.rs +++ b/src/commands/command_approval.rs @@ -22,7 +22,7 @@ use std::path::Path; use anyhow::Context; use color_print::cformat; -use worktrunk::config::Approvals; +use worktrunk::config::{Approvals, require_approvals_path}; use worktrunk::git::{GitError, HookType}; use worktrunk::styling::{ INFO_SYMBOL, WARNING_SYMBOL, eprint, eprintln, format_bash_with_gutter, format_with_gutter, @@ -77,7 +77,10 @@ pub fn approve_command_batch( .iter() .map(|cmd| cmd.command.template.clone()) .collect(); - if let Err(e) = fresh_approvals.approve_commands(project_id.to_string(), commands, None) { + let save_result = require_approvals_path().and_then(|path| { + fresh_approvals.approve_commands(project_id.to_string(), commands, &path) + }); + if let Err(e) = save_result { eprintln!( "{}", warning_message(format!("Failed to save command approval: {e}")) diff --git a/src/commands/config/approvals.rs b/src/commands/config/approvals.rs index b273aea1f1..bbe85c2f90 100644 --- a/src/commands/config/approvals.rs +++ b/src/commands/config/approvals.rs @@ -6,7 +6,7 @@ use anyhow::Context; use strum::IntoEnumIterator; use worktrunk::HookType; -use worktrunk::config::Approvals; +use worktrunk::config::{Approvals, require_approvals_path}; use worktrunk::git::{GitError, Repository}; use worktrunk::styling::{eprintln, info_message, success_message}; @@ -96,7 +96,7 @@ pub fn clear_approvals(global: bool) -> anyhow::Result<()> { } approvals - .clear_all(None) + .clear_all(&require_approvals_path()?) .context("Failed to clear approvals")?; eprintln!( @@ -124,7 +124,7 @@ pub fn clear_approvals(global: bool) -> anyhow::Result<()> { } approvals - .revoke_project(&project_id, None) + .revoke_project(&project_id, &require_approvals_path()?) .context("Failed to clear project approvals")?; eprintln!( diff --git a/src/commands/hook_plan.rs b/src/commands/hook_plan.rs index 4abe48ce83..7f952b13e5 100644 --- a/src/commands/hook_plan.rs +++ b/src/commands/hook_plan.rs @@ -187,20 +187,20 @@ impl HookPlan { out } - /// Interactive / `--yes` gate. Reuses [`approve_command_batch`] so the - /// prompt, the `--yes` path, and the saved approvals are byte-identical to - /// before. `Ok(None)` means the user declined — the caller prints its own + /// Interactive / `--yes` gate. The interactive path reuses + /// [`approve_command_batch`] for its prompt and for the approvals it + /// saves. `Ok(None)` means the user declined: the caller prints its own /// "continuing without hooks" message and proceeds with /// [`ApprovedHookPlan::empty`]. /// - /// When no project-source command needs the gate (no project config, or - /// only user hooks), this returns the frozen plan **without** loading - /// `Approvals` or requiring `project_id`. That keeps a malformed - /// `approvals.toml` or an unresolvable project identifier from aborting a - /// command that has nothing to authorize, matching the pre-plan behaviour - /// where the empty-batch fast path ran before any approval state was - /// touched. `project_id` is therefore `Option`: it is consulted only when - /// there is something to approve, where it must be present. + /// `Approvals` is loaded only on the interactive path. Two paths skip the + /// load. When nothing project-sourced needs the gate (no project config, + /// or only user hooks) the frozen plan returns at once. `--yes` also + /// approves every project command unconditionally and persists nothing, so + /// it has no approval state to read. A malformed `approvals.toml` therefore + /// never aborts a command that would not consult it. `project_id` is + /// `Option` only because that no-approvable fast path needs none; every + /// path that has something to approve requires it. pub fn approve( self, project_id: Option<&str>, @@ -214,8 +214,14 @@ impl HookPlan { } let project_id = project_id.context("project identifier is required to approve project commands")?; - let approvals = Approvals::load().context("Failed to load approvals")?; - let approved = approve_command_batch(&approvable, project_id, &approvals, yes, false)?; + // `approve_command_batch` with `yes = true` is an unconditional + // `Ok(true)`, so loading `Approvals` here would be dead work. + let approved = if yes { + true + } else { + let approvals = Approvals::load().context("Failed to load approvals")?; + approve_command_batch(&approvable, project_id, &approvals, false, false)? + }; if !approved { return Ok(None); } @@ -462,7 +468,7 @@ mod tests { .approve_command( "proj".to_string(), "echo project-hook".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); let mut builder = HookPlanBuilder::new(); diff --git a/src/commands/worktree/resolve.rs b/src/commands/worktree/resolve.rs index 95aa7f9d99..3b049972ef 100644 --- a/src/commands/worktree/resolve.rs +++ b/src/commands/worktree/resolve.rs @@ -385,7 +385,7 @@ pub fn offer_bare_repo_worktree_path_fix( config.set_project_worktree_path( &project_id, BARE_REPO_WORKTREE_PATH.to_string(), - None, + &worktrunk::config::require_config_path()?, )?; print_accepted_message(&display_path, &config_path_display); Ok(true) diff --git a/src/config/approvals.rs b/src/config/approvals.rs index 1410098f22..f3ae66625b 100644 --- a/src/config/approvals.rs +++ b/src/config/approvals.rs @@ -54,12 +54,23 @@ struct ApprovedProject { // Path resolution // ========================================================================= -/// Get the approvals file path. +/// Resolve the approvals file path. /// /// Priority: /// 1. `WORKTRUNK_APPROVALS_PATH` environment variable -/// 2. In test builds: panic if env var not set -/// 3. Production: sibling of config.toml (`approvals.toml` in same directory) +/// 2. Lib-crate test builds with the variable unset: panic (see below) +/// 3. Production: `approvals.toml` beside `config.toml` +/// +/// Called by [`Approvals::load`] and [`require_approvals_path`]. The mutation +/// methods take an explicit `&Path`, so they never resolve here. +/// +/// The step-2 guard is `#[cfg(test)]`, so it fires only when the `worktrunk` +/// lib crate is itself compiled as a test target. A unit test in the `wt` bin +/// crate (anything under `src/commands/`) links the lib in non-test mode, so +/// the guard is compiled out and step 3 resolves the real user config +/// directory. In-process unit tests must not resolve here, directly or via +/// [`Approvals::load`]; they pass tempdir-backed paths instead. See +/// `tests/CLAUDE.md`. pub fn approvals_path() -> Option { if let Ok(path) = std::env::var("WORKTRUNK_APPROVALS_PATH") { return Some(PathBuf::from(path)); @@ -67,8 +78,9 @@ pub fn approvals_path() -> Option { #[cfg(test)] panic!( - "WORKTRUNK_APPROVALS_PATH not set in test. Tests must use TestRepo which sets this \ - automatically, or set it manually to an isolated test approvals path." + "WORKTRUNK_APPROVALS_PATH not set in test. Subprocess tests set it via TestRepo. \ + An in-process unit test must pass explicit tempdir paths to the mutation \ + methods and must not resolve the approvals path globally. See tests/CLAUDE.md." ); #[cfg(not(test))] @@ -77,6 +89,16 @@ pub fn approvals_path() -> Option { } } +/// Resolve the approvals path, erroring when no location can be determined. +/// +/// The `Result`-returning counterpart of [`approvals_path`], for the callers +/// that save approvals and need a concrete path. +pub fn require_approvals_path() -> Result { + approvals_path().ok_or_else(|| { + ConfigError("Cannot determine approvals path. Set $HOME or $XDG_CONFIG_HOME".to_string()) + }) +} + // ========================================================================= // Loading // ========================================================================= @@ -253,25 +275,17 @@ impl Approvals { /// Acquires lock, reloads from disk, calls the mutator, and saves if mutator returns true. fn with_locked_mutation( &mut self, - approvals_path: Option<&Path>, + approvals_path: &Path, mutate: F, ) -> Result<(), ConfigError> where F: FnOnce(&mut Self) -> bool, { - let path = match approvals_path { - Some(p) => p.to_path_buf(), - None => self::approvals_path().ok_or_else(|| { - ConfigError( - "Cannot determine approvals path. Set $HOME or $XDG_CONFIG_HOME".to_string(), - ) - })?, - }; - let _lock = super::user::mutation::acquire_config_lock(&path)?; - self.reload_from(&path)?; + let _lock = super::user::mutation::acquire_config_lock(approvals_path)?; + self.reload_from(approvals_path)?; if mutate(self) { - self.save_to(&path)?; + self.save_to(approvals_path)?; } Ok(()) } @@ -321,7 +335,7 @@ impl Approvals { &mut self, project: String, command: String, - approvals_path: Option<&Path>, + approvals_path: &Path, ) -> Result<(), ConfigError> { self.with_locked_mutation(approvals_path, |approvals| { if approvals.is_command_approved(&project, &command) { @@ -342,7 +356,7 @@ impl Approvals { &mut self, project: String, commands: Vec, - approvals_path: Option<&Path>, + approvals_path: &Path, ) -> Result<(), ConfigError> { self.with_locked_mutation(approvals_path, |approvals| { let entry = approvals.projects.entry(project).or_default(); @@ -366,7 +380,7 @@ impl Approvals { pub fn revoke_project( &mut self, project: &str, - approvals_path: Option<&Path>, + approvals_path: &Path, ) -> Result<(), ConfigError> { let project = project.to_string(); self.with_locked_mutation(approvals_path, |approvals| { @@ -382,7 +396,7 @@ impl Approvals { } /// Clear all approvals for all projects and save. - pub fn clear_all(&mut self, approvals_path: Option<&Path>) -> Result<(), ConfigError> { + pub fn clear_all(&mut self, approvals_path: &Path) -> Result<(), ConfigError> { self.with_locked_mutation(approvals_path, |approvals| { if approvals.projects.is_empty() { return false; @@ -458,7 +472,7 @@ mod tests { .approve_command( "github.com/user/repo".to_string(), "npm install".to_string(), - Some(&path), + &path, ) .unwrap(); @@ -476,14 +490,14 @@ mod tests { .approve_command( "github.com/user/repo".to_string(), "npm install".to_string(), - Some(&path), + &path, ) .unwrap(); approvals .approve_command( "github.com/user/repo".to_string(), "npm install".to_string(), - Some(&path), + &path, ) .unwrap(); @@ -505,7 +519,7 @@ mod tests { .approve_commands( "github.com/user/repo".to_string(), vec!["npm install".to_string(), "npm test".to_string()], - Some(&path), + &path, ) .unwrap(); @@ -522,19 +536,19 @@ mod tests { .approve_commands( "github.com/user/repo1".to_string(), vec!["npm install".to_string(), "npm test".to_string()], - Some(&path), + &path, ) .unwrap(); approvals .approve_command( "github.com/user/repo2".to_string(), "cargo build".to_string(), - Some(&path), + &path, ) .unwrap(); approvals - .revoke_project("github.com/user/repo1", Some(&path)) + .revoke_project("github.com/user/repo1", &path) .unwrap(); assert!(!approvals.projects.contains_key("github.com/user/repo1")); assert!(approvals.projects.contains_key("github.com/user/repo2")); @@ -549,18 +563,18 @@ mod tests { .approve_command( "github.com/user/repo1".to_string(), "npm install".to_string(), - Some(&path), + &path, ) .unwrap(); approvals .approve_command( "github.com/user/repo2".to_string(), "cargo build".to_string(), - Some(&path), + &path, ) .unwrap(); - approvals.clear_all(Some(&path)).unwrap(); + approvals.clear_all(&path).unwrap(); assert!(approvals.projects.is_empty()); } @@ -573,7 +587,7 @@ mod tests { .approve_commands( "github.com/user/repo".to_string(), vec!["npm install".to_string(), "npm test".to_string()], - Some(&path), + &path, ) .unwrap(); @@ -641,7 +655,7 @@ mod tests { .approve_commands( "github.com/user/repo".to_string(), vec!["npm install".to_string(), "npm test".to_string()], - Some(&path), + &path, ) .unwrap(); @@ -665,7 +679,7 @@ mod tests { .approve_command( "project".to_string(), "echo {{ repo_root }}".to_string(), - Some(&path), + &path, ) .unwrap(); @@ -679,11 +693,7 @@ mod tests { let mut approvals = Approvals::default(); approvals - .approve_command( - "project".to_string(), - "echo repo_root".to_string(), - Some(&path), - ) + .approve_command("project".to_string(), "echo repo_root".to_string(), &path) .unwrap(); assert!(!approvals.is_command_approved("project", "echo repo_path")); @@ -711,7 +721,7 @@ mod tests { .approve_command( "github.com/user/repo".to_string(), format!("command_{i}"), - Some(&config_path), + &config_path, ) .unwrap(); }) @@ -780,7 +790,7 @@ approved-commands = ["npm install"] .approve_command( "github.com/user/repo".to_string(), "npm test".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -873,12 +883,10 @@ approved-command = ["npm test"] let (_temp_dir, path) = test_dir(); let mut approvals = Approvals::default(); approvals - .approve_command("project-a".to_string(), "cmd1".to_string(), Some(&path)) + .approve_command("project-a".to_string(), "cmd1".to_string(), &path) .unwrap(); // Revoke a project that doesn't exist — should be a no-op - approvals - .revoke_project("nonexistent", Some(&path)) - .unwrap(); + approvals.revoke_project("nonexistent", &path).unwrap(); assert!(approvals.is_command_approved("project-a", "cmd1")); } @@ -887,7 +895,7 @@ approved-command = ["npm test"] let (_temp_dir, path) = test_dir(); let mut approvals = Approvals::default(); // Clear when there's nothing — should be a no-op - approvals.clear_all(Some(&path)).unwrap(); + approvals.clear_all(&path).unwrap(); assert!(approvals.projects.is_empty()); } @@ -921,7 +929,7 @@ approved-command = ["npm test"] let (_temp_dir, path) = test_dir(); let mut approvals = Approvals::default(); approvals - .approve_command("project-a".to_string(), "cmd1".to_string(), Some(&path)) + .approve_command("project-a".to_string(), "cmd1".to_string(), &path) .unwrap(); // Manually clear the commands (without removing the project entry) approvals @@ -933,7 +941,7 @@ approved-command = ["npm test"] // Write this state to disk approvals.save_to(&path).unwrap(); // Now revoke_project should find the project but see empty commands → no-op - approvals.revoke_project("project-a", Some(&path)).unwrap(); + approvals.revoke_project("project-a", &path).unwrap(); } #[test] @@ -942,10 +950,10 @@ approved-command = ["npm test"] let mut approvals = Approvals::default(); approvals - .approve_command("project1".to_string(), "cmd1".to_string(), Some(&path)) + .approve_command("project1".to_string(), "cmd1".to_string(), &path) .unwrap(); approvals - .approve_command("project2".to_string(), "cmd2".to_string(), Some(&path)) + .approve_command("project2".to_string(), "cmd2".to_string(), &path) .unwrap(); let projects: Vec<_> = approvals.projects().collect(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 61b44d874f..bef2ced7eb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -101,7 +101,7 @@ pub(crate) fn is_default(value: &T) -> bool { } // Re-export public types -pub use approvals::{Approvals, approvals_path}; +pub use approvals::{Approvals, approvals_path, require_approvals_path}; pub use commands::{Command, CommandConfig, HookStep, append_aliases}; pub use deprecation::CheckAndMigrateResult; pub use deprecation::DeprecationInfo; @@ -140,7 +140,7 @@ pub use user::{ CommitConfig, CommitGenerationConfig, CopyIgnoredConfig, ListConfig, MergeConfig, RemoveConfig, ResolvedConfig, StageMode, StepConfig, SwitchConfig, SwitchPickerConfig, UserConfig, UserProjectOverrides, config_path, default_config_path, default_system_config_path, - set_config_path, system_config_path, valid_user_config_keys, + require_config_path, set_config_path, system_config_path, valid_user_config_keys, }; #[cfg(test)] diff --git a/src/config/user/mod.rs b/src/config/user/mod.rs index 2a015984f3..186b7240a6 100644 --- a/src/config/user/mod.rs +++ b/src/config/user/mod.rs @@ -22,8 +22,8 @@ use serde::{Deserialize, Serialize}; // Re-export public types pub use merge::Merge; pub use path::{ - config_path, default_config_path, default_system_config_path, set_config_path, - system_config_path, + config_path, default_config_path, default_system_config_path, require_config_path, + set_config_path, system_config_path, }; pub use resolved::ResolvedConfig; pub use schema::valid_user_config_keys; diff --git a/src/config/user/mutation.rs b/src/config/user/mutation.rs index 88006fdf45..5870f33806 100644 --- a/src/config/user/mutation.rs +++ b/src/config/user/mutation.rs @@ -10,11 +10,8 @@ use crate::config::ConfigError; use crate::path::format_path_for_display; use super::UserConfig; -use super::path; use super::sections::CommitGenerationConfig; -const NO_CONFIG_DIR_MSG: &str = "Cannot determine config directory. Set $HOME or $XDG_CONFIG_HOME"; - /// Acquire an exclusive lock on the config file for read-modify-write operations. /// /// Uses a `.lock` file alongside the config file to coordinate between processes. @@ -50,21 +47,17 @@ impl UserConfig { /// Acquires lock, reloads from disk, calls the mutator, and saves if mutator returns true. pub(super) fn with_locked_mutation( &mut self, - config_path: Option<&std::path::Path>, + config_path: &std::path::Path, mutate: F, ) -> Result<(), ConfigError> where F: FnOnce(&mut Self) -> bool, { - let path = match config_path { - Some(p) => p.to_path_buf(), - None => path::config_path().ok_or_else(|| ConfigError(NO_CONFIG_DIR_MSG.into()))?, - }; - let _lock = acquire_config_lock(&path)?; - self.reload_from(&path)?; + let _lock = acquire_config_lock(config_path)?; + self.reload_from(config_path)?; if mutate(self) { - self.save_to(&path)?; + self.save_to(config_path)?; } Ok(()) } @@ -106,10 +99,9 @@ impl UserConfig { /// Set `skip-shell-integration-prompt = true` and save. /// /// Acquires lock, reloads from disk, sets flag if not already set, and saves. - /// Pass `None` for default config path, or `Some(path)` for testing. pub fn set_skip_shell_integration_prompt( &mut self, - config_path: Option<&std::path::Path>, + config_path: &std::path::Path, ) -> Result<(), ConfigError> { self.with_locked_mutation(config_path, |config| { if config.skip_shell_integration_prompt { @@ -123,10 +115,9 @@ impl UserConfig { /// Set `skip-commit-generation-prompt = true` and save. /// /// Acquires lock, reloads from disk, sets flag if not already set, and saves. - /// Pass `None` for default config path, or `Some(path)` for testing. pub fn set_skip_commit_generation_prompt( &mut self, - config_path: Option<&std::path::Path>, + config_path: &std::path::Path, ) -> Result<(), ConfigError> { self.with_locked_mutation(config_path, |config| { if config.skip_commit_generation_prompt { @@ -140,12 +131,11 @@ impl UserConfig { /// Set worktree-path for a specific project and save. /// /// Creates the project entry if it doesn't exist. - /// Pass `None` for default config path, or `Some(path)` for testing. pub fn set_project_worktree_path( &mut self, project: &str, worktree_path: String, - config_path: Option<&std::path::Path>, + config_path: &std::path::Path, ) -> Result<(), ConfigError> { self.with_locked_mutation(config_path, |config| { let entry = config.projects.entry(project.to_string()).or_default(); @@ -161,11 +151,10 @@ impl UserConfig { /// /// Sets `[commit.generation] command = ...` in the user config. /// Acquires lock, reloads from disk, sets the command, and saves. - /// Pass `None` for default config path, or `Some(path)` for testing. pub fn set_commit_generation_command( &mut self, command: String, - config_path: Option<&std::path::Path>, + config_path: &std::path::Path, ) -> Result<(), ConfigError> { self.with_locked_mutation(config_path, |config| { let gen_config = config diff --git a/src/config/user/path.rs b/src/config/user/path.rs index 0cd0101b03..08545dac45 100644 --- a/src/config/user/path.rs +++ b/src/config/user/path.rs @@ -8,6 +8,8 @@ use std::sync::OnceLock; use etcetera::base_strategy::{BaseStrategy, choose_base_strategy}; +use crate::config::ConfigError; + /// Override for user config path, set via --config CLI flag static CONFIG_PATH: OnceLock = OnceLock::new(); @@ -46,6 +48,17 @@ pub fn config_path() -> Option { default_config_path() } +/// Resolve the user config path, erroring when no location can be determined. +/// +/// The `Result`-returning counterpart of [`config_path`], for callers that +/// must produce a concrete path (config mutation) rather than tolerate its +/// absence. +pub fn require_config_path() -> Result { + config_path().ok_or_else(|| { + ConfigError("Cannot determine config directory. Set $HOME or $XDG_CONFIG_HOME".to_string()) + }) +} + /// Platform-specific default config path, without CLI or env var overrides. /// /// Returns the etcetera-based platform default. Called by `config_path()` diff --git a/src/config/user/tests.rs b/src/config/user/tests.rs index a9088696ec..4dedbc2ccf 100644 --- a/src/config/user/tests.rs +++ b/src/config/user/tests.rs @@ -578,7 +578,7 @@ fn test_set_project_worktree_path() { .set_project_worktree_path( "github.com/user/repo", "../{{ branch | sanitize }}".to_string(), - Some(&config_path), + &config_path, ) .unwrap(); @@ -2156,7 +2156,7 @@ fn test_reload_from_invalid_toml() { // Try to reload via a mutation — should fail with parse error let mut config = UserConfig::default(); - let result = config.set_skip_shell_integration_prompt(Some(&config_path)); + let result = config.set_skip_shell_integration_prompt(&config_path); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -2372,7 +2372,7 @@ fn test_reload_from_permission_error() { // Try to reload via a mutation — should fail with read error let mut config = UserConfig::default(); - let result = config.set_skip_shell_integration_prompt(Some(&config_path)); + let result = config.set_skip_shell_integration_prompt(&config_path); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -3281,7 +3281,7 @@ fn test_set_project_worktree_path_noop_when_unchanged() { let mut config = UserConfig::default(); config - .set_project_worktree_path("user/repo", "../custom".to_string(), Some(&config_path)) + .set_project_worktree_path("user/repo", "../custom".to_string(), &config_path) .unwrap(); let after_first = std::fs::read_to_string(&config_path).unwrap(); @@ -3293,7 +3293,7 @@ fn test_set_project_worktree_path_noop_when_unchanged() { // false, so save is skipped. let mut config2 = UserConfig::default(); config2 - .set_project_worktree_path("user/repo", "../custom".to_string(), Some(&config_path)) + .set_project_worktree_path("user/repo", "../custom".to_string(), &config_path) .unwrap(); let after_second = std::fs::read_to_string(&config_path).unwrap(); @@ -3315,7 +3315,7 @@ fn test_set_skip_shell_integration_prompt_noop_on_second_call() { let mut config = UserConfig::default(); config - .set_skip_shell_integration_prompt(Some(&config_path)) + .set_skip_shell_integration_prompt(&config_path) .unwrap(); let after_first = std::fs::read_to_string(&config_path).unwrap(); assert!(after_first.contains("skip-shell-integration-prompt = true")); @@ -3323,7 +3323,7 @@ fn test_set_skip_shell_integration_prompt_noop_on_second_call() { // Second call with the flag already true in-memory — mutator returns // false, save is skipped, file is byte-identical. config - .set_skip_shell_integration_prompt(Some(&config_path)) + .set_skip_shell_integration_prompt(&config_path) .unwrap(); let after_second = std::fs::read_to_string(&config_path).unwrap(); assert_eq!(after_first, after_second); @@ -3339,7 +3339,7 @@ fn test_acquire_config_lock_handles_root_path() { // None branch executes cleanly. let mut config = UserConfig::default(); let err = config - .set_skip_shell_integration_prompt(Some(std::path::Path::new("/"))) + .set_skip_shell_integration_prompt(std::path::Path::new("/")) .unwrap_err(); let msg = err.to_string(); assert!( @@ -3365,7 +3365,7 @@ fn test_acquire_config_lock_fails_when_parent_is_file() { let mut config = UserConfig::default(); let err = config - .set_skip_shell_integration_prompt(Some(&config_path)) + .set_skip_shell_integration_prompt(&config_path) .unwrap_err(); let msg = err.to_string(); assert!( @@ -3407,7 +3407,7 @@ fn test_with_locked_mutation_propagates_save_error() { let cfg_path_for_closure = config_path.clone(); let mut config = UserConfig::default(); let err = config - .with_locked_mutation(Some(&config_path), move |_config| { + .with_locked_mutation(&config_path, move |_config| { // Mid-mutation: strip read permissions from the config file. // reload_from already ran; save_to will try to read again and fail. let mut perms = std::fs::metadata(&cfg_path_for_closure) diff --git a/src/output/commit_generation.rs b/src/output/commit_generation.rs index e80e520fb0..ff0552870f 100644 --- a/src/output/commit_generation.rs +++ b/src/output/commit_generation.rs @@ -11,7 +11,7 @@ use std::path::PathBuf; use std::sync::LazyLock; use color_print::cformat; -use worktrunk::config::UserConfig; +use worktrunk::config::{UserConfig, require_config_path}; use worktrunk::styling::{eprintln, format_toml, hint_message, info_message, success_message}; use super::prompt::{PromptResponse, prompt_yes_no_preview}; @@ -180,7 +180,8 @@ pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result // Detect available tool let Some(tool) = detect_llm_tool() else { // No tool found - set skip flag so we don't check every time - let _ = config.set_skip_commit_generation_prompt(None); + let _ = + require_config_path().and_then(|path| config.set_skip_commit_generation_prompt(&path)); return Ok(false); }; @@ -208,7 +209,9 @@ pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result PromptResponse::Accepted => { // Set the configuration let command = command.to_string(); - if let Err(e) = config.set_commit_generation_command(command.clone(), None) { + let save_result = require_config_path() + .and_then(|path| config.set_commit_generation_command(command.clone(), &path)); + if let Err(e) = save_result { log::error!("Failed to save config: {}", e); eprintln!( "{}", @@ -234,7 +237,8 @@ pub fn prompt_commit_generation(config: &mut UserConfig) -> anyhow::Result } PromptResponse::Declined => { // Set skip flag so we don't prompt again - let _ = config.set_skip_commit_generation_prompt(None); + let _ = require_config_path() + .and_then(|path| config.set_skip_commit_generation_prompt(&path)); Ok(false) } } diff --git a/src/output/shell_integration.rs b/src/output/shell_integration.rs index 35dca2cada..0a7bf3cdde 100644 --- a/src/output/shell_integration.rs +++ b/src/output/shell_integration.rs @@ -61,7 +61,7 @@ use std::io::IsTerminal; use color_print::cformat; -use worktrunk::config::UserConfig; +use worktrunk::config::{UserConfig, require_config_path}; use worktrunk::git::Repository; use worktrunk::path::format_path_for_display; use worktrunk::shell::{Shell, current_shell, extract_filename_from_path}; @@ -470,7 +470,8 @@ pub fn prompt_shell_integration( if !confirmed { // Only skip future prompts after explicit decline (not Ctrl+C) - let _ = config.set_skip_shell_integration_prompt(None); + let _ = + require_config_path().and_then(|path| config.set_skip_shell_integration_prompt(&path)); print_shell_integration_hint(repo); return Ok(false); } diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 4107ee48ac..a32a6f2722 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -96,6 +96,51 @@ call `.current_dir(...)` explicitly. | `wt_command()` | `Command` | Running wt without a TestRepo (free function) | | `repo.git_command()` | `Cmd` | Running git commands (use `.run()` not `.output()`) | +## Config Isolation for In-Process Unit Tests + +`repo.wt_command()` / `wt_command()` isolate *subprocess* tests (above). An +in-process unit test that calls library functions directly gets no such +isolation: it runs in the test process, which inherits the real environment. + +The `Approvals` and `UserConfig` mutation methods take an explicit `&Path`, so +a unit test passes a tempdir-backed path and the write stays isolated. The +global resolvers do not isolate: `Approvals::load()`, `approvals_path()`, +`config_path()`, and `system_config_path()` all fall back to the real +`~/.config/worktrunk/`. + + + + +Bad: + +```rust +let mut approvals = Approvals::load().unwrap(); +approvals.approve_command(project, command, &approvals_path).unwrap(); +``` + + + + +Good: + +```rust +let temp_dir = tempfile::tempdir().unwrap(); +let approvals_path = temp_dir.path().join("approvals.toml"); +let mut approvals = Approvals::default(); +approvals.approve_command(project, command, &approvals_path).unwrap(); +``` + + + + +`approvals_path()` panics when `WORKTRUNK_APPROVALS_PATH` is unset, but +`#[cfg(test)]` makes that guard fire only for `worktrunk` lib-crate tests. A +bin-crate test (anything under `src/commands/`) links the lib in non-test +mode, so the guard is compiled out and a global read hits the real config +silently: it passes wherever `$HOME` is writable and fails only in a sandbox +that forbids it. `config_path()` and `system_config_path()` have no guard at +all. + ## Timing Tests: Long Timeouts with Fast Polling **Core principle:** Use long timeouts (5+ seconds) for reliability on slow CI, but poll frequently (10-50ms) so tests complete quickly when things work. diff --git a/tests/integration_tests/approval_save.rs b/tests/integration_tests/approval_save.rs index cb6fecc109..52153467fa 100644 --- a/tests/integration_tests/approval_save.rs +++ b/tests/integration_tests/approval_save.rs @@ -19,7 +19,7 @@ fn test_approval_saves_to_disk() { .approve_command( "github.com/test/repo".to_string(), "test command".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -55,14 +55,14 @@ fn test_duplicate_approvals_not_saved_twice() { .approve_command( "github.com/test/repo".to_string(), "test".to_string(), - Some(&approvals_path), + &approvals_path, ) .ok(); approvals .approve_command( "github.com/test/repo".to_string(), "test".to_string(), - Some(&approvals_path), + &approvals_path, ) .ok(); @@ -88,21 +88,21 @@ fn test_multiple_project_approvals() { .approve_command( "github.com/user1/repo1".to_string(), "npm install".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); approvals .approve_command( "github.com/user2/repo2".to_string(), "cargo build".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); approvals .approve_command( "github.com/user1/repo1".to_string(), "npm test".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -159,7 +159,7 @@ fn test_isolated_config_safety() { .approve_command( "github.com/safety-test/repo".to_string(), "THIS SHOULD NOT APPEAR IN USER APPROVALS".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -217,7 +217,7 @@ fn test_approval_saves_to_new_approvals_file() { .approve_command( "github.com/test/nested".to_string(), "test command".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -263,7 +263,7 @@ command = "llm -m claude-haiku-4.5" // Change a non-approval setting and save back to the same file config - .set_commit_generation_command("llm -m claude-sonnet-4".to_string(), Some(&config_path)) + .set_commit_generation_command("llm -m claude-sonnet-4".to_string(), &config_path) .unwrap(); // Read back the saved config @@ -310,7 +310,7 @@ fn test_concurrent_approve_preserves_all_approvals() { .approve_command( "github.com/user/repo".to_string(), "npm install".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -327,7 +327,7 @@ fn test_concurrent_approve_preserves_all_approvals() { .approve_command( "github.com/user/repo".to_string(), "npm test".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -362,14 +362,14 @@ fn test_concurrent_revoke_preserves_all_changes() { .approve_command( "github.com/user/repo".to_string(), "npm install".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); setup_approvals .approve_command( "github.com/user/repo".to_string(), "npm test".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -381,7 +381,7 @@ fn test_concurrent_revoke_preserves_all_changes() { // Process A: revokes the project let mut approvals_a = Approvals::default(); approvals_a - .revoke_project("github.com/user/repo", Some(&approvals_path)) + .revoke_project("github.com/user/repo", &approvals_path) .unwrap(); // Read the final state from disk @@ -414,7 +414,7 @@ fn test_concurrent_approve_different_projects() { .approve_command( "github.com/user/project1".to_string(), "npm install".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -424,7 +424,7 @@ fn test_concurrent_approve_different_projects() { .approve_command( "github.com/user/project2".to_string(), "cargo build".to_string(), - Some(&approvals_path), + &approvals_path, ) .unwrap(); @@ -482,7 +482,7 @@ fn test_truly_concurrent_approve_with_threads() { .approve_command( "github.com/user/repo".to_string(), format!("command_{i}"), - Some(&approvals_path), + &approvals_path, ) .unwrap(); }) @@ -559,7 +559,7 @@ fn test_permission_error_prevents_save() { let result = approvals.approve_command( "github.com/test/readonly".to_string(), "test command".to_string(), - Some(&approvals_path), + &approvals_path, ); // Restore write permissions so temp_dir can be cleaned up @@ -592,7 +592,7 @@ fn test_skip_shell_integration_prompt_saves_to_disk() { let mut config = UserConfig::default(); config - .set_skip_shell_integration_prompt(Some(&config_path)) + .set_skip_shell_integration_prompt(&config_path) .unwrap(); // Verify file was created @@ -616,10 +616,10 @@ fn test_skip_shell_integration_prompt_idempotent() { // Call twice - should not error config - .set_skip_shell_integration_prompt(Some(&config_path)) + .set_skip_shell_integration_prompt(&config_path) .unwrap(); config - .set_skip_shell_integration_prompt(Some(&config_path)) + .set_skip_shell_integration_prompt(&config_path) .unwrap(); // Field should still be true @@ -640,7 +640,7 @@ fn test_skip_commit_generation_prompt_saves_to_disk() { let mut config = UserConfig::default(); config - .set_skip_commit_generation_prompt(Some(&config_path)) + .set_skip_commit_generation_prompt(&config_path) .unwrap(); // Verify file was created @@ -664,10 +664,10 @@ fn test_skip_commit_generation_prompt_idempotent() { // Call twice - should not error config - .set_skip_commit_generation_prompt(Some(&config_path)) + .set_skip_commit_generation_prompt(&config_path) .unwrap(); config - .set_skip_commit_generation_prompt(Some(&config_path)) + .set_skip_commit_generation_prompt(&config_path) .unwrap(); // Field should still be true @@ -688,7 +688,7 @@ fn test_set_commit_generation_command_saves_to_disk() { let mut config = UserConfig::default(); config - .set_commit_generation_command("llm -m haiku".to_string(), Some(&config_path)) + .set_commit_generation_command("llm -m haiku".to_string(), &config_path) .unwrap(); // Verify file was created @@ -716,7 +716,7 @@ fn test_set_commit_generation_command_with_special_chars() { let command = "MAX_THINKING_TOKENS=0 claude -p --model=haiku --tools='' --system-prompt=''".to_string(); config - .set_commit_generation_command(command, Some(&config_path)) + .set_commit_generation_command(command, &config_path) .unwrap(); // Verify TOML can be parsed back @@ -769,7 +769,7 @@ worktree-path = "../{{ main_worktree }}.{{ branch }}" let mut config: UserConfig = toml::from_str(&toml_str).unwrap(); config - .set_commit_generation_command("llm -m haiku".to_string(), Some(&symlink_path)) + .set_commit_generation_command("llm -m haiku".to_string(), &symlink_path) .unwrap(); // Verify symlink is preserved @@ -829,7 +829,7 @@ approved-commands = [ let mut config: UserConfig = toml::from_str(&toml_str).unwrap(); config - .set_commit_generation_command("llm -m haiku".to_string(), Some(&config_path)) + .set_commit_generation_command("llm -m haiku".to_string(), &config_path) .unwrap(); // Read back what was saved diff --git a/tests/integration_tests/approvals.rs b/tests/integration_tests/approvals.rs index 72576e50d8..8a40826c51 100644 --- a/tests/integration_tests/approvals.rs +++ b/tests/integration_tests/approvals.rs @@ -114,7 +114,7 @@ fn test_clear_approvals_with_approvals(repo: TestRepo) { .approve_command( repo.project_id(), "echo 'test'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); @@ -142,7 +142,7 @@ fn test_clear_approvals_global_with_approvals(repo: TestRepo) { .approve_command( repo.project_id(), "echo 'test'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); @@ -169,7 +169,7 @@ fn test_clear_approvals_after_clear(repo: TestRepo) { .approve_command( repo.project_id(), "echo 'test'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); @@ -243,21 +243,21 @@ lint = "echo 'third'" .approve_command( project_id.clone(), "echo 'first'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); approvals .approve_command( project_id.clone(), "echo 'second'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); approvals .approve_command( project_id, "echo 'third'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); @@ -284,7 +284,7 @@ fn test_add_approvals_all_already_approved(repo: TestRepo) { .approve_command( repo.project_id(), "echo 'test'".to_string(), - Some(repo.test_approvals_path()), + repo.test_approvals_path(), ) .unwrap(); From 251d1b45f3b807e01e04568556fd25059b61ca68 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 13:28:59 -0700 Subject: [PATCH 17/34] feat(switch): add a feedback link to the --execute deprecation warning (#2863) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2852. The `--execute` deprecation warning tells users how to migrate a shell command line to the upcoming argv form, but gave no channel for users whose workflow the argv cutover would genuinely regress to push back before it lands. This adds the B2 tracking issue (#2860) to the migration hint. To keep the deprecation at one warning + one hint, the issue link is merged into the single existing hint line rather than added as a second `↳`: ``` ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line ↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hi && ls' ``` The two suggestions are semicolon-joined per the writing-user-outputs "multiple suggestions in one hint" pattern; the migration command stays last so it remains the easy copy target, and the URL stays clickable mid-line. The 26 `--execute` snapshots gained the URL mechanically. Ref #2860. > _This was written by Claude Code on behalf of Maximilian Roos_ Co-authored-by: Claude Opus 4.7 (1M context) --- src/commands/worktree/switch.rs | 16 +++++++++------- ...__directives__switch_exec_scrubbed_warns.snap | 2 +- ...witch_legacy_directive_file_with_execute.snap | 2 +- ..._start_commands__execute_with_post_start.snap | 2 +- ..._tests__fish_multiline_command_execution.snap | 2 +- ...sts__switch_with_execute_through_wrapper.snap | 2 +- ...sts__switch__switch_execute_creates_file.snap | 2 +- ..._switch_execute_deprecation_fish_wrapper.snap | 2 +- ..._switch_execute_deprecation_shell_syntax.snap | 2 +- ...switch_execute_deprecation_trailing_args.snap | 2 +- ...n_tests__switch__switch_execute_existing.snap | 2 +- ...on_tests__switch__switch_execute_failure.snap | 2 +- ..._tests__switch__switch_execute_multiline.snap | 2 +- ...on_tests__switch__switch_execute_success.snap | 2 +- ...ts__switch__switch_execute_template_base.snap | 2 +- ...tch_execute_template_base_without_create.snap | 2 +- ...__switch__switch_execute_template_branch.snap | 2 +- ...ch__switch_execute_template_shell_escape.snap | 2 +- ...tch__switch_execute_template_with_filter.snap | 2 +- ...h__switch_execute_template_worktree_path.snap | 2 +- ...witch_execute_verbose_multiline_template.snap | 2 +- ..._switch__switch_execute_verbose_template.snap | 2 +- ...witch__switch_internal_execute_exit_code.snap | 2 +- ...switch_internal_execute_output_then_exit.snap | 2 +- ...ts__switch__switch_internal_with_execute.snap | 2 +- ...itch__switch_no_hooks_execute_still_runs.snap | 2 +- ..._tests__switch__switch_no_hooks_existing.snap | 2 +- 27 files changed, 35 insertions(+), 33 deletions(-) diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 56c1c7bd94..b5e22a48a3 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -1991,12 +1991,14 @@ fn is_clean_program_token(value: &str) -> bool { /// here without the user's shell, so it is left to fail loudly at the cutover /// rather than guessed at. Informational only — it never blocks the switch. /// -/// The migration hint reconstructs the command line that runs today — the -/// `-x` value with its trailing args appended, the way `run_switch` joins -/// them — and wraps it for whichever shell the active wrapper evaluates the -/// payload with (`sh` / `fish` / `pwsh`). That keeps the suggestion -/// behavior-preserving on fish/PowerShell, not just POSIX sh, and complete -/// when trailing args are present. +/// The hint reconstructs the command line that runs today — the `-x` value +/// with its trailing args appended, the way `run_switch` joins them — and +/// wraps it for whichever shell the active wrapper evaluates the payload +/// with (`sh` / `fish` / `pwsh`), so the suggestion is behavior-preserving +/// on fish/PowerShell, not just POSIX sh, and complete when trailing args +/// are present. It also links the tracking issue (#2860) so anyone whose +/// workflow the argv model would regress can report the case before the +/// cutover. fn warn_if_execute_form_deprecated(cmd: &str, execute_args: &[String]) { if is_clean_program_token(cmd) { return; @@ -2026,7 +2028,7 @@ fn warn_if_execute_form_deprecated(cmd: &str, execute_args: &[String]) { eprintln!( "{}", hint_message(cformat!( - "To run this command line unchanged, pass it to a shell: --execute {shell} -- {flag} {suggested}" + "Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute {shell} -- {flag} {suggested}" )) ); } diff --git a/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap b/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap index bdf0516064..41e1be372d 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_exec_scrubbed_warns.snap @@ -61,7 +61,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo should-not-run' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo should-not-run' ✓ Created branch scrub-test from main and worktree @ _REPO_.scrub-test ↳ To customize worktree locations, run wt config create ▲ --execute disabled inside project alias / hook bodies for safety; skipping echo should-not-run diff --git a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap index 94abc58584..a79a59bc17 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap @@ -61,7 +61,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hello' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hello' ✓ Created branch exec-legacy from main and worktree @ _REPO_.exec-legacy ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap b/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap index 67dddc252b..c3697ecfd2 100644 --- a/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap +++ b/tests/snapshots/integration__integration_tests__post_start_commands__execute_with_post_start.snap @@ -60,7 +60,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''Execute flag'\'' > execute.txt' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''Execute flag'\'' > execute.txt' ✓ Created branch feature from main and worktree @ _REPO_.feature ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap index d184375554..390dda3bae 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__fish_multiline_command_execution.snap @@ -4,7 +4,7 @@ expression: "&output.combined" --- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute fish -- -c 'echo \'line 1\'; echo \'line 2\'; echo \'line 3\'' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute fish -- -c 'echo \'line 1\'; echo \'line 2\'; echo \'line 3\'' ✓ Created branch fish-multiline from main and worktree @ _REPO_.fish-multiline ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap index cdca1d39cb..28ff04ece1 100644 --- a/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap +++ b/tests/snapshots/integration__integration_tests__shell_wrapper__unix_tests__switch_with_execute_through_wrapper.snap @@ -4,7 +4,7 @@ expression: "&output.combined" --- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo executed' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo executed' ✓ Created branch test-exec from main and worktree @ _REPO_.test-exec ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap index acc1eb447d..dca4726ed2 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_creates_file.snap @@ -60,7 +60,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''test content'\'' > test.txt' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''test content'\'' > test.txt' ✓ Created branch file-test from main and worktree @ _REPO_.file-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap index c56ed6d435..3efa581b78 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_fish_wrapper.snap @@ -63,7 +63,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute fish -- -c 'set -lx FOO bar; echo $FOO' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute fish -- -c 'set -lx FOO bar; echo $FOO' ✓ Created branch dep-fish from main and worktree @ _REPO_.dep-fish ◎ Executing (--execute):   set -lx FOO bar; echo $FOO diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap index 34037c9be2..c130f427ab 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_shell_syntax.snap @@ -62,7 +62,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hi && ls' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hi && ls' ✓ Created branch dep-shell from main and worktree @ _REPO_.dep-shell ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap index bf6de2fe85..73522a90f9 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_deprecation_trailing_args.snap @@ -64,7 +64,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'npm run test' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'npm run test' ✓ Created branch dep-args from main and worktree @ _REPO_.dep-args ◎ Executing (--execute):   npm run test diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap index a5c3edefd7..cbd99cf030 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_existing.snap @@ -59,7 +59,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''existing worktree'\'' > existing.txt' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''existing worktree'\'' > existing.txt' ▲ Worktree for existing-exec @ _REPO_.existing-exec, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install ◎ Executing (--execute) @ _REPO_.existing-exec: diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap index a93111ddf5..1e0dad0ba9 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_failure.snap @@ -60,7 +60,7 @@ exit_code: 1 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'exit 1' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'exit 1' ✓ Created branch fail-test from main and worktree @ _REPO_.fail-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap index e0d58350a1..ef5d434b20 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_multiline.snap @@ -63,7 +63,7 @@ line3 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''line1'\'' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''line1'\'' echo '\''line2'\'' echo '\''line3'\''' ✓ Created branch multiline-test from main and worktree @ _REPO_.multiline-test diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap index b78c74e54a..5894155e28 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_success.snap @@ -61,7 +61,7 @@ test output ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''test output'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''test output'\''' ✓ Created branch exec-test from main and worktree @ _REPO_.exec-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap index d1ef79cc0a..500822d32a 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base.snap @@ -63,7 +63,7 @@ base=main ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''base={{ base }}'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''base={{ base }}'\''' ✓ Created branch from-main from main and worktree @ _REPO_.from-main ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap index e849a937ea..56956e0794 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_base_without_create.snap @@ -60,7 +60,7 @@ base=main ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''base={{ base }}'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''base={{ base }}'\''' ▲ Worktree for existing @ _REPO_.existing, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install ◎ Executing (--execute) @ _REPO_.existing: diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap index be59b9fd7d..b987c0cb2a 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_branch.snap @@ -61,7 +61,7 @@ branch=template-test ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''branch={{ branch }}'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''branch={{ branch }}'\''' ✓ Created branch template-test from main and worktree @ _REPO_.template-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap index ac68496904..0346aa477f 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_shell_escape.snap @@ -61,7 +61,7 @@ feat;id ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo {{ branch }}' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo {{ branch }}' ✓ Created branch feat;id from main and worktree @ '_REPO_.feat;id' ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap index 059fbc36aa..1126192293 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_with_filter.snap @@ -61,7 +61,7 @@ sanitized=feature-with-slash ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''sanitized={{ branch | sanitize }}'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''sanitized={{ branch | sanitize }}'\''' ✓ Created branch feature/with-slash from main and worktree @ _REPO_.feature-with-slash ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap index 2353df985b..75eb448fc6 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_template_worktree_path.snap @@ -61,7 +61,7 @@ path=_REPO_.path-test ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''path={{ worktree_path }}'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''path={{ worktree_path }}'\''' ✓ Created branch path-test from main and worktree @ _REPO_.path-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap index 643eecf6d3..ccd0a9df30 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_multiline_template.snap @@ -67,7 +67,7 @@ repo=repo   →   _REPO_/../repo.multiline-test ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c '{% if branch %} +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c '{% if branch %} echo '\''branch={{ branch }}'\'' echo '\''repo={{ repo }}'\'' {% endif %}' diff --git a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap index da7f6bae20..0235d16fdf 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_execute_verbose_template.snap @@ -66,7 +66,7 @@ branch=verbose-test   →   _REPO_/../repo.verbose-test ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''branch={{ branch }}'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''branch={{ branch }}'\''' ✓ Created branch verbose-test from main and worktree @ _REPO_.verbose-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap index 5ec7bad334..d0b12ffddb 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_exit_code.snap @@ -62,7 +62,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'exit 42' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'exit 42' ✓ Created branch exit-code-test from main and worktree @ _REPO_.exit-code-test ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): diff --git a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap index 017fbc3c38..7dbb8ca6f6 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_internal_execute_output_then_exit.snap @@ -62,7 +62,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''doing work'\'' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''doing work'\'' exit 7' ✓ Created branch output-exit-test from main and worktree @ _REPO_.output-exit-test ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap b/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap index 96f6889900..67e60c349e 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_internal_with_execute.snap @@ -62,7 +62,7 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''line1'\'' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''line1'\'' echo '\''line2'\''' ✓ Created branch exec-internal from main and worktree @ _REPO_.exec-internal ↳ To customize worktree locations, run wt config create diff --git a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap index 083f3dfb1d..2921da327b 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_execute_still_runs.snap @@ -62,7 +62,7 @@ execute command runs ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''execute command runs'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''execute command runs'\''' ✓ Created branch no-hooks-test from main and worktree @ _REPO_.no-hooks-test ↳ To customize worktree locations, run wt config create ▲ Cannot change directory — shell integration not installed diff --git a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap index 3ff221ff38..48cdddcefa 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_no_hooks_existing.snap @@ -61,7 +61,7 @@ execute still runs ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line -↳ To run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''execute still runs'\''' +↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo '\''execute still runs'\''' ▲ Worktree for existing-no-hooks @ _REPO_.existing-no-hooks, but cannot change directory — shell integration not installed ↳ To enable automatic cd, run wt config shell install ◎ Executing (--execute) @ _REPO_.existing-no-hooks: From ad7b0dd69ccd9e428d940f1f78cac6045e7f7558 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 14:01:52 -0700 Subject: [PATCH 18/34] refactor(config): fold require_user_config_path into require_config_path (#2872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `require_config_path()` (added in #2862) and the older `require_user_config_path()` in `src/commands/config/state.rs` both just wrapped `config_path()` with a `Result`. Two functions, two slightly different error messages, one purpose. This deletes `require_user_config_path()` and points its four callers — `config create` (×2) and `config show` (×2) — at the lib-crate `require_config_path()`. The two wrapper tests in `config/mod.rs` are removed with it. `test_require_user_config_path_returns_ok` tested a trivial wrapper. `test_require_user_config_path_matches_config_path` guarded issue #1134 (config-create resolving a *different* path than config-loading) — that regression is now structurally impossible: `config create` / `show` resolve through `require_config_path()`, which is `config_path()` itself, so there is no separate resolver left to diverge. One behavior delta: when no config directory can be resolved, `config create` / `config show` now error `Cannot determine config directory. Set $HOME or $XDG_CONFIG_HOME` instead of `Cannot determine config directory`. No snapshot test references either string. Follow-up to #2862. Co-authored-by: Claude Opus 4.7 (1M context) --- src/commands/config/create.rs | 9 +++------ src/commands/config/mod.rs | 26 -------------------------- src/commands/config/show.rs | 7 +++---- src/commands/config/state.rs | 14 +------------- 4 files changed, 7 insertions(+), 49 deletions(-) diff --git a/src/commands/config/create.rs b/src/commands/config/create.rs index d4b10a78ac..330e83a4f6 100644 --- a/src/commands/config/create.rs +++ b/src/commands/config/create.rs @@ -5,12 +5,11 @@ use anyhow::Context; use color_print::cformat; use std::path::PathBuf; +use worktrunk::config::require_config_path; use worktrunk::git::Repository; use worktrunk::path::format_path_for_display; use worktrunk::styling::{eprintln, hint_message, info_message, success_message}; -use super::state::require_user_config_path; - /// Example user configuration file content (displayed in help with values uncommented) const USER_CONFIG_EXAMPLE: &str = include_str!("../../../dev/config.example.toml"); @@ -47,9 +46,7 @@ pub fn handle_config_create(project: bool) -> anyhow::Result<()> { let config_path = repo .project_config_path()? .context("Cannot determine project config location — no worktree found")?; - let user_config_exists = require_user_config_path() - .map(|p| p.exists()) - .unwrap_or(false); + let user_config_exists = require_config_path().map(|p| p.exists()).unwrap_or(false); create_config_file( config_path, PROJECT_CONFIG_EXAMPLE, @@ -69,7 +66,7 @@ pub fn handle_config_create(project: bool) -> anyhow::Result<()> { .map(|path| path.exists()) .unwrap_or(false); create_config_file( - require_user_config_path()?, + require_config_path()?, USER_CONFIG_EXAMPLE, "User config", &["Edit this file to customize worktree paths and LLM settings"], diff --git a/src/commands/config/mod.rs b/src/commands/config/mod.rs index ccb08a1317..fcd165effd 100644 --- a/src/commands/config/mod.rs +++ b/src/commands/config/mod.rs @@ -38,7 +38,6 @@ mod tests { use super::create::comment_out_config; use super::show::{render_ci_tool_status, warn_unknown_keys}; - use super::state::require_user_config_path; // ==================== comment_out_config tests ==================== @@ -233,29 +232,4 @@ mod tests { render_ci_tool_status(&mut out, "glab", "GitLab", true, true).unwrap(); assert_snapshot!(out, @"✓ glab installed & authenticated"); } - - // ==================== require_user_config_path tests ==================== - - #[test] - fn test_require_user_config_path_returns_ok() { - // In a normal environment, require_user_config_path should succeed - let result = require_user_config_path(); - assert!(result.is_ok()); - let path = result.unwrap(); - assert!(path.ends_with("worktrunk/config.toml")); - } - - #[test] - fn test_require_user_config_path_matches_config_path() { - // Verify that config create/show path matches config loading path. - // This was the root cause of #1134: the two paths diverged on Windows - // when XDG_CONFIG_HOME was set because config create had its own - // XDG/HOME resolution that differed from config loading. - let create_path = require_user_config_path().unwrap(); - let load_path = worktrunk::config::config_path().unwrap(); - assert_eq!( - create_path, load_path, - "config create path and config loading path must be identical" - ); - } } diff --git a/src/commands/config/show.rs b/src/commands/config/show.rs index 375d0dda72..0452bf1113 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use color_print::cformat; use worktrunk::config::{ - ProjectConfig, UserConfig, default_system_config_path, system_config_path, + ProjectConfig, UserConfig, default_system_config_path, require_config_path, system_config_path, }; use worktrunk::git::{CiPlatform, ErrorExt, Repository}; use worktrunk::path::format_path_for_display; @@ -21,7 +21,6 @@ use worktrunk::styling::{ hint_message, info_message, success_message, warning_message, }; -use super::state::require_user_config_path; use crate::cli::{SwitchFormat, version_str}; use crate::commands::configure_shell::{ConfigAction, ConfigureResult, scan_shell_configs}; use crate::commands::list::ci_status::CiToolsStatus; @@ -96,7 +95,7 @@ pub fn handle_config_show(full: bool, format: SwitchFormat) -> anyhow::Result<() /// JSON output for config show: paths, existence, and parsed config contents. fn handle_config_show_json() -> anyhow::Result<()> { - let user_path = require_user_config_path()?; + let user_path = require_config_path()?; let user_exists = user_path.exists(); let user_config = if user_exists { Some(serde_json::to_value(&UserConfig::load()?)?) @@ -583,7 +582,7 @@ fn render_system_config(out: &mut String) -> anyhow::Result { } fn render_user_config(out: &mut String, has_system_config: bool) -> anyhow::Result<()> { - let config_path = require_user_config_path()?; + let config_path = require_config_path()?; writeln!( out, diff --git a/src/commands/config/state.rs b/src/commands/config/state.rs index a6e2208e1d..e04b29c982 100644 --- a/src/commands/config/state.rs +++ b/src/commands/config/state.rs @@ -42,12 +42,11 @@ //! under a single reserved subdirectory rather than adding sibling top-level dirs. use std::fmt::Write as _; -use std::path::{Path, PathBuf}; +use std::path::Path; use anyhow::Context; use color_print::cformat; use path_slash::PathExt as _; -use worktrunk::config::config_path; use worktrunk::git::{BranchRef, Repository, sha_cache}; use worktrunk::path::format_path_for_display; use worktrunk::styling::{ @@ -95,17 +94,6 @@ fn picker_preview_clear(repo: &Repository) -> anyhow::Result { } } -// ==================== Path Helpers ==================== - -/// Get the user config path, or error if it cannot be determined. -/// -/// Delegates to `config_path()` so that `config create` and `config show` -/// resolve the same path that config loading uses — including any CLI -/// (`--config`) or environment variable (`WORKTRUNK_CONFIG_PATH`) overrides. -pub fn require_user_config_path() -> anyhow::Result { - config_path().context("Cannot determine config directory") -} - // ==================== Log Management ==================== /// Top-level files created by `-vv` under `wt_logs_dir()`. From fe0fbbb76efbab1ca012efc95c3c91150d800b10 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 14:05:43 -0700 Subject: [PATCH 19/34] fix(statusline): reserve a fixed 5 columns instead of 20% of width (#2871) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Claude Code statusline runs `wt list statusline` as a subprocess with all three standard streams piped, so `terminal_width()` can't detect the terminal directly and `detect_parent_tty_width()` walks the process tree to find a TTY. That fallback reserved 20% of the detected width for Claude Code's own UI messages, which scales badly: on a 200-column terminal it gave up 40 columns, far more than the chrome needs. This reserves a fixed 5 columns instead, so wide terminals keep nearly all their width and narrow ones still get a sensible margin (`saturating_sub` guards against terminals under 5 columns). Also adds a weekly tend maintenance task that scans the agent CLIs Worktrunk integrates with (Claude Code, Codex, Gemini CLI, OpenCode) for changes to the surfaces Worktrunk depends on — the statusline JSON schema, worktree-lifecycle hooks, and plugin install mechanisms — and files an issue when something relevant changes. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .claude/skills/running-tend/SKILL.md | 21 +++++++++++++ src/styling/mod.rs | 44 +++++++++++++++++++--------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/.claude/skills/running-tend/SKILL.md b/.claude/skills/running-tend/SKILL.md index d57847c396..0cffd1ef91 100644 --- a/.claude/skills/running-tend/SKILL.md +++ b/.claude/skills/running-tend/SKILL.md @@ -288,6 +288,27 @@ git grep -niE "claude|codex" Check the latest IDs at and . The recommended commit-message commands should use the most recent fastest model from each vendor (Haiku for Anthropic, the smallest current Codex variant for OpenAI). +## Weekly Maintenance: Agent App Integration Surfaces + +Worktrunk ships a plugin for each agent CLI it integrates with, and those CLIs +change their integration surfaces without notice. Each week, scan the upstream +changelogs and flag changes that affect what Worktrunk consumes or produces. + +| App | Source to check | Integration surface | +|-----|-----------------|---------------------| +| Claude Code | `gh api repos/anthropics/claude-code/contents/CHANGELOG.md -H 'Accept: application/vnd.github.raw'`, plus `curl -sL https://code.claude.com/docs/en/statusline.md` for the statusline JSON schema | statusline stdin JSON, `WorktreeCreate`/`WorktreeRemove` hooks, plugin marketplace, `/wt-switch-create` | +| Codex | `gh release list -R openai/codex -L 10` | plugin marketplace | +| Gemini CLI | `gh release list -R google-gemini/gemini-cli -L 10` | native extension loading | +| OpenCode | `gh release list -R sst/opencode -L 10` | plugins API in `~/.config/opencode/plugins/` | + +What to flag: + +- **New statusline JSON fields** — `src/commands/statusline.rs` parses `workspace.current_dir`, `model.display_name`, and `context_window.used_percentage`. A newly added field (rate limits, session cost, PR review state) may be worth surfacing in `wt list statusline`. +- **Renamed or removed hook events** — `WorktreeCreate`/`WorktreeRemove` route agent worktree creation through `wt`; a renamed event silently disables isolation rather than erroring. +- **Changed plugin install mechanisms** — `wt config plugins {claude,codex,opencode} install` and the Gemini extension manifest break if the marketplace or plugins-directory contract changes. + +Don't open a PR speculatively. File one issue per relevant change, linking the upstream entry and noting what Worktrunk would need to do. If nothing changed, say so and move on. + ## README Date Check The README blockquote opens with a month+year (e.g., "**April 2026**"). During daily diff --git a/src/styling/mod.rs b/src/styling/mod.rs index 4002bc288d..1004bdf9d7 100644 --- a/src/styling/mod.rs +++ b/src/styling/mod.rs @@ -104,8 +104,8 @@ pub fn terminal_width() -> usize { /// stdout, and stderr — so [`terminal_width`] always falls through to its /// `usize::MAX` sentinel, and the statusline output would overflow the bar. /// As a last resort, this walks up to 10 parent processes looking for a TTY -/// and asks `stty size` for its dimensions, reserving 20% for Claude Code's -/// own UI messages. +/// and asks `stty size` for its dimensions, reserving 5 columns for Claude +/// Code's own UI messages. /// /// Every other caller should use [`terminal_width`] — the parent-TTY walk is /// a statusline-specific workaround, not a general fallback. @@ -131,10 +131,7 @@ fn statusline_width_fallback(base: usize) -> usize { /// /// This is a fallback for subprocesses (like Claude Code hooks) that don't have /// direct TTY access. Walks up to 10 parent processes looking for one with a TTY, -/// then queries that TTY's size. -/// -/// Returns 80% of the detected width to reserve space for Claude Code's UI messages -/// (like "Approaching context limit"). +/// then queries that TTY's size via `stty` and [`statusline_width_from_stty_size`]. #[cfg(unix)] fn detect_parent_tty_width() -> Option { use crate::shell_exec::Cmd; @@ -160,14 +157,7 @@ fn detect_parent_tty_width() -> Option { .run() .ok()?; - let cols = String::from_utf8_lossy(&size.stdout) - .split_whitespace() - .nth(1)? - .parse::() - .ok()?; - - // Reserve 20% for Claude Code UI messages - return Some(cols * 80 / 100); + return statusline_width_from_stty_size(&String::from_utf8_lossy(&size.stdout)); } if ppid == "1" || ppid == "0" { @@ -179,6 +169,17 @@ fn detect_parent_tty_width() -> Option { None } +/// Convert `stty size` output (`" "`) into a statusline width. +/// +/// Reserves 5 columns for Claude Code's UI messages (like "Approaching +/// context limit"). Returns `None` when the output has no parseable column +/// count. +#[cfg(unix)] +fn statusline_width_from_stty_size(stty_size: &str) -> Option { + let cols = stty_size.split_whitespace().nth(1)?.parse::().ok()?; + Some(cols.saturating_sub(5)) +} + /// Calculate visual width of a string, ignoring ANSI escape codes /// /// Uses unicode-width for proper handling of wide characters (CJK, emoji). @@ -232,6 +233,21 @@ mod tests { assert!(width > 0); } + #[cfg(unix)] + #[test] + fn statusline_width_from_stty_size_reserves_five_columns() { + // `stty size` prints " "; the column count loses 5 to + // Claude Code's UI chrome. + assert_eq!(statusline_width_from_stty_size("24 200"), Some(195)); + assert_eq!(statusline_width_from_stty_size("24 80"), Some(75)); + // Saturates rather than underflowing on a terminal narrower than 5. + assert_eq!(statusline_width_from_stty_size("24 3"), Some(0)); + // No parseable column count. + assert_eq!(statusline_width_from_stty_size(""), None); + assert_eq!(statusline_width_from_stty_size("24"), None); + assert_eq!(statusline_width_from_stty_size("24 wide"), None); + } + #[test] fn test_toml_formatting() { let toml_content = r#"worktree-path = "../{{ repo }}.{{ branch }}" From 4fe3f19550b5a246494bb0d4ad2aed51f804344b Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 18:40:41 -0700 Subject: [PATCH 20/34] docs(remove): state that --force discards all uncommitted changes (#2869) The FAQ and the `wt remove --force` help text said `--force` overrides the untracked-files check "for build artifacts". `--force` actually removes a dirty worktree including staged and modified *tracked* files, not just untracked ones (`test_remove_force_with_modified_files`, `test_remove_force_with_staged_files`). On a destructive command, that wording can lead users to consent to more data loss than they expected. Updates the `--force` arg help, the "Force flags" table and example in `src/cli/mod.rs`, and the FAQ to say `--force` discards staged, modified, and untracked files; the `help_remove_long` snapshot and the `remove.md` / `faq.md` doc and skill mirrors are regenerated to match. > _This was written by Claude Code on behalf of Maximilian Roos_ Co-authored-by: Claude Opus 4.7 --- docs/content/faq.md | 2 +- docs/content/remove.md | 10 ++++---- skills/worktrunk/reference/faq.md | 2 +- skills/worktrunk/reference/remove.md | 10 ++++---- src/cli/mod.rs | 11 +++++---- ...gration_tests__help__help_remove_long.snap | 24 ++++++++++--------- 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/docs/content/faq.md b/docs/content/faq.md index 289ae98d5e..0fe35d2675 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -144,7 +144,7 @@ Worktrunk can delete **worktrees** and **branches**. Both have safeguards. ### Worktree removal -`wt remove` mirrors `git worktree remove`: it refuses to remove worktrees with uncommitted changes (staged, modified, or untracked files). The `--force` flag overrides the untracked-files check for build artifacts that weren't cleaned up. +`wt remove` mirrors `git worktree remove`: it refuses to remove worktrees with uncommitted changes (staged, modified, or untracked files). The `--force` flag removes the worktree anyway, discarding all of those changes. For worktrees containing precious ignored data (databases, caches, large assets), use `git worktree lock`: diff --git a/docs/content/remove.md b/docs/content/remove.md index 4bb1b811fa..164215345f 100644 --- a/docs/content/remove.md +++ b/docs/content/remove.md @@ -58,12 +58,12 @@ Worktrunk has two force flags for different situations: | Flag | Scope | When to use | |------|-------|-------------| -| `--force` (`-f`) | Worktree | Worktree has untracked files | +| `--force` (`-f`) | Worktree | Worktree has uncommitted changes | | `--force-delete` (`-D`) | Branch | Branch has unmerged commits | -{{ terminal(cmd="wt remove feature --force # Remove worktree with untracked files|||wt remove feature -D # Delete unmerged branch|||wt remove feature --force -D # Both") }} +{{ terminal(cmd="wt remove feature --force # Remove dirty worktree|||wt remove feature -D # Delete unmerged branch|||wt remove feature --force -D # Both") }} -Without `--force`, removal fails if the worktree contains untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status. +Without `--force`, removal fails if the worktree has staged, modified, or untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status. ## Background removal @@ -110,8 +110,8 @@ Usage: wt remove [OPTIONS] -f, --force Force worktree removal - Remove worktrees even if they contain untracked files (like build artifacts). Without this - flag, removal fails if untracked files exist. + Remove a dirty worktree, including staged, modified, and untracked files. Without this + flag, removal fails if the worktree has any uncommitted changes. -h, --help Print help (see a summary with '-h') diff --git a/skills/worktrunk/reference/faq.md b/skills/worktrunk/reference/faq.md index 228856d523..2bd0a1f841 100644 --- a/skills/worktrunk/reference/faq.md +++ b/skills/worktrunk/reference/faq.md @@ -137,7 +137,7 @@ Worktrunk can delete **worktrees** and **branches**. Both have safeguards. ### Worktree removal -`wt remove` mirrors `git worktree remove`: it refuses to remove worktrees with uncommitted changes (staged, modified, or untracked files). The `--force` flag overrides the untracked-files check for build artifacts that weren't cleaned up. +`wt remove` mirrors `git worktree remove`: it refuses to remove worktrees with uncommitted changes (staged, modified, or untracked files). The `--force` flag removes the worktree anyway, discarding all of those changes. For worktrees containing precious ignored data (databases, caches, large assets), use `git worktree lock`: diff --git a/skills/worktrunk/reference/remove.md b/skills/worktrunk/reference/remove.md index a0009e81b5..cb606f87c8 100644 --- a/skills/worktrunk/reference/remove.md +++ b/skills/worktrunk/reference/remove.md @@ -57,16 +57,16 @@ Worktrunk has two force flags for different situations: | Flag | Scope | When to use | |------|-------|-------------| -| `--force` (`-f`) | Worktree | Worktree has untracked files | +| `--force` (`-f`) | Worktree | Worktree has uncommitted changes | | `--force-delete` (`-D`) | Branch | Branch has unmerged commits | ```bash -$ wt remove feature --force # Remove worktree with untracked files +$ wt remove feature --force # Remove dirty worktree $ wt remove feature -D # Delete unmerged branch $ wt remove feature --force -D # Both ``` -Without `--force`, removal fails if the worktree contains untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status. +Without `--force`, removal fails if the worktree has staged, modified, or untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status. ## Background removal @@ -108,8 +108,8 @@ Options: -f, --force Force worktree removal - Remove worktrees even if they contain untracked files (like build artifacts). Without this - flag, removal fails if untracked files exist. + Remove a dirty worktree, including staged, modified, and untracked files. Without this + flag, removal fails if the worktree has any uncommitted changes. -h, --help Print help (see a summary with '-h') diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c5b1db1647..e20ade71b5 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -430,8 +430,9 @@ pub(crate) struct RemoveArgs { /// Force worktree removal /// - /// Remove worktrees even if they contain untracked files (like build - /// artifacts). Without this flag, removal fails if untracked files exist. + /// Remove a dirty worktree, including staged, modified, and untracked + /// files. Without this flag, removal fails if the worktree has any + /// uncommitted changes. #[arg(short, long)] pub(crate) force: bool, @@ -992,16 +993,16 @@ Worktrunk has two force flags for different situations: | Flag | Scope | When to use | |------|-------|-------------| -| `--force` (`-f`) | Worktree | Worktree has untracked files | +| `--force` (`-f`) | Worktree | Worktree has uncommitted changes | | `--force-delete` (`-D`) | Branch | Branch has unmerged commits | ```console -$ wt remove feature --force # Remove worktree with untracked files +$ wt remove feature --force # Remove dirty worktree $ wt remove feature -D # Delete unmerged branch $ wt remove feature --force -D # Both ``` -Without `--force`, removal fails if the worktree contains untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status. +Without `--force`, removal fails if the worktree has staged, modified, or untracked files. Without `--force-delete`, removal keeps branches with unmerged changes. Use `--no-delete-branch` to keep the branch regardless of merge status. ## Background removal diff --git a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap index 649923c7fc..49294c5170 100644 --- a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap @@ -11,20 +11,22 @@ info: GIT_EDITOR: "" LANG: C LC_ALL: C - LLVM_PROFILE_FILE: /var/folders/v3/8k5q0_6j3_q6gl4h8czr66sh0000gn/T/wt-test-profraw/cov-%m_%p.profraw + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" NO_COLOR: "" PSModulePath: "" RUST_LOG: warn SHELL: "" TERM: alacritty - WORKTRUNK_APPROVALS_PATH: /nonexistent/wt/approvals.toml - WORKTRUNK_CONFIG_PATH: /nonexistent/wt/config.toml - WORKTRUNK_SYSTEM_CONFIG_PATH: /etc/xdg/worktrunk/config.toml + WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" @@ -58,7 +60,7 @@ Usage: wt remove [OPTIONS] [BRANCHES]... -f, --force Force worktree removal - Remove worktrees even if they contain untracked files (like build artifacts). Without this flag, removal fails if untracked files exist. + Remove a dirty worktree, including staged, modified, and untracked files. Without this flag, removal fails if the worktree has any uncommitted changes. -h, --help Print help (see a summary with '-h') @@ -136,16 +138,16 @@ Branches matching these conditions and with empty working trees are dimmed in [ Worktrunk has two force flags for different situations: - Flag Scope When to use - ─────────────────── ──────── ──────────────────────────── - --force (-f) Worktree Worktree has untracked files - --force-delete (-D) Branch Branch has unmerged commits + Flag Scope When to use + ─────────────────── ──────── ──────────────────────────────── + --force (-f) Worktree Worktree has uncommitted changes + --force-delete (-D) Branch Branch has unmerged commits -  wt remove feature --force # Remove worktree with untracked files +  wt remove feature --force # Remove dirty worktree   wt remove feature -D # Delete unmerged branch   wt remove feature --force -D # Both -Without --force, removal fails if the worktree contains untracked files. Without --force-delete, removal keeps branches with unmerged changes. Use --no-delete-branch to keep the branch regardless of merge status. +Without --force, removal fails if the worktree has staged, modified, or untracked files. Without --force-delete, removal keeps branches with unmerged changes. Use --no-delete-branch to keep the branch regardless of merge status. Background removal From 5d7dac1ea52ddbf94fffc7f14635a0200eb33d08 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 18:41:20 -0700 Subject: [PATCH 21/34] refactor(cli): consolidate --no-verify deprecation into one path (#2868) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deprecated `--no-verify` flag was declared as a clap arg five times — across `SwitchArgs`, `RemoveArgs`, `CommitArgs`, `SquashArgs`, and `MergeArgs` — under two field names, resolved through two helpers, with the deprecation-warning string hand-duplicated in two places. This collapses the four bool-resolving commands (`switch`, `remove`, `step commit`, `step squash`) onto a single flattened `HookFlags` args struct with one `resolve()` method, and routes every `--no-verify` deprecation warning — merge's included — through one emitter, so the warning text exists in exactly one place. `wt merge` keeps its own flags: its hooks flag is genuinely tri-state (`Option`, so config `[merge] verify` still applies) with a positive `--verify` override, which `HookFlags`'s `SetFalse`/default-true shape cannot express. It shares only the warning emitter — forcing it into the struct would need a special case. Behavior is unchanged: `--no-verify` still works as a deprecated alias and emits the same warning under the same conditions. One visible `--help` change, a direct consequence of the shared struct: `--no-hooks` for `step commit`/`step squash` moves under the `Automation` heading, matching where `switch`/`remove`/`merge` already place it. Doc mirrors regenerated. > _This was written by Claude Code on behalf of Maximilian Roos_ Co-authored-by: Claude Opus 4.7 --- docs/content/step.md | 12 +++---- skills/worktrunk/reference/step.md | 12 +++---- src/cli/mod.rs | 51 ++++++++++++++++++++++-------- src/cli/step.rs | 18 +++-------- src/main.rs | 35 +++++++++----------- tests/integration_tests/remove.rs | 33 +++++++++++++++++++ 6 files changed, 101 insertions(+), 60 deletions(-) diff --git a/docs/content/step.md b/docs/content/step.md index e7567dee09..20063c8c2c 100644 --- a/docs/content/step.md +++ b/docs/content/step.md @@ -138,9 +138,6 @@ Usage: wt step commit [OPTIONS] -b, --branch <BRANCH> Branch to operate on (defaults to current worktree) - --no-hooks - Skip hooks - --stage <STAGE> What to stage before committing [default: all] @@ -156,6 +153,9 @@ Usage: wt step commit [OPTIONS] Print help (see a summary with '-h') Automation: + --no-hooks + Skip hooks + --format <FORMAT> Output format @@ -233,9 +233,6 @@ Usage: wt step squash [OPTIONS] Defaults to default branch. Options: - --no-hooks - Skip hooks - --stage <STAGE> What to stage before committing [default: all] @@ -251,6 +248,9 @@ Usage: wt step squash [OPTIONS] Print help (see a summary with '-h') Automation: + --no-hooks + Skip hooks + --format <FORMAT> Output format diff --git a/skills/worktrunk/reference/step.md b/skills/worktrunk/reference/step.md index d945e5a830..bd9680f2cc 100644 --- a/skills/worktrunk/reference/step.md +++ b/skills/worktrunk/reference/step.md @@ -133,9 +133,6 @@ Options: -b, --branch Branch to operate on (defaults to current worktree) - --no-hooks - Skip hooks - --stage What to stage before committing [default: all] @@ -151,6 +148,9 @@ Options: Print help (see a summary with '-h') Automation: + --no-hooks + Skip hooks + --format Output format @@ -232,9 +232,6 @@ Arguments: Defaults to default branch. Options: - --no-hooks - Skip hooks - --stage What to stage before committing [default: all] @@ -250,6 +247,9 @@ Options: Print help (see a summary with '-h') Automation: + --no-hooks + Skip hooks + --format Output format diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e20ade71b5..3b41d7c9ec 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -266,6 +266,39 @@ pub(crate) struct Cli { pub command: Option, } +/// Shared `--no-hooks` / `--no-verify` flags for commands that resolve hook +/// skipping to a plain `bool` (`switch`, `remove`, `step commit`, +/// `step squash`). +/// +/// `wt merge` does not flatten this struct: its hooks flag is tri-state +/// (`Option`, so config `[merge] verify` can still apply) and it carries +/// a positive `--verify` override. It declares its own flags but routes the +/// `--no-verify` deprecation through `crate::warn_no_verify_deprecated`, so the +/// warning text lives in exactly one place. +#[derive(Args)] +pub(crate) struct HookFlags { + /// Skip hooks + #[arg(long = "no-hooks", action = clap::ArgAction::SetFalse, default_value_t = true, help_heading = "Automation")] + pub(crate) verify: bool, + + /// Skip hooks (deprecated alias for --no-hooks) + #[arg(long = "no-verify", hide = true)] + pub(crate) no_verify_deprecated: bool, +} + +impl HookFlags { + /// Resolve to the effective verify value, emitting the deprecation warning + /// once if `--no-verify` was used. + pub(crate) fn resolve(&self) -> bool { + if self.no_verify_deprecated { + crate::warn_no_verify_deprecated(); + false + } else { + self.verify + } + } +} + #[derive(Args)] pub(crate) struct SwitchArgs { /// Branch name or shortcut @@ -344,13 +377,8 @@ pub(crate) struct SwitchArgs { #[arg(long, overrides_with = "no_cd", hide = true)] pub(crate) cd: bool, - /// Skip hooks - #[arg(long = "no-hooks", action = clap::ArgAction::SetFalse, default_value_t = true, help_heading = "Automation")] - pub(crate) verify: bool, - - /// Skip hooks (deprecated alias for --no-hooks) - #[arg(long = "no-verify", hide = true)] - pub(crate) no_verify_deprecated: bool, + #[command(flatten)] + pub(crate) hooks: HookFlags, /// Output format /// @@ -420,13 +448,8 @@ pub(crate) struct RemoveArgs { #[arg(long)] pub(crate) foreground: bool, - /// Skip hooks - #[arg(long = "no-hooks", action = clap::ArgAction::SetFalse, default_value_t = true, help_heading = "Automation")] - pub(crate) verify: bool, - - /// Skip hooks (deprecated alias for --no-hooks) - #[arg(long = "no-verify", hide = true)] - pub(crate) no_verify_deprecated: bool, + #[command(flatten)] + pub(crate) hooks: HookFlags, /// Force worktree removal /// diff --git a/src/cli/step.rs b/src/cli/step.rs index ff64926acc..0f0f40b580 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -6,13 +6,8 @@ pub struct CommitArgs { #[arg(short, long, add = crate::completion::worktree_only_completer())] pub(crate) branch: Option, - /// Skip hooks - #[arg(long = "no-hooks", action = clap::ArgAction::SetFalse, default_value_t = true)] - pub(crate) verify: bool, - - /// Skip hooks (deprecated alias for --no-hooks) - #[arg(long = "no-verify", hide = true)] - pub(crate) no_verify_deprecated: bool, + #[command(flatten)] + pub(crate) hooks: crate::cli::HookFlags, /// What to stage before committing [default: all] #[arg(long)] @@ -41,13 +36,8 @@ pub struct SquashArgs { #[arg(add = crate::completion::branch_value_completer())] pub(crate) target: Option, - /// Skip hooks - #[arg(long = "no-hooks", action = clap::ArgAction::SetFalse, default_value_t = true)] - pub(crate) verify: bool, - - /// Skip hooks (deprecated alias for --no-hooks) - #[arg(long = "no-verify", hide = true)] - pub(crate) no_verify_deprecated: bool, + #[command(flatten)] + pub(crate) hooks: crate::cli::HookFlags, /// What to stage before committing [default: all] #[arg(long)] diff --git a/src/main.rs b/src/main.rs index 94ad556750..352146e316 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,18 +144,16 @@ fn warn_select_deprecated() { ); } -/// Resolve the `--no-hooks` / `--no-verify` pair: emit a deprecation warning -/// if the old flag was used, then return the effective verify value. -fn resolve_verify(verify: bool, no_verify_deprecated: bool) -> bool { - if no_verify_deprecated { - eprintln!( - "{}", - warning_message("--no-verify is deprecated; use --no-hooks instead") - ); - false - } else { - verify - } +/// Emit the canonical `--no-verify` deprecation warning to stderr. +/// +/// Single source of this warning text. `HookFlags::resolve` (switch, remove, +/// step commit, step squash) and `handle_merge_command` both call here, so the +/// message stays identical across every command that accepts `--no-verify`. +pub(crate) fn warn_no_verify_deprecated() { + eprintln!( + "{}", + warning_message("--no-verify is deprecated; use --no-hooks instead") + ); } fn handle_hook_command(action: HookCommand, yes: bool) -> anyhow::Result<()> { @@ -201,7 +199,7 @@ fn handle_hook_command(action: HookCommand, yes: bool) -> anyhow::Result<()> { fn handle_step_command(action: StepCommand, yes: bool) -> anyhow::Result<()> { match action { StepCommand::Commit(args) => { - let verify = resolve_verify(args.verify, args.no_verify_deprecated); + let verify = args.hooks.resolve(); let format = args.format; // `--show-prompt` and `--dry-run` emit raw text (rendered prompt or LLM // preview), which would corrupt a JSON consumer's stdout. Refuse the @@ -230,7 +228,7 @@ fn handle_step_command(action: StepCommand, yes: bool) -> anyhow::Result<()> { Ok(()) } StepCommand::Squash(args) => { - let verify = resolve_verify(args.verify, args.no_verify_deprecated); + let verify = args.hooks.resolve(); // `--show-prompt` and `--dry-run` emit raw text (rendered prompt or LLM // preview), which would corrupt a JSON consumer's stdout. if args.format == SwitchFormat::Json && (args.show_prompt || args.dry_run) { @@ -670,7 +668,7 @@ fn handle_select_command(_branches: bool, _remotes: bool) -> anyhow::Result<()> } fn handle_switch_command(args: SwitchArgs, yes: bool) -> anyhow::Result<()> { - let verify = resolve_verify(args.verify, args.no_verify_deprecated); + let verify = args.hooks.resolve(); // With no branch argument, `wt switch` opens a TUI picker — config // deprecation warnings would render above the picker and push it down. @@ -896,7 +894,7 @@ fn validate_remove_targets( /// or success message. See [`commands::process::run_internal_sweep`]. fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> { let json_mode = args.format == SwitchFormat::Json; - let verify = resolve_verify(args.verify, args.no_verify_deprecated); + let verify = args.hooks.resolve(); UserConfig::load() .context("Failed to load config") .and_then(|config| { @@ -1331,10 +1329,7 @@ fn init_logging(verbose_level: u8) { fn handle_merge_command(args: MergeArgs, yes: bool) -> anyhow::Result<()> { if args.no_verify { - eprintln!( - "{}", - warning_message("--no-verify is deprecated; use --no-hooks instead") - ); + warn_no_verify_deprecated(); } handle_merge(MergeOptions { target: args.target.as_deref(), diff --git a/tests/integration_tests/remove.rs b/tests/integration_tests/remove.rs index 329b24d9d4..6f1bdacd03 100644 --- a/tests/integration_tests/remove.rs +++ b/tests/integration_tests/remove.rs @@ -2107,6 +2107,39 @@ approved-commands = ["echo 'hook ran' > {}"] ); } +#[rstest] +fn test_remove_no_verify_deprecated_still_works(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-no-verify"); + + // --no-verify is a deprecated alias for --no-hooks: still works, still + // emits the shared deprecation warning that points at --no-hooks. + let output = repo + .wt_command() + .args([ + "remove", + "--foreground", + "--yes", + "--no-verify", + "feature-no-verify", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--no-verify is deprecated"), + "Expected deprecation warning in stderr: {stderr}" + ); + assert!( + stderr.contains("--no-hooks"), + "Expected --no-hooks suggestion in stderr: {stderr}" + ); + assert!( + !worktree_path.exists(), + "Worktree should be removed with --no-verify" + ); +} + /// /// Even when a worktree is in detached HEAD state (no branch), the pre-remove /// hook should still execute. From f6343d77900198969a3be4523bff678045bd720d Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 18:42:25 -0700 Subject: [PATCH 22/34] fix(output): correct user-output consistency defects (#2867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit of `wt`'s user-visible strings against the repo's `writing-user-outputs` skill. Six consistency corrections, each matching a rule the skill states — and, in most cases, a sibling message that already follows it: - `RefCreateConflict` hint: dropped the cross-message pronoun ("it") and switched to the skill's `"To X, run Y"` command-hint form. - `"All shells already configured"` and the version-check `"Up to date"`: `success ✓` → `info ○` — both acknowledge existing state without changing anything. - `relocate` summary: keyed the message type on whether anything was *relocated*, not on whether anything was *skipped* (the same outcome was rendered two ways). - `"Diagnostic saved"`: → `success` (a file was created) and the `@`-before-path convention. - Removed a stray trailing period — the only single-line message in the codebase that had one. `--no-verify` is deliberately untouched (handled in the `canonical-paths` PR). Snapshot tests updated. > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: Claude Opus 4.7 --- src/commands/command_approval.rs | 2 +- src/commands/config/show.rs | 57 ++++++++++++------- src/commands/relocate.rs | 54 ++++++++++-------- src/diagnostic.rs | 4 +- src/git/error.rs | 2 +- src/output/shell_integration.rs | 2 +- tests/integration_tests/configure_shell.rs | 4 +- ...pty__approval_prompt_permission_error.snap | 2 +- ...w__config_show_full_command_not_found.snap | 2 +- ...g_show__config_show_full_gitea_remote.snap | 2 +- ...show__config_show_full_not_configured.snap | 2 +- ...cate__relocate_mixed_success_and_skip.snap | 2 +- ...locate__relocate_same_target_no_panic.snap | 2 +- ...ts__switch__switch_mr_create_conflict.snap | 2 +- ...itch__switch_pr_azure_create_conflict.snap | 2 +- ...witch__switch_pr_azure_forge_platform.snap | 2 +- ...ts__switch__switch_pr_create_conflict.snap | 2 +- ...itch__switch_pr_gitea_create_conflict.snap | 2 +- ...witch__switch_pr_gitea_forge_platform.snap | 2 +- 19 files changed, 85 insertions(+), 64 deletions(-) diff --git a/src/commands/command_approval.rs b/src/commands/command_approval.rs index 1967de43f7..17fec1c06d 100644 --- a/src/commands/command_approval.rs +++ b/src/commands/command_approval.rs @@ -87,7 +87,7 @@ pub fn approve_command_batch( ); eprintln!( "{}", - hint_message("Approval will be requested again next time.") + hint_message("Approval will be requested again next time") ); } } diff --git a/src/commands/config/show.rs b/src/commands/config/show.rs index 0452bf1113..714c1fd79e 100644 --- a/src/commands/config/show.rs +++ b/src/commands/config/show.rs @@ -17,8 +17,8 @@ use worktrunk::path::format_path_for_display; use worktrunk::shell::{FileDetectionResult, Shell, scan_for_detection_details}; use worktrunk::shell_exec::Cmd; use worktrunk::styling::{ - error_message, format_bash_with_gutter, format_heading, format_toml, format_with_gutter, - hint_message, info_message, success_message, warning_message, + FormattedMessage, error_message, format_bash_with_gutter, format_heading, format_toml, + format_with_gutter, hint_message, info_message, success_message, warning_message, }; use crate::cli::{SwitchFormat, version_str}; @@ -1389,28 +1389,26 @@ pub(super) fn render_ci_tool_status( Ok(()) } +/// Format the version-check line given the latest release version. +/// +/// Pure over `latest` so both arms are unit-testable without injecting a +/// version through the environment; `render_version_check` supplies the value +/// from `fetch_latest_version`. +fn format_version_status(latest: &str) -> FormattedMessage { + let current = crate::cli::version_str(); + if is_newer_version(latest, env!("CARGO_PKG_VERSION")) { + info_message(cformat!( + "Update available: {latest} (current: {current})" + )) + } else { + info_message(cformat!("Up to date ({current})")) + } +} + /// Render version update check (fetches from GitHub) fn render_version_check(out: &mut String) -> anyhow::Result<()> { match fetch_latest_version() { - Ok(latest) => { - let current = crate::cli::version_str(); - let current_semver = env!("CARGO_PKG_VERSION"); - if is_newer_version(&latest, current_semver) { - writeln!( - out, - "{}", - info_message(cformat!( - "Update available: {latest} (current: {current})" - )) - )?; - } else { - writeln!( - out, - "{}", - success_message(cformat!("Up to date ({current})")) - )?; - } - } + Ok(latest) => writeln!(out, "{}", format_version_status(&latest))?, Err(e) => { log::debug!("Version check failed: {e}"); writeln!(out, "{}", hint_message("Version check unavailable"))?; @@ -1509,4 +1507,21 @@ mod tests { assert!(!is_newer_version("invalid", "0.23.2")); assert!(!is_newer_version("0.23.2", "invalid")); } + + #[test] + fn test_format_version_status() { + // A version far above the current crate version is "newer". + let update = format_version_status("999.0.0").to_string(); + assert!( + update.contains("Update available"), + "expected update message, got: {update}" + ); + + // The current crate version is not newer than itself. + let up_to_date = format_version_status(env!("CARGO_PKG_VERSION")).to_string(); + assert!( + up_to_date.contains("Up to date"), + "expected up-to-date message, got: {up_to_date}" + ); + } } diff --git a/src/commands/relocate.rs b/src/commands/relocate.rs index 82525cae51..9b7cb1c559 100644 --- a/src/commands/relocate.rs +++ b/src/commands/relocate.rs @@ -709,21 +709,26 @@ pub fn show_dry_run_preview(candidates: &[RelocationCandidate]) { } /// Show summary of relocations performed. +/// +/// Only called after validation produced at least one candidate, so +/// `relocated + skipped >= 1` always — each candidate either moves or is skipped. pub fn show_summary(relocated: usize, skipped: usize) { - if relocated > 0 || skipped > 0 { - eprintln!(); - let plural = |n: usize| if n == 1 { "worktree" } else { "worktrees" }; - if skipped == 0 { - let msg = format!("Relocated {relocated} {}", plural(relocated)); - eprintln!("{}", success_message(msg)); - } else { - let msg = format!( - "Relocated {relocated} {}, skipped {skipped} {}", - plural(relocated), - plural(skipped) - ); - eprintln!("{}", info_message(msg)); - } + eprintln!(); + let plural = |n: usize| if n == 1 { "worktree" } else { "worktrees" }; + let msg = if skipped == 0 { + format!("Relocated {relocated} {}", plural(relocated)) + } else { + format!( + "Relocated {relocated} {}, skipped {skipped} {}", + plural(relocated), + plural(skipped) + ) + }; + // Success when worktrees moved; info when only skips (no change made). + if relocated > 0 { + eprintln!("{}", success_message(msg)); + } else { + eprintln!("{}", info_message(msg)); } } @@ -744,15 +749,16 @@ pub fn show_no_relocations_needed(template_errors: usize) { } /// Show message when all candidates were skipped during validation. +/// +/// Only called when validation skipped every candidate, and at least one +/// candidate exists by then, so `skipped >= 1` always. pub fn show_all_skipped(skipped: usize) { - if skipped > 0 { - eprintln!(); - eprintln!( - "{}", - info_message(format!( - "Skipped {skipped} worktree{}", - if skipped == 1 { "" } else { "s" } - )) - ); - } + eprintln!(); + eprintln!( + "{}", + info_message(format!( + "Skipped {skipped} worktree{}", + if skipped == 1 { "" } else { "s" } + )) + ); } diff --git a/src/diagnostic.rs b/src/diagnostic.rs index fc46c415c7..6e8fcf39fe 100644 --- a/src/diagnostic.rs +++ b/src/diagnostic.rs @@ -51,7 +51,7 @@ use minijinja::{Environment, context}; use worktrunk::git::Repository; use worktrunk::path::format_path_for_display; use worktrunk::shell_exec::Cmd; -use worktrunk::styling::{eprintln, hint_message, info_message, warning_message}; +use worktrunk::styling::{eprintln, hint_message, success_message, warning_message}; use crate::cli::version_str; use crate::output; @@ -254,7 +254,7 @@ pub(crate) fn write_if_verbose(verbose: u8, command_line: &str, error_msg: Optio let path_display = format_path_for_display(&path); eprintln!( "{}", - info_message(format!("Diagnostic saved: {path_display}")) + success_message(format!("Diagnostic saved @ {path_display}")) ); // Only show gh command if gh is installed diff --git a/src/git/error.rs b/src/git/error.rs index 9e2151f75b..89d4b65963 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -1220,7 +1220,7 @@ impl GitError { "Cannot create branch for {syntax}{number} — {name} already has branch {branch}" )), hint_message(cformat!( - "To switch to it: wt switch {syntax}{number}" + "To switch to the existing branch, run wt switch {syntax}{number}" )) ) } diff --git a/src/output/shell_integration.rs b/src/output/shell_integration.rs index 0a7bf3cdde..f0fabac47e 100644 --- a/src/output/shell_integration.rs +++ b/src/output/shell_integration.rs @@ -347,7 +347,7 @@ pub fn print_shell_install_result(scan_result: &crate::commands::configure_shell )) ); } else { - eprintln!("{}", success_message("All shells already configured")); + eprintln!("{}", info_message("All shells already configured")); } // Zsh compinit advisory diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index 965d142dce..b4ca415a40 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -155,7 +155,7 @@ fn test_configure_shell_already_exists(repo: TestRepo, temp_home: TempDir) { ----- stderr ----- ○ Already configured shell extension & completions for zsh @ ~/.zshrc - ✓ All shells already configured + ○ All shells already configured "); }); @@ -1608,7 +1608,7 @@ fn test_configure_shell_no_warning_when_already_configured(repo: TestRepo, temp_ ----- stderr ----- ○ Already configured shell extension & completions for zsh @ ~/.zshrc - ✓ All shells already configured + ○ All shells already configured "); }); } diff --git a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap index 38062520e2..5d4372e856 100644 --- a/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap +++ b/tests/snapshots/integration__integration_tests__approval_pty__approval_prompt_permission_error.snap @@ -8,7 +8,7 @@ expression: "&output" ❯ Allow and remember? [y/N] y ▲ Failed to save command approval: Failed to open lock file: Permission denied (os error 13) -↳ Approval will be requested again next time. +↳ Approval will be requested again next time ◎ Running pre-start project hook @ _REPO_.test-permission   echo 'test command' test command diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_full_command_not_found.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_full_command_not_found.snap index c34ee647f2..9caf77371e 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_full_command_not_found.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_full_command_not_found.snap @@ -74,7 +74,7 @@ exit_code: 0 DIAGNOSTICS ↳ CI status requires GitHub, GitLab, Gitea, or Azure DevOps remote -✓ Up to date ([VERSION]) +○ Up to date ([VERSION]) ✗ Commit generation failed (nonexistent-llm-command-12345 -m test-model)   ✗ Commit generation command failed     sh: nonexistent-llm-command-12345: command not found diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_full_gitea_remote.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_full_gitea_remote.snap index 2cc443375f..4b3413eef1 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_full_gitea_remote.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_full_gitea_remote.snap @@ -71,7 +71,7 @@ exit_code: 0 DIAGNOSTICS ▲ tea installed but not authenticated; run tea login add -✓ Up to date ([VERSION]) +○ Up to date ([VERSION]) ↳ Commit generation not configured OTHER diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_full_not_configured.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_full_not_configured.snap index 8c334d1abd..931cd6262e 100644 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_full_not_configured.snap +++ b/tests/snapshots/integration__integration_tests__config_show__config_show_full_not_configured.snap @@ -71,7 +71,7 @@ exit_code: 0 DIAGNOSTICS ↳ CI status requires GitHub, GitLab, Gitea, or Azure DevOps remote -✓ Up to date ([VERSION]) +○ Up to date ([VERSION]) ↳ Commit generation not configured OTHER diff --git a/tests/snapshots/integration__integration_tests__step_relocate__relocate_mixed_success_and_skip.snap b/tests/snapshots/integration__integration_tests__step_relocate__relocate_mixed_success_and_skip.snap index 7fedd91cf1..7cb97edf08 100644 --- a/tests/snapshots/integration__integration_tests__step_relocate__relocate_mixed_success_and_skip.snap +++ b/tests/snapshots/integration__integration_tests__step_relocate__relocate_mixed_success_and_skip.snap @@ -47,4 +47,4 @@ exit_code: 0 ▲ Skipping feature2 (locked) ✓ Relocated feature1: _PARENT_/wrong-location-1 → _REPO_.feature1 -○ Relocated 1 worktree, skipped 1 worktree +✓ Relocated 1 worktree, skipped 1 worktree diff --git a/tests/snapshots/integration__integration_tests__step_relocate__relocate_same_target_no_panic.snap b/tests/snapshots/integration__integration_tests__step_relocate__relocate_same_target_no_panic.snap index 6ac2192239..ead2788db0 100644 --- a/tests/snapshots/integration__integration_tests__step_relocate__relocate_same_target_no_panic.snap +++ b/tests/snapshots/integration__integration_tests__step_relocate__relocate_same_target_no_panic.snap @@ -49,4 +49,4 @@ exit_code: 0 ✓ Relocated alpha: _PARENT_/wrong-location-1 → _REPO_/repo.shared ▲ Skipping beta (target occupied: _REPO_/repo.shared) -○ Relocated 1 worktree, skipped 1 worktree +✓ Relocated 1 worktree, skipped 1 worktree diff --git a/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap b/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap index f2e2356278..7490957c85 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_mr_create_conflict.snap @@ -49,4 +49,4 @@ exit_code: 1   Fix authentication bug in login flow (!101)   by @alice · opened · feature-auth · https://gitlab.com/owner/test-repo/-/merge_requests/101 ✗ Cannot create branch for mr:101 — MR already has branch feature-auth -↳ To switch to it: wt switch mr:101 +↳ To switch to the existing branch, run wt switch mr:101 diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_create_conflict.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_create_conflict.snap index 82e925c7cd..af4c5abe32 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_create_conflict.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_create_conflict.snap @@ -58,4 +58,4 @@ exit_code: 1   Fix authentication bug in login flow (#101)   by @alice@example.com · active · feature-auth · https://dev.azure.com/myorg/myproject/_git/test-repo/pullrequest/101 ✗ Cannot create branch for pr:101 — PR already has branch feature-auth -↳ To switch to it: wt switch pr:101 +↳ To switch to the existing branch, run wt switch pr:101 diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_forge_platform.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_forge_platform.snap index 7f3722bf24..88d0335d6b 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_forge_platform.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_azure_forge_platform.snap @@ -58,4 +58,4 @@ exit_code: 1   Override test (#101)   by @alice@example.com · active · feature-auth · https://dev.azure.com/myorg/myproject/_git/test-repo/pullrequest/101 ✗ Cannot create branch for pr:101 — PR already has branch feature-auth -↳ To switch to it: wt switch pr:101 +↳ To switch to the existing branch, run wt switch pr:101 diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap index 8e765d02f0..a86c9f6879 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_create_conflict.snap @@ -49,4 +49,4 @@ exit_code: 1   Fix authentication bug in login flow (#101)   by @alice · open · feature-auth · https://github.com/owner/test-repo/pull/101 ✗ Cannot create branch for pr:101 — PR already has branch feature-auth -↳ To switch to it: wt switch pr:101 +↳ To switch to the existing branch, run wt switch pr:101 diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_create_conflict.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_create_conflict.snap index 7299296056..53d06af4a0 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_create_conflict.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_create_conflict.snap @@ -49,4 +49,4 @@ exit_code: 1   Fix authentication bug in login flow (#101)   by @alice · open · feature-auth · https://gitea.example.com/owner/test-repo/pulls/101 ✗ Cannot create branch for pr:101 — PR already has branch feature-auth -↳ To switch to it: wt switch pr:101 +↳ To switch to the existing branch, run wt switch pr:101 diff --git a/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_forge_platform.snap b/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_forge_platform.snap index e6617bfd2f..779a2431a4 100644 --- a/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_forge_platform.snap +++ b/tests/snapshots/integration__integration_tests__switch__switch_pr_gitea_forge_platform.snap @@ -49,4 +49,4 @@ exit_code: 1   Override test (#101)   by @alice · open · feature-auth · https://git.internal.example.com/owner/test-repo/pulls/101 ✗ Cannot create branch for pr:101 — PR already has branch feature-auth -↳ To switch to it: wt switch pr:101 +↳ To switch to the existing branch, run wt switch pr:101 From 160d0c7a2d7b40ea35f6a7c480d4c8722c710617 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 18:43:12 -0700 Subject: [PATCH 23/34] fix(relocate): atomic no-overwrite rename for --clobber backup (#2865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wt switch --clobber` and `wt step relocate --clobber` each had their own logic for backing up a path blocking the target, and the two had diverged. relocate used an `exists()` check followed by `std::fs::rename` — a time-of-check/time-of-use race, since `std::fs::rename` silently replaces an existing file or empty directory on Unix and could destroy a just-created backup. switch (#2849) instead used `renamore::rename_exclusive`, an atomic no-overwrite rename, and counted up through suffixes (`…-2`, `…-3`, …) on a name collision rather than failing. This PR replaces both with one shared helper in `commands::backup`. `back_up_clobbered_path_now` computes the timestamped name and delegates to the suffix-iterating `back_up_clobbered_path`, which moves the blocker with `renamore::rename_exclusive` — atomic and no-overwrite, so an existing backup is never clobbered; a name collision just lands on the next free `-N` name. relocate gains the suffix fallback (previously it failed closed on `AlreadyExists`), the timestamp computation is no longer duplicated across the two call sites, and `renamore::rename_exclusive` now has a single caller. `renamore` is already a direct dependency and keeps the platform FFI and its `unsafe` inside that crate, so worktrunk stays `unsafe_code = "forbid"`. relocate's backup name changes from `.bak-` to the extension-aware `.bak.` form used by switch (e.g. `repo.feature` → `repo.feature.bak.`). The `test_relocate_clobber_error_backup_exists` regression test asserted the old fail-closed behavior and is rewritten as `test_relocate_clobber_falls_back_when_backup_taken`, mirroring the switch test. CLI help and the generated doc mirrors are updated for the new naming and fallback. The shared helper carries the suffix-iteration, collision, and missing-source unit tests (moved from `resolve.rs`); the relocate `--clobber` path keeps its integration coverage. > _This was written by Claude Code on behalf of Maximilian Roos_ --------- Co-authored-by: Claude Opus 4.7 --- docs/content/step.md | 7 +- skills/worktrunk/reference/step.md | 7 +- src/cli/step.rs | 7 +- src/commands/backup.rs | 237 ++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/relocate.rs | 37 +-- src/commands/step/relocate.rs | 2 +- src/commands/worktree/resolve.rs | 178 ------------- src/commands/worktree/switch.rs | 22 +- tests/integration_tests/step_relocate.rs | 41 +-- ...p_relocate__relocate_clobber_backs_up.snap | 14 +- 11 files changed, 305 insertions(+), 248 deletions(-) create mode 100644 src/commands/backup.rs diff --git a/docs/content/step.md b/docs/content/step.md index 20063c8c2c..9405ba6a05 100644 --- a/docs/content/step.md +++ b/docs/content/step.md @@ -826,7 +826,9 @@ this by using a temporary location. ### Clobbering With `--clobber`, non-worktree paths at target locations are moved to -`.bak-` before relocating. +`.bak.` before relocating. If that name is already taken, +the move counts up (`…-2`, `…-3`, …) until it finds a free name, so an +existing backup is never overwritten. ### Main worktree behavior @@ -866,7 +868,8 @@ Usage: wt step relocate [OPTIONS]--clobber Backup non-worktree paths at target locations - Moves blocking paths to <path>.bak-<timestamp>. + Moves blocking paths to <path>.bak.<timestamp>. If that name is taken, counts up (…-2, …-3 + , …) to a free name. -h, --help Print help (see a summary with '-h') diff --git a/skills/worktrunk/reference/step.md b/skills/worktrunk/reference/step.md index bd9680f2cc..bb94f4d629 100644 --- a/skills/worktrunk/reference/step.md +++ b/skills/worktrunk/reference/step.md @@ -869,7 +869,9 @@ this by using a temporary location. ### Clobbering With `--clobber`, non-worktree paths at target locations are moved to -`.bak-` before relocating. +`.bak.` before relocating. If that name is already taken, +the move counts up (`…-2`, `…-3`, …) until it finds a free name, so an +existing backup is never overwritten. ### Main worktree behavior @@ -909,7 +911,8 @@ Options: --clobber Backup non-worktree paths at target locations - Moves blocking paths to .bak-. + Moves blocking paths to .bak.. If that name is taken, counts up (…-2, …-3 + , …) to a free name. -h, --help Print help (see a summary with '-h') diff --git a/src/cli/step.rs b/src/cli/step.rs index 0f0f40b580..99573b8001 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -631,7 +631,9 @@ this by using a temporary location. ## Clobbering With `--clobber`, non-worktree paths at target locations are moved to -`.bak-` before relocating. +`.bak.` before relocating. If that name is already taken, +the move counts up (`…-2`, `…-3`, …) until it finds a free name, so an +existing backup is never overwritten. ## Main worktree behavior @@ -663,7 +665,8 @@ Note: This command is experimental and may change in future versions. /// Backup non-worktree paths at target locations /// - /// Moves blocking paths to `.bak-`. + /// Moves blocking paths to `.bak.`. If that name is + /// taken, counts up (`…-2`, `…-3`, …) to a free name. #[arg(long)] clobber: bool, diff --git a/src/commands/backup.rs b/src/commands/backup.rs new file mode 100644 index 0000000000..517428d581 --- /dev/null +++ b/src/commands/backup.rs @@ -0,0 +1,237 @@ +//! Atomic no-overwrite backup of a path blocking a `--clobber` move. +//! +//! Both `wt switch --clobber` and `wt step relocate --clobber` need to move a +//! stale or blocking path aside before they can take its place. They share this +//! helper so the two paths behave identically: the backup name and the atomic +//! no-overwrite rename are defined once. +//! +//! # Why an atomic rename +//! +//! The backup name is only second-resolution (`.bak.`), so an +//! `exists()` check followed by a rename would race: another process could +//! create that path in the gap. [`renamore::rename_exclusive`] closes the gap +//! — an atomic no-overwrite rename (`renameat2(RENAME_NOREPLACE)` on Linux, +//! `renamex_np(RENAME_EXCL)` on macOS, `MoveFileExW` on Windows) that fails +//! closed rather than overwriting an existing backup. `std::fs::rename` +//! silently replaces an existing file or empty directory and cannot be used +//! here. A name collision is not fatal: the move counts up (`…-2`, `…-3`, …) +//! until it lands on a free name. + +use std::path::{Path, PathBuf}; + +use worktrunk::path::format_path_for_display; + +/// Generate a backup path for the given path with a timestamp suffix. +/// +/// For paths with extensions: `file.txt` → `file.txt.bak.TIMESTAMP` +/// For paths without extensions: `foo` → `foo.bak.TIMESTAMP` +/// +/// Returns an error for unusual paths without a file name (e.g., `/` or `..`). +fn generate_backup_path(path: &Path, suffix: &str) -> anyhow::Result { + let file_name = path.file_name().ok_or_else(|| { + anyhow::anyhow!( + "Cannot generate backup path for {}", + format_path_for_display(path) + ) + })?; + + if path.extension().is_none() { + // Path has no extension (e.g., /repo/feature) + Ok(path.with_file_name(format!("{}.bak.{suffix}", file_name.to_string_lossy()))) + } else { + // Path has an extension (e.g., /repo.feature or /file.txt) + Ok(path.with_extension(format!( + "{}.bak.{suffix}", + path.extension() + .map(|e| e.to_string_lossy().to_string()) + .unwrap_or_default() + ))) + } +} + +/// Move `blocking_path` aside to a `.bak.` sibling. +/// +/// If that name is already taken — a same-second clobber, or a path that raced +/// in after planning — it counts up (`…-2`, `…-3`, …) until it finds a free +/// name. Every attempt is an atomic no-overwrite rename +/// ([`renamore::rename_exclusive`]), so an existing backup is never overwritten; +/// the move just lands on the next free name. Returns the path the blocking +/// directory was moved to. +/// +/// `base_suffix` is a parameter rather than computed internally so tests can +/// pass a fixed value; [`back_up_clobbered_path_now`] is the production entry +/// point that derives the timestamp. +fn back_up_clobbered_path(blocking_path: &Path, base_suffix: &str) -> anyhow::Result { + // Count up until a free name is found. This cannot spin forever: a + // directory holds finitely many entries, so some `-N` is always unused. + let mut n: u64 = 1; + loop { + // First attempt uses the bare suffix; later ones disambiguate with -N. + let suffix = if n == 1 { + base_suffix.to_string() + } else { + format!("{base_suffix}-{n}") + }; + let candidate = generate_backup_path(blocking_path, &suffix)?; + match renamore::rename_exclusive(blocking_path, &candidate) { + Ok(()) => return Ok(candidate), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => n += 1, + Err(err) => { + return Err(anyhow::Error::new(err).context(format!( + "Failed to move {} to {}", + format_path_for_display(blocking_path), + format_path_for_display(&candidate), + ))); + } + } + } +} + +/// Move `blocking_path` aside to a timestamped `.bak.` sibling. +/// +/// Wraps [`back_up_clobbered_path`] with the timestamp suffix computed at move +/// time, so the suffix reflects when the path is actually set aside. Returns the +/// path the blocking directory was moved to. +pub(crate) fn back_up_clobbered_path_now(blocking_path: &Path) -> anyhow::Result { + let timestamp_secs = worktrunk::utils::epoch_now() as i64; + let datetime = + chrono::DateTime::from_timestamp(timestamp_secs, 0).unwrap_or_else(chrono::Utc::now); + let base_suffix = datetime.format("%Y%m%d-%H%M%S").to_string(); + back_up_clobbered_path(blocking_path, &base_suffix) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_backup_path_with_extension() { + // Paths with extensions: file.txt -> file.txt.bak.TIMESTAMP + let path = PathBuf::from("/tmp/repo.feature"); + let backup = generate_backup_path(&path, "20250101-000000").unwrap(); + assert_eq!( + backup, + PathBuf::from("/tmp/repo.feature.bak.20250101-000000") + ); + + let path = PathBuf::from("/tmp/file.txt"); + let backup = generate_backup_path(&path, "20250101-000000").unwrap(); + assert_eq!(backup, PathBuf::from("/tmp/file.txt.bak.20250101-000000")); + } + + #[test] + fn test_generate_backup_path_without_extension() { + // Paths without extensions: foo -> foo.bak.TIMESTAMP + let path = PathBuf::from("/tmp/repo/feature"); + let backup = generate_backup_path(&path, "20250101-000000").unwrap(); + assert_eq!( + backup, + PathBuf::from("/tmp/repo/feature.bak.20250101-000000") + ); + + let path = PathBuf::from("/tmp/mydir"); + let backup = generate_backup_path(&path, "20250101-000000").unwrap(); + assert_eq!(backup, PathBuf::from("/tmp/mydir.bak.20250101-000000")); + } + + #[test] + fn test_generate_backup_path_unusual_paths() { + // Root path has no file name + let path = PathBuf::from("/"); + assert!(generate_backup_path(&path, "20250101-000000").is_err()); + + // Parent reference has no file name + let path = PathBuf::from(".."); + assert!(generate_backup_path(&path, "20250101-000000").is_err()); + } + + #[test] + fn test_back_up_clobbered_path_moves_to_fresh_suffix() { + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + std::fs::write(stale.join("file"), "content").unwrap(); + + let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap(); + + assert_eq!(used, temp.path().join("feature.bak.20250101-000000")); + assert!(!stale.exists(), "stale path should be moved away"); + assert_eq!( + std::fs::read_to_string(used.join("file")).unwrap(), + "content" + ); + } + + #[test] + fn test_back_up_clobbered_path_falls_back_when_suffix_taken() { + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + + // The preferred backup name and its first -N variant are both taken. + let taken = temp.path().join("feature.bak.20250101-000000"); + std::fs::create_dir(&taken).unwrap(); + std::fs::write(taken.join("keep"), "pre-existing").unwrap(); + std::fs::create_dir(temp.path().join("feature.bak.20250101-000000-2")).unwrap(); + + let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap(); + + // Lands on -3; neither pre-existing backup is overwritten. + assert_eq!(used, temp.path().join("feature.bak.20250101-000000-3")); + assert!(!stale.exists()); + assert_eq!( + std::fs::read_to_string(taken.join("keep")).unwrap(), + "pre-existing" + ); + } + + #[test] + fn test_back_up_clobbered_path_errors_when_source_missing() { + // A missing source fails the rename with a non-AlreadyExists error, + // which surfaces (with the "Failed to move" context) rather than being + // retried. + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("does-not-exist"); + let err = back_up_clobbered_path(&missing, "20250101-000000").unwrap_err(); + assert!( + err.to_string().contains("Failed to move"), + "expected wrapped error, got: {err}" + ); + } + + #[test] + fn test_back_up_clobbered_path_keeps_incrementing_past_many_collisions() { + // There is no attempt cap: the move keeps counting up until a free + // name is found, however many backups already exist. + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + + // Occupy the preferred name and the first 49 -N fallbacks (suffix "S"). + std::fs::create_dir(temp.path().join("feature.bak.S")).unwrap(); + for n in 2..=50 { + std::fs::create_dir(temp.path().join(format!("feature.bak.S-{n}"))).unwrap(); + } + + let used = back_up_clobbered_path(&stale, "S").unwrap(); + + assert_eq!(used, temp.path().join("feature.bak.S-51")); + assert!(!stale.exists(), "stale path should be moved away"); + } + + #[test] + fn test_back_up_clobbered_path_now_uses_timestamped_suffix() { + let temp = tempfile::tempdir().unwrap(); + let stale = temp.path().join("feature"); + std::fs::create_dir(&stale).unwrap(); + + let used = back_up_clobbered_path_now(&stale).unwrap(); + + let name = used.file_name().unwrap().to_string_lossy(); + assert!( + name.starts_with("feature.bak."), + "expected timestamped backup name, got: {name}" + ); + assert!(!stale.exists(), "stale path should be moved away"); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1ddd48c963..e3f87fa4fd 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,5 @@ mod alias; +pub(crate) mod backup; pub(crate) mod command_approval; pub(crate) mod command_executor; pub(crate) mod commit; diff --git a/src/commands/relocate.rs b/src/commands/relocate.rs index 9b7cb1c559..849a5bffe1 100644 --- a/src/commands/relocate.rs +++ b/src/commands/relocate.rs @@ -33,6 +33,7 @@ use worktrunk::styling::{ warning_message, }; +use super::backup; use super::commit::{CommitGenerator, StageMode}; use super::worktree::compute_worktree_path; @@ -375,39 +376,13 @@ impl<'a> RelocationExecutor<'a> { } if clobber { - // Backup the blocker - let timestamp_secs = worktrunk::utils::epoch_now() as i64; - let datetime = chrono::DateTime::from_timestamp(timestamp_secs, 0) - .unwrap_or_else(chrono::Utc::now); - let suffix = datetime.format("%Y%m%d-%H%M%S"); - let backup_path = expected_path.with_file_name(format!( - "{}.bak-{suffix}", - expected_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - )); - // Fail closed if the backup destination already exists: `fs::rename` - // can silently replace an existing file (and some empty - // directories) on Unix, which would destroy a prior backup. - if backup_path.exists() { - anyhow::bail!( - "Backup path already exists: {}", - format_path_for_display(&backup_path) - ); - } + // Atomically move the blocker aside to a timestamped backup. + // A backup name already taken is never overwritten — the move + // falls back to the next free `-N` name. let src = format_path_for_display(expected_path); + let backup_path = backup::back_up_clobbered_path_now(expected_path)?; let dest = format_path_for_display(&backup_path); - eprintln!( - "{}", - progress_message(cformat!("Backing up {src} → {dest}")) - ); - std::fs::rename(expected_path, &backup_path).with_context(|| { - format!( - "Failed to backup {}", - format_path_for_display(expected_path) - ) - })?; + eprintln!("{}", progress_message(cformat!("Backed up {src} → {dest}"))); } else { let blocked_path = format_path_for_display(expected_path); let msg = cformat!("Skipping {branch} (target blocked: {blocked_path})"); diff --git a/src/commands/step/relocate.rs b/src/commands/step/relocate.rs index 5681febc9d..853b887b47 100644 --- a/src/commands/step/relocate.rs +++ b/src/commands/step/relocate.rs @@ -18,7 +18,7 @@ use worktrunk::styling::println; /// |------|---------| /// | `--dry-run` | Show what would be moved without moving | /// | `--commit` | Auto-commit dirty worktrees with LLM-generated messages before relocating | -/// | `--clobber` | Move non-worktree paths out of the way (`.bak-`) | +/// | `--clobber` | Move non-worktree paths out of the way (`.bak.`) | /// | `[branches...]` | Specific branches to relocate (default: all mismatched) | pub fn step_relocate( branches: Vec, diff --git a/src/commands/worktree/resolve.rs b/src/commands/worktree/resolve.rs index 3b049972ef..44b057736a 100644 --- a/src/commands/worktree/resolve.rs +++ b/src/commands/worktree/resolve.rs @@ -202,74 +202,6 @@ pub fn worktree_display_name( } } -/// Generate a backup path for the given path with a timestamp suffix. -/// -/// For paths with extensions: `file.txt` → `file.txt.bak.TIMESTAMP` -/// For paths without extensions: `foo` → `foo.bak.TIMESTAMP` -/// -/// Returns an error for unusual paths without a file name (e.g., `/` or `..`). -pub(super) fn generate_backup_path( - path: &std::path::Path, - suffix: &str, -) -> anyhow::Result { - let file_name = path.file_name().ok_or_else(|| { - anyhow::anyhow!( - "Cannot generate backup path for {}", - format_path_for_display(path) - ) - })?; - - if path.extension().is_none() { - // Path has no extension (e.g., /repo/feature) - Ok(path.with_file_name(format!("{}.bak.{suffix}", file_name.to_string_lossy()))) - } else { - // Path has an extension (e.g., /repo.feature or /file.txt) - Ok(path.with_extension(format!( - "{}.bak.{suffix}", - path.extension() - .map(|e| e.to_string_lossy().to_string()) - .unwrap_or_default() - ))) - } -} - -/// Move a stale path aside so `wt switch --clobber` can take its place. -/// -/// Renames `worktree_path` to a `.bak.` sibling. If that name is -/// already taken — a same-second clobber, or a path that raced in after -/// planning — it counts up (`…-2`, `…-3`, …) until it finds a free name. -/// Every attempt is an atomic no-overwrite rename ([`renamore::rename_exclusive`]), -/// so an existing backup is never overwritten; the move just lands on the next -/// free name. Returns the path the stale directory was moved to. -pub(super) fn back_up_clobbered_path( - worktree_path: &Path, - base_suffix: &str, -) -> anyhow::Result { - // Count up until a free name is found. This cannot spin forever: a - // directory holds finitely many entries, so some `-N` is always unused. - let mut n: u64 = 1; - loop { - // First attempt uses the bare suffix; later ones disambiguate with -N. - let suffix = if n == 1 { - base_suffix.to_string() - } else { - format!("{base_suffix}-{n}") - }; - let candidate = generate_backup_path(worktree_path, &suffix)?; - match renamore::rename_exclusive(worktree_path, &candidate) { - Ok(()) => return Ok(candidate), - Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => n += 1, - Err(err) => { - return Err(anyhow::Error::new(err).context(format!( - "Failed to move {} to {}", - format_path_for_display(worktree_path), - format_path_for_display(&candidate), - ))); - } - } - } -} - /// Suggested worktree-path template for bare repos with hidden directory names. /// /// Places worktrees as siblings of the bare repo directory inside the parent, @@ -423,116 +355,6 @@ fn print_accepted_message(display_path: &str, config_path: &str) { mod tests { use super::*; - #[test] - fn test_generate_backup_path_with_extension() { - // Paths with extensions: file.txt -> file.txt.bak.TIMESTAMP - let path = PathBuf::from("/tmp/repo.feature"); - let backup = generate_backup_path(&path, "20250101-000000").unwrap(); - assert_eq!( - backup, - PathBuf::from("/tmp/repo.feature.bak.20250101-000000") - ); - - let path = PathBuf::from("/tmp/file.txt"); - let backup = generate_backup_path(&path, "20250101-000000").unwrap(); - assert_eq!(backup, PathBuf::from("/tmp/file.txt.bak.20250101-000000")); - } - - #[test] - fn test_generate_backup_path_without_extension() { - // Paths without extensions: foo -> foo.bak.TIMESTAMP - let path = PathBuf::from("/tmp/repo/feature"); - let backup = generate_backup_path(&path, "20250101-000000").unwrap(); - assert_eq!( - backup, - PathBuf::from("/tmp/repo/feature.bak.20250101-000000") - ); - - let path = PathBuf::from("/tmp/mydir"); - let backup = generate_backup_path(&path, "20250101-000000").unwrap(); - assert_eq!(backup, PathBuf::from("/tmp/mydir.bak.20250101-000000")); - } - - #[test] - fn test_generate_backup_path_unusual_paths() { - // Root path has no file name - let path = PathBuf::from("/"); - assert!(generate_backup_path(&path, "20250101-000000").is_err()); - - // Parent reference has no file name - let path = PathBuf::from(".."); - assert!(generate_backup_path(&path, "20250101-000000").is_err()); - } - - #[test] - fn test_back_up_clobbered_path_moves_to_fresh_suffix() { - let temp = tempfile::tempdir().unwrap(); - let stale = temp.path().join("feature"); - std::fs::create_dir(&stale).unwrap(); - std::fs::write(stale.join("file"), "content").unwrap(); - - let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap(); - - assert_eq!(used, temp.path().join("feature.bak.20250101-000000")); - assert!(!stale.exists(), "stale path should be moved away"); - assert_eq!( - std::fs::read_to_string(used.join("file")).unwrap(), - "content" - ); - } - - #[test] - fn test_back_up_clobbered_path_falls_back_when_suffix_taken() { - let temp = tempfile::tempdir().unwrap(); - let stale = temp.path().join("feature"); - std::fs::create_dir(&stale).unwrap(); - - // The preferred backup name and its first -N variant are both taken. - let taken = temp.path().join("feature.bak.20250101-000000"); - std::fs::create_dir(&taken).unwrap(); - std::fs::write(taken.join("keep"), "pre-existing").unwrap(); - std::fs::create_dir(temp.path().join("feature.bak.20250101-000000-2")).unwrap(); - - let used = back_up_clobbered_path(&stale, "20250101-000000").unwrap(); - - // Lands on -3; neither pre-existing backup is overwritten. - assert_eq!(used, temp.path().join("feature.bak.20250101-000000-3")); - assert!(!stale.exists()); - assert_eq!( - std::fs::read_to_string(taken.join("keep")).unwrap(), - "pre-existing" - ); - } - - #[test] - fn test_back_up_clobbered_path_errors_when_source_missing() { - // A missing source fails the rename with a non-AlreadyExists error, - // which surfaces rather than being retried. - let temp = tempfile::tempdir().unwrap(); - let missing = temp.path().join("does-not-exist"); - assert!(back_up_clobbered_path(&missing, "20250101-000000").is_err()); - } - - #[test] - fn test_back_up_clobbered_path_keeps_incrementing_past_many_collisions() { - // There is no attempt cap: the move keeps counting up until a free - // name is found, however many backups already exist. - let temp = tempfile::tempdir().unwrap(); - let stale = temp.path().join("feature"); - std::fs::create_dir(&stale).unwrap(); - - // Occupy the preferred name and the first 49 -N fallbacks (suffix "S"). - std::fs::create_dir(temp.path().join("feature.bak.S")).unwrap(); - for n in 2..=50 { - std::fs::create_dir(temp.path().join(format!("feature.bak.S-{n}"))).unwrap(); - } - - let used = back_up_clobbered_path(&stale, "S").unwrap(); - - assert_eq!(used, temp.path().join("feature.bak.S-51")); - assert!(!stale.exists(), "stale path should be moved away"); - } - #[test] fn test_template_references_repo_name_default() { // Default template uses {{ repo }} diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index b5e22a48a3..64b8db4d4d 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -31,11 +31,10 @@ use worktrunk::styling::{ warning_message, }; -use super::resolve::{ - back_up_clobbered_path, compute_worktree_path, offer_bare_repo_worktree_path_fix, path_mismatch, -}; +use super::resolve::{compute_worktree_path, offer_bare_repo_worktree_path_fix, path_mismatch}; use super::types::{CreationMethod, SwitchBranchInfo, SwitchPlan, SwitchResult}; use crate::cli::SwitchFormat; +use crate::commands::backup::back_up_clobbered_path_now; use crate::commands::command_approval::approve_hooks; use crate::commands::command_executor::FailureStrategy; use crate::commands::command_executor::{CommandContext, build_hook_context}; @@ -927,18 +926,11 @@ fn execute_switch( } => { // Handle --clobber backup if needed (shared for all creation methods) if needs_clobber_backup { - // Timestamped backup name, computed here at move time so the - // suffix reflects when the path is actually set aside. - let timestamp = worktrunk::utils::epoch_now() as i64; - let datetime = - chrono::DateTime::from_timestamp(timestamp, 0).unwrap_or_else(chrono::Utc::now); - let base_suffix = datetime.format("%Y%m%d-%H%M%S").to_string(); - - // Atomically move the stale path aside. A backup name that is - // already taken (a same-second clobber, or one that raced in - // after planning) is never overwritten — the move falls back - // to the next free `-N` name. - let backup_path = back_up_clobbered_path(&worktree_path, &base_suffix)?; + // Atomically move the stale path aside, to a timestamped backup + // name. A name already taken (a same-second clobber, or one + // that raced in after planning) is never overwritten — the move + // falls back to the next free `-N` name. + let backup_path = back_up_clobbered_path_now(&worktree_path)?; let path_display = worktrunk::path::format_path_for_display(&worktree_path); let backup_display = worktrunk::path::format_path_for_display(&backup_path); diff --git a/tests/integration_tests/step_relocate.rs b/tests/integration_tests/step_relocate.rs index ff84a6ee6d..b0e0f6ef58 100644 --- a/tests/integration_tests/step_relocate.rs +++ b/tests/integration_tests/step_relocate.rs @@ -308,16 +308,17 @@ fn test_relocate_clobber_backs_up(repo: TestRepo) { .filter_map(|e| e.ok()) .any(|e| { let name = e.file_name().to_string_lossy().to_string(); - name.starts_with("repo.feature.bak-") + name.starts_with("repo.feature.bak.") }); assert!(backup_exists, "Backup directory should exist"); } /// Regression: when the computed backup path already exists, relocate -/// --clobber must fail rather than silently overwriting it. (Matches the -/// `wt switch --clobber` contract — see test_switch_clobber_error_backup_exists.) +/// --clobber falls back to the next free `-N` name rather than overwriting it. +/// (Matches the `wt switch --clobber` contract — see +/// test_switch_clobber_falls_back_when_backup_taken.) #[rstest] -fn test_relocate_clobber_error_backup_exists(repo: TestRepo) { +fn test_relocate_clobber_falls_back_when_backup_taken(repo: TestRepo) { let parent = worktree_parent(&repo); // Create a worktree at a non-standard location. @@ -334,32 +335,40 @@ fn test_relocate_clobber_error_backup_exists(repo: TestRepo) { let expected_path = parent.join("repo.feature"); fs::write(&expected_path, "blocker contents").unwrap(); - // Pre-create the backup path that relocate would compute. TEST_EPOCH - // pins the timestamp suffix so this name is deterministic. + // Pre-create the backup path relocate would compute. TEST_EPOCH pins the + // timestamp suffix so this name is deterministic. // TEST_EPOCH=1735776000 -> 2025-01-02 00:00:00 UTC - let backup_path = parent.join("repo.feature.bak-20250102-000000"); - fs::write(&backup_path, "existing backup").unwrap(); + let taken = parent.join("repo.feature.bak.20250102-000000"); + fs::write(&taken, "existing backup").unwrap(); let output = make_snapshot_cmd(&repo, "step", &["relocate", "--clobber"], None) .output() .expect("relocate should run"); assert!( - !output.status.success(), - "relocate must fail when backup path already exists; stderr:\n{}", + output.status.success(), + "relocate must fall back to a free backup name; stderr:\n{}", String::from_utf8_lossy(&output.stderr) ); - // Both the blocker and the existing backup must be intact. - assert_eq!( - fs::read_to_string(&expected_path).unwrap(), - "blocker contents", - "blocker file must not be moved" + // The worktree was moved to the expected location. + assert!( + expected_path.is_dir(), + "Worktree should be at expected location: {}", + expected_path.display() ); + + // The pre-existing backup is untouched; the blocker moved to the -2 name. assert_eq!( - fs::read_to_string(&backup_path).unwrap(), + fs::read_to_string(&taken).unwrap(), "existing backup", "existing backup must not be overwritten" ); + let fallback = parent.join("repo.feature.bak.20250102-000000-2"); + assert_eq!( + fs::read_to_string(&fallback).unwrap(), + "blocker contents", + "blocker file must move to the -2 fallback name" + ); } /// Test that --clobber refuses to clobber an existing worktree diff --git a/tests/snapshots/integration__integration_tests__step_relocate__relocate_clobber_backs_up.snap b/tests/snapshots/integration__integration_tests__step_relocate__relocate_clobber_backs_up.snap index 280e5686d7..0f461db496 100644 --- a/tests/snapshots/integration__integration_tests__step_relocate__relocate_clobber_backs_up.snap +++ b/tests/snapshots/integration__integration_tests__step_relocate__relocate_clobber_backs_up.snap @@ -12,13 +12,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -31,13 +37,19 @@ info: WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -45,7 +57,7 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -◎ Backing up _REPO_.feature → _REPO_.feature.bak-20250102-000000 +◎ Backed up _REPO_.feature → _REPO_.feature.bak.20250102-000000 ✓ Relocated feature: _PARENT_/wrong-location → _REPO_.feature ✓ Relocated 1 worktree From 9a4ff002907f265d78cb0a625a5437f0172216cf Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 18:56:14 -0700 Subject: [PATCH 24/34] fix(hooks): resolve all hook config from the invoking worktree (#2873) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worktrunk resolved each hook's `.config/wt.toml` from a different worktree depending on the hook — `post-merge` from the merge target, `post-switch` from the destination, `pre-remove`/`post-remove` from each removed worktree, `wt step prune` from each prunable worktree, and `wt switch --create` from the base ref's *committed* config via `git show`. That last one is the bug behind #2856 and #2818: an uncommitted or branch-local `.config/wt.toml` silently failed to fire creation hooks, and `wt config show` (which reads the working tree) disagreed with what actually ran. This replaces all of it with one rule: **every hook resolves its commands from the `.config/wt.toml` of the worktree `wt` ran in** — the invoking worktree, read from its working tree, the same file `wt config show` displays. ## Behavior changes - `wt switch --create` / `pr:` / `mr:` creation hooks read the invoking worktree's config, so an uncommitted `.config/wt.toml` fires them; the base ref's or PR's committed config is no longer consulted. - `post-merge` runs the feature worktree's config, not the merge target's. - `post-switch` into an existing worktree uses the source, not the destination. - `wt remove ` and `wt step prune` use the invoking worktree's config, not each removed worktree's. In the common case — a committed, repo-wide `.config/wt.toml` — these are identical; they diverge only when a branch carries its own working-tree edits. ## For reviewers The module docstring in `src/commands/hooks.rs` is the spec — its per-hook config-source table collapsed to one rule. The change is concentrated in five approval gates that now call `repo.load_project_config()` once instead of `Repository::at()`: `merge::approve_merge_plan`, `main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks`, `picker::approved_removal_plan`, and `worktree::switch`. The `switch_hook_project_config` helper and the `base_ref_for_create` / `project_config_at_ref` `git show` machinery are deleted. The *anchor* — the worktree a hook runs in, the executor's plan-lookup key — is unchanged; only the config *source* unifies. The frozen `ApprovedHookPlan` still closes the approval-boundary TOCTOU. ## Testing Hook config-resolution tests across `switch`, `merge`, `remove`, and `step_prune` were rewritten to assert the new rule, each also checking that the non-invoking worktree's config is ignored. `test_post_merge_hook_from_rebased_in_config_does_not_run` is the TOCTOU regression: a `post-merge` that enters the invoking worktree's config only via the rebase, after the gate froze the plan, must not run. Ref #2856, #2818. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/content/hook.md | 2 +- skills/worktrunk/reference/hook.md | 2 +- src/cli/mod.rs | 2 +- src/commands/hook_plan.rs | 31 ++-- src/commands/hooks.rs | 48 +++-- src/commands/merge.rs | 39 ++-- src/commands/picker/mod.rs | 49 ++--- src/commands/repository_ext.rs | 2 +- src/commands/step/prune.rs | 13 +- src/commands/worktree/finish.rs | 2 +- src/commands/worktree/hooks.rs | 2 +- src/commands/worktree/switch.rs | 254 +++----------------------- src/git/repository/config.rs | 100 ---------- src/main.rs | 27 +-- src/output/handlers.rs | 9 +- tests/integration_tests/merge.rs | 121 +++++------- tests/integration_tests/remove.rs | 141 ++++++-------- tests/integration_tests/step_prune.rs | 47 +++-- tests/integration_tests/switch.rs | 186 +++++++++++-------- 19 files changed, 384 insertions(+), 693 deletions(-) diff --git a/docs/content/hook.md b/docs/content/hook.md index 0f23aef7ce..0305fdc253 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -69,7 +69,7 @@ Manage approvals with `wt config approvals add` and `wt config approvals clear`. # Configuration -Hooks can be defined in project config (`.config/wt.toml`) or user config (`~/.config/worktrunk/config.toml`). Both use the same format. +Hooks can be defined in project config (`.config/wt.toml`) or user config (`~/.config/worktrunk/config.toml`). Both use the same format. The project config is read from the worktree the command ran in. ## Hook forms diff --git a/skills/worktrunk/reference/hook.md b/skills/worktrunk/reference/hook.md index cd73c98db7..dbde00f2c5 100644 --- a/skills/worktrunk/reference/hook.md +++ b/skills/worktrunk/reference/hook.md @@ -60,7 +60,7 @@ Manage approvals with `wt config approvals add` and `wt config approvals clear`. # Configuration -Hooks can be defined in project config (`.config/wt.toml`) or user config (`~/.config/worktrunk/config.toml`). Both use the same format. +Hooks can be defined in project config (`.config/wt.toml`) or user config (`~/.config/worktrunk/config.toml`). Both use the same format. The project config is read from the worktree the command ran in. ## Hook forms diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3b41d7c9ec..48418a4f09 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1281,7 +1281,7 @@ Manage approvals with `wt config approvals add` and `wt config approvals clear`. # Configuration -Hooks can be defined in project config (`.config/wt.toml`) or user config (`~/.config/worktrunk/config.toml`). Both use the same format. +Hooks can be defined in project config (`.config/wt.toml`) or user config (`~/.config/worktrunk/config.toml`). Both use the same format. The project config is read from the worktree the command ran in. ## Hook forms diff --git a/src/commands/hook_plan.rs b/src/commands/hook_plan.rs index 7f952b13e5..7f43e69d88 100644 --- a/src/commands/hook_plan.rs +++ b/src/commands/hook_plan.rs @@ -29,9 +29,9 @@ //! so it cannot re-select. //! //! The frozen unit is the *selected, source-tagged [`CommandConfig`] list* per -//! `(HookType, anchor)` — the anchor being the worktree the hook is *about* -//! (its config source). Rendering stays deferred (post-`*` hooks legitimately -//! need post-operation context like the merge commit), but it consumes +//! `(HookType, anchor)` — the anchor being the worktree the hook runs in. +//! Rendering stays deferred (post-`*` hooks legitimately need post-operation +//! context like the merge commit), but it consumes //! `&[(HookSource, CommandConfig)]`, so it cannot change the set. The //! authorization-relevant artifact is the template set, which is exactly what //! [`Approvals`] stores and the prompt shows. @@ -68,10 +68,10 @@ use super::project_config::{ApprovableCommand, Phase}; /// handle to re-resolve from. type Selection = Vec<(HookSource, CommandConfig)>; -/// One frozen entry: the hook type, the anchor worktree (the `.config/wt.toml` -/// the hook reads — stored as the caller provides it; the gate and executor -/// both derive it from the same value, so equality holds without -/// canonicalization), and that pair's source-tagged selection. +/// One frozen entry: the hook type, the anchor worktree (the worktree the hook +/// runs in — stored as the caller provides it; the gate and executor both +/// derive it from the same value, so equality holds without canonicalization), +/// and that pair's source-tagged selection. struct PlanEntry { hook_type: HookType, anchor: PathBuf, @@ -83,7 +83,8 @@ pub struct HookPlan { entries: Vec, } -/// Accumulates per-anchor selections from each worktree's resolved config. +/// Accumulates per-anchor selections from the invoking worktree's resolved +/// config. /// /// `add` is the only place `load_project_config()`'s result feeds command /// selection for the covered hook types — `pub(crate)` and called only from @@ -100,7 +101,7 @@ impl HookPlanBuilder { } /// Select `hook_types` anchored at `anchor`, from the already-resolved - /// `project_config` (the gate snapshots / `git show`s it) plus user config. + /// `project_config` (the gate snapshots it) plus user config. /// /// `project_id` scopes the user-config hook lookup. Source identity is /// preserved so source-scoped behavior survives into execution. @@ -322,10 +323,11 @@ fn render_planned( /// signature carries no config — only the plan, the render context, and the /// failure strategy. /// -/// `anchor` is the worktree the hook's config was selected from at the gate -/// (usually `ctx.worktree_path`, but for `post-remove` the removed worktree -/// while `ctx` runs in the destination). It is *not* a config handle — just -/// the lookup key into the frozen plan. +/// `anchor` is the worktree the hook runs in (usually `ctx.worktree_path`, but +/// for `post-remove` the removed worktree while `ctx` runs in the destination). +/// It is *not* a config handle — just the lookup key into the frozen plan. The +/// gate always selects from the invoking worktree's config, which need not be +/// the anchor. pub fn execute_planned_hook( plan: &ApprovedHookPlan, anchor: &Path, @@ -355,7 +357,8 @@ pub fn execute_planned_hook( /// rendered from the frozen selection and added via the announcer's existing /// config-free `extend`, with no `load_project_config()` at execution. /// -/// `anchor` is the gate's config-source worktree (see [`execute_planned_hook`]). +/// `anchor` is the worktree the hook runs in — the lookup key into the frozen +/// plan (see [`execute_planned_hook`]). pub fn register_planned( announcer: &mut HookAnnouncer<'_>, plan: &ApprovedHookPlan, diff --git a/src/commands/hooks.rs b/src/commands/hooks.rs index 0702de6873..b539c5e1ff 100644 --- a/src/commands/hooks.rs +++ b/src/commands/hooks.rs @@ -5,26 +5,37 @@ //! //! # Which `.config/wt.toml` a hook reads //! -//! Every hook is anchored to one worktree: the worktree it is *about*. It runs -//! the commands selected from that worktree's `.config/wt.toml`. Two execution -//! models split on whether a state mutation separates the approval gate from -//! execution. +//! Every hook resolves its commands from the **invoking** worktree's +//! `.config/wt.toml` — the worktree `wt` ran in, read from its working tree. +//! That holds regardless of which worktree the hook is *about*: `post-merge` +//! runs in the merge target and `post-start` in the newly created worktree, +//! but both select their commands from the invoking worktree's config — the +//! same file `wt config show` reads. +//! +//! Two execution models split on whether a state mutation separates the +//! approval gate from execution. //! //! **Plan-backed (the TOCTOU-covered set):** `pre-merge`, `post-merge`, //! `pre-remove`, `post-remove`, `post-switch`, `pre-start`, `post-start`. A //! merge, rebase, removal, or `git worktree add` runs between the gate and -//! these hooks, so a second config read could select a command the user never -//! approved. Each command gate selects the commands once and freezes them into -//! a [`super::hook_plan::ApprovedHookPlan`]; the executor renders and runs only +//! these hooks; a rebase can even rewrite the invoking worktree's own +//! `.config/wt.toml`, so a second config read could select a command the user +//! never approved. Each command gate calls `load_project_config()` on the +//! invoking worktree once, selects the commands, and freezes them into a +//! [`super::hook_plan::ApprovedHookPlan`]; the executor renders and runs only //! that frozen value via [`super::hook_plan::execute_planned_hook`] / //! [`super::hook_plan::register_planned`], holding no `ProjectConfig` to //! re-derive from. See the [`super::hook_plan`] module spec. //! -//! | Plan-backed hook | Anchor worktree | Gate builds the plan in | +//! | Plan-backed hook | Runs in (the anchor) | Gate | //! |---|---|---| //! | `pre-merge`, `pre-remove`, `post-remove` | the feature/removed worktree | `merge::approve_merge_plan`, `main.rs`'s `approve_remove`, `step::prune::approve_prune_hooks` | //! | `post-merge`, `post-switch` (after a removal) | the merge/removal destination | the same gates | -//! | `pre-start`, `post-start`, `post-switch` (on switch) | the new/destination worktree (the `--create` base ref's committed config, read via `git show` by `switch_hook_project_config`) | `worktree::switch::approve_switch_hooks` | +//! | `pre-start`, `post-start`, `post-switch` (on switch) | the new/destination worktree | `worktree::switch::approve_switch_hooks` | +//! +//! "Runs in" is the *anchor* — the executor's plan lookup key and render root, +//! not a config source. A `pre-start`'s new worktree need not exist when the +//! gate runs; the config came from the invoking worktree regardless. //! //! **Invocation-resolved (no gate→exec mutation):** `pre-commit`, //! `post-commit`, `pre-switch`, `wt hook `, aliases. They resolve config @@ -40,19 +51,16 @@ //! (empty cache) breaks (2) and silently reintroduces the TOCTOU even if (1) //! still holds — there is no compile-time guard here, unlike the plan-backed //! set. (Aliases get the property structurally instead: the body is frozen -//! into `AliasEntry` before the gate, like `ApprovedHookPlan`.) Anchors: -//! `pre-commit`/`post-commit` the worktree being committed (cwd, or ``'s -//! worktree for `wt step commit --branch `); `pre-switch`, `pre-merge`'s -//! manual `wt hook`, and aliases the worktree `wt` was invoked in. +//! into `AliasEntry` before the gate, like `ApprovedHookPlan`.) +//! +//! `ctx.repo` is the invoking worktree — except `wt step commit --branch ` +//! and `wt -C ` re-root the whole command (the commit, its hooks, and +//! `ctx.repo` are all ``), so "the invoking worktree" follows them. //! -//! Each worktree's `.config/wt.toml` stands alone: no fallback to the primary -//! worktree's config when the anchor has none, and a present-but-malformed -//! config aborts the operation rather than silently using a different one. -//! `WORKTRUNK_PROJECT_CONFIG_PATH` overrides the path regardless of the root +//! A present-but-malformed config aborts the operation rather than silently +//! running something else. `WORKTRUNK_PROJECT_CONFIG_PATH` overrides the path //! (test isolation); user config (`~/.config/worktrunk/config.toml`) is global -//! and unaffected. `wt step prune` selects over every linked worktree it might -//! prune (the integrated set isn't known until the checks run), a superset of -//! what executes. +//! and unaffected. use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/src/commands/merge.rs b/src/commands/merge.rs index f4615bfa2e..33ea65def9 100644 --- a/src/commands/merge.rs +++ b/src/commands/merge.rs @@ -77,10 +77,13 @@ pub struct MergeOptions<'a> { /// Build the frozen [`ApprovedHookPlan`] for the merge's covered hooks, gating /// every project command once. /// -/// Each hook is anchored to the worktree it reads `.config/wt.toml` from: +/// Every hook selects its commands from the invoking worktree's +/// `.config/wt.toml` — `repo`'s cwd, the feature worktree `wt merge` ran in. +/// The *anchor* — the executor's plan lookup key — is the worktree each hook +/// runs in: /// /// - `pre-commit` / `post-commit` / `pre-merge` / `pre-remove` / `post-remove` -/// → the feature worktree (`repo`'s cwd). +/// → the feature worktree. /// - `post-merge` / `post-switch` → the merge destination. /// /// `pre-commit`/`post-commit` execute via the unchanged commit/squash path @@ -125,28 +128,27 @@ fn approve_merge_plan( feature_hooks.push(HookType::PostRemove); } - // Read each `.config/wt.toml` only when a hook actually needs it: with - // `--no-hooks` (`verify` false) nothing is selected, so a malformed or - // inaccessible destination/feature config must not fail the merge. + // Every merge hook resolves against the invoking worktree's + // `.config/wt.toml` — the feature worktree `wt merge` ran in. Read once, + // inside the `verify` gate: `--no-hooks` selects no hook, so the gate needs + // no config. let mut builder = HookPlanBuilder::new(); - if !feature_hooks.is_empty() { - let feature_cfg = repo.load_project_config()?; + if verify { + let project_config = repo.load_project_config()?; builder.add( feature_root, &feature_hooks, - feature_cfg.as_ref(), + project_config.as_ref(), config, pid, ); - } - if verify { - // `post-merge` reads the destination; `post-switch` (feature worktree - // removed → user lands here) reads the same destination config. - let dest_cfg = Repository::at(destination_path)?.load_project_config()?; + // `post-merge` runs in the destination, and `post-switch` lands the + // user there (the feature worktree is removed) — both still selected + // from the invoking worktree's config. builder.add( destination_path, &[HookType::PostMerge], - dest_cfg.as_ref(), + project_config.as_ref(), config, pid, ); @@ -154,7 +156,7 @@ fn approve_merge_plan( builder.add( destination_path, &[HookType::PostSwitch], - dest_cfg.as_ref(), + project_config.as_ref(), config, pid, ); @@ -224,9 +226,10 @@ pub fn handle_merge(opts: MergeOptions<'_>) -> anyhow::Result<()> { let target_branch = repo.require_target_branch(target)?; // Worktree for target is optional: if present we use it for safety checks and as destination. let target_worktree_path = repo.worktree_for_branch(&target_branch)?; - // Where `post-merge` / `post-remove` / `post-switch` run (and resolve their - // `.config/wt.toml`): the target branch's worktree if it exists, else the - // primary worktree. Mirrors `finish_after_merge`'s destination resolution. + // Where `post-merge` / `post-remove` / `post-switch` run: the target + // branch's worktree if it exists, else the primary worktree. Mirrors + // `finish_after_merge`'s destination resolution. (Config is resolved from + // the invoking worktree, not here — see `approve_merge_plan`.) let destination_path = match &target_worktree_path { Some(path) => path.clone(), None => repo.home_path()?, diff --git a/src/commands/picker/mod.rs b/src/commands/picker/mod.rs index 932b83732c..0042d586e7 100644 --- a/src/commands/picker/mod.rs +++ b/src/commands/picker/mod.rs @@ -229,9 +229,11 @@ impl PickerCollector { /// and the TUI stays responsive. A removal failure is logged; the item stays /// gone from the picker — a tradeoff until we can show in-progress state. /// - /// `repo` is only used for `BranchOnly` deletion; `RemovedWorktree` removal - /// is rooted at `main_path` (which may differ from the picker's startup repo - /// in bare-repo setups). + /// `repo` is the worktree the picker is operating from — the config source + /// for the removal hooks (see [`approved_removal_plan`]) and the target of + /// a `BranchOnly` deletion. `RemovedWorktree` removal itself is rooted at + /// `main_path` (which may differ from the picker's startup repo in bare-repo + /// setups). fn do_removal( repo: &Repository, result: &RemoveResult, @@ -244,7 +246,7 @@ impl PickerCollector { .. } => { let main_repo = Repository::at(main_path)?; - let plan = approved_removal_plan(&main_repo, main_path, worktree_path, approvals)?; + let plan = approved_removal_plan(repo, main_path, worktree_path, approvals)?; let mut announcer = HookAnnouncer::new(&main_repo, main_repo.user_config(), false); handle_remove_output( result, @@ -366,16 +368,17 @@ impl CommandCollector for PickerCollector { /// Whether every `pre-remove` / `post-remove` / `post-switch` command this /// removal would run is already approved — a read-only check, no prompt. /// -/// `main_path` is the post-removal destination (where `post-switch` reads its -/// config, same as the executor); `worktree_path` is the worktree being -/// removed (where `pre-remove` / `post-remove` read theirs). `wt remove` / -/// `wt merge` collect the same command set and prompt for it at the gate; the -/// picker can't prompt mid-render, so it runs the removal's hooks only when -/// they're already approved (e.g. from a prior `wt remove` / `wt merge`) and -/// skips them otherwise — unapproved project commands must never run. See -/// CLAUDE.md → "Project Commands Run Only After Approval". +/// `repo` is the worktree the picker is operating from; its `.config/wt.toml` +/// is what every removal hook resolves against, matching `wt remove` / +/// `wt merge`. `main_path` is the post-removal destination (the `post-switch` +/// anchor); `worktree_path` is the worktree being removed (the `pre-remove` / +/// `post-remove` anchor). The picker can't prompt mid-render, so it runs the +/// removal's hooks only when they're already approved (e.g. from a prior +/// `wt remove` / `wt merge`) and skips them otherwise — unapproved project +/// commands must never run. See CLAUDE.md → "Project Commands Run Only After +/// Approval". fn approved_removal_plan( - main_repo: &Repository, + repo: &Repository, main_path: &Path, worktree_path: &Path, approvals: &Approvals, @@ -383,24 +386,23 @@ fn approved_removal_plan( // Non-fatal: an unresolvable project identifier just means no project // pipeline can be matched against approvals — `approve_readonly` then // drops them (fail-closed), rather than aborting the picker removal. - let project_id = main_repo.project_identifier().ok(); + let project_id = repo.project_identifier().ok(); let pid = project_id.as_deref(); - let user = main_repo.user_config(); - let removed_cfg = Repository::at(worktree_path)?.load_project_config()?; - let dest_cfg = Repository::at(main_path)?.load_project_config()?; + let user = repo.user_config(); + let project_config = repo.load_project_config()?; let mut builder = HookPlanBuilder::new(); builder.add( worktree_path, &[HookType::PreRemove, HookType::PostRemove], - removed_cfg.as_ref(), + project_config.as_ref(), user, pid, ); builder.add( main_path, &[HookType::PostSwitch], - dest_cfg.as_ref(), + project_config.as_ref(), user, pid, ); @@ -1240,13 +1242,14 @@ pub mod tests { ]) .unwrap(); - // A `pre-remove` hook in the removed worktree's `.config/wt.toml` that - // would write a marker (outside the worktree) if it ever ran. + // A `pre-remove` hook in the invoking worktree's `.config/wt.toml` — + // the config the picker removal resolves against — that would write a + // marker if it ever ran. let marker_dir = tempfile::tempdir().unwrap(); let marker = marker_dir.path().join("pre-remove-ran"); - fs::create_dir_all(wt_path.join(".config")).unwrap(); + fs::create_dir_all(test.path().join(".config")).unwrap(); fs::write( - wt_path.join(".config/wt.toml"), + test.path().join(".config/wt.toml"), format!("pre-remove = {:?}\n", format!("touch {}", marker.display())), ) .unwrap(); diff --git a/src/commands/repository_ext.rs b/src/commands/repository_ext.rs index 977ca0d14f..f051fcf5ba 100644 --- a/src/commands/repository_ext.rs +++ b/src/commands/repository_ext.rs @@ -321,7 +321,7 @@ impl RepositoryCliExt for Repository { // No `.config/wt.toml` snapshot: `pre-remove` / `post-remove` were // selected and frozen into the `ApprovedHookPlan` at the gate // (`main.rs`'s `approve_remove`), anchored at this worktree's path, so - // the executor never re-reads the removed worktree's config. + // the executor needs no config — it runs only the frozen plan. Ok(RemoveResult::RemovedWorktree { main_path, worktree_path, diff --git a/src/commands/step/prune.rs b/src/commands/step/prune.rs index 4f09df3c10..737f13f208 100644 --- a/src/commands/step/prune.rs +++ b/src/commands/step/prune.rs @@ -420,8 +420,8 @@ fn render_dry_run( /// selection is a superset of what executes (extra anchors are simply never /// looked up). `post-switch` is anchored at the primary worktree: a prune /// candidate is never the primary, so each removal's -/// `RemoveResult::destination_path()` is `home_path()`. No fallback between -/// worktrees — each `.config/wt.toml` stands alone. +/// `RemoveResult::destination_path()` is `home_path()`. Every hook is selected +/// from the invoking worktree's `.config/wt.toml`, whatever its anchor. /// /// A declined prompt yields an empty plan — every executor runs no hooks. fn approve_prune_hooks( @@ -437,6 +437,9 @@ fn approve_prune_hooks( // and `approve` never needs it). let project_id = repo.project_identifier().ok(); let pid = project_id.as_deref(); + // Every prune hook is selected from the invoking worktree's + // `.config/wt.toml` — the worktree `wt step prune` ran in. + let project_config = repo.load_project_config()?; let removed_worktree_paths: Vec<&Path> = check_items .iter() @@ -448,20 +451,18 @@ fn approve_prune_hooks( let mut builder = HookPlanBuilder::new(); for &wt_path in &removed_worktree_paths { - let cfg = Repository::at(wt_path)?.load_project_config()?; builder.add( wt_path, &[HookType::PreRemove, HookType::PostRemove], - cfg.as_ref(), + project_config.as_ref(), config, pid, ); } - let primary_cfg = Repository::at(&primary_path)?.load_project_config()?; builder.add( &primary_path, &[HookType::PostSwitch], - primary_cfg.as_ref(), + project_config.as_ref(), config, pid, ); diff --git a/src/commands/worktree/finish.rs b/src/commands/worktree/finish.rs index 6c9ac0aa2f..236e98ffe3 100644 --- a/src/commands/worktree/finish.rs +++ b/src/commands/worktree/finish.rs @@ -147,7 +147,7 @@ pub fn finish_after_merge( // No config snapshot: `pre-remove` / `post-remove` were selected and // frozen into `plan` at the gate (anchored at `feature_path`), so the - // executor never re-reads the removed worktree's `.config/wt.toml`. + // executor needs no config — it runs only the frozen `plan`. let remove_result = RemoveResult::RemovedWorktree { main_path: destination_path.clone(), worktree_path: worktree_root, diff --git a/src/commands/worktree/hooks.rs b/src/commands/worktree/hooks.rs index 9b9ac0954d..4b11b57893 100644 --- a/src/commands/worktree/hooks.rs +++ b/src/commands/worktree/hooks.rs @@ -18,7 +18,7 @@ impl<'a> CommandContext<'a> { /// /// Runs user hooks first, then project hooks. `anchor` is the new /// worktree's path — the gate selected `pre-start` under it from the - /// base-ref config; the executor never re-reads the (now on-disk) config. + /// invoking worktree's config; the executor never re-reads any config. /// Shows path in hook announcements when shell integration isn't active /// (the user's shell won't cd to the new worktree). pub fn execute_pre_create_commands( diff --git a/src/commands/worktree/switch.rs b/src/commands/worktree/switch.rs index 64b8db4d4d..c3befc972f 100644 --- a/src/commands/worktree/switch.rs +++ b/src/commands/worktree/switch.rs @@ -14,8 +14,7 @@ use dunce::canonicalize; use serde::Serialize; use worktrunk::HookType; use worktrunk::config::{ - ProjectConfig, UserConfig, ValidationScope, expand_template, template_references_var, - validate_template, + UserConfig, ValidationScope, expand_template, template_references_var, validate_template, }; use worktrunk::git::remote_ref::{ self, AzureDevOpsProvider, GitHubProvider, GitLabProvider, GiteaProvider, RemoteRefInfo, @@ -1139,11 +1138,12 @@ fn execute_switch( CreationMethod::Regular { .. } => (None, None), }; - // Execute pre-start commands. The hook resolves its `.config/wt.toml` - // from the new worktree (created just above) — see - // `hook_repo_for_worktree`. + // Execute pre-start commands. `hook_repo` roots the render context + // in the new worktree (created just above); the commands come from + // the frozen `hook_plan`, selected at the gate from the invoking + // worktree's config. if run_hooks { - let hook_repo = hook_repo_for_worktree(&worktree_path)?; + let hook_repo = Repository::at(&worktree_path)?; let ctx = CommandContext::new(&hook_repo, config, Some(&branch), &worktree_path, force); let mut vars = TemplateVars::new() @@ -1365,109 +1365,12 @@ fn switch_post_hook_types(is_create: bool) -> &'static [HookType] { } } -/// The git ref a `wt switch --create` (or a branchless `wt switch ` -/// that has to create a worktree) checks out into the new worktree — covers -/// [`CreationMethod::Regular`] only; [`CreationMethod::ForkRef`] needs a -/// `git fetch` first and is handled by [`switch_hook_project_config`]. -/// -/// Used to read that ref's committed `.config/wt.toml` before the worktree -/// exists. Mirrors how [`execute_switch`] picks the `git worktree add` -/// argument: an explicit `--create` base if one was resolved, else the -/// existing local branch, else its single remote's tracking ref, else `HEAD` -/// (the current worktree's tip — git's fallback for `git worktree add -b -/// ` with no base, and the only thing left for a stale default branch). -fn base_ref_for_create( - repo: &Repository, - create_branch: bool, - base_branch: Option<&str>, - branch: &str, -) -> String { - if create_branch { - return base_branch.unwrap_or("HEAD").to_string(); - } - if repo.branch(branch).exists_locally().unwrap_or(false) { - return branch.to_string(); - } - let remotes = repo.branch(branch).remotes().unwrap_or_default(); - if remotes.len() == 1 { - return format!("{}/{}", remotes[0], branch); - } - // Multiple remotes: replicate `git worktree add `'s DWIM — when - // `checkout.defaultRemote` names one of these remotes, that's the ref the - // new worktree will check out, so the hook preview must match. Without - // this, a `pre-start` on `/` could run unapproved - // because the preview defaulted to `HEAD`. - if remotes.len() > 1 - && let Ok(Some(default)) = repo.config_value("checkout.defaultRemote") - && remotes.iter().any(|r| r == &default) - { - return format!("{default}/{branch}"); - } - "HEAD".to_string() -} - -/// The `.config/wt.toml` that `wt switch`'s post-switch hooks (`pre-start` / -/// `post-start` / `post-switch`) will resolve against, viewed from *before* -/// the worktree is created — so the approval prompt and the pre-flight -/// template validation see the exact config `execute_switch` / -/// [`spawn_switch_background_hooks`] (via [`hook_repo_for_worktree`]) read at -/// run time. -/// -/// For an existing destination the worktree is on disk, so its config is read -/// directly. For `--create` the new worktree is a checkout of the resolved -/// base ref (or, for `pr:`/`mr:` fork refs, the fetched PR/MR head), so the -/// committed `.config/wt.toml` at that ref is read via `git show`. In either -/// case `Ok(None)` means the destination worktree has no `.config/wt.toml` -/// at all — no project hooks run; the primary worktree's config is not -/// consulted. A present-but-malformed config surfaces as `Err` so the user -/// fixes it rather than silently running a different one. -/// -/// May `git fetch` a `pr:`/`mr:` fork head ref — `wt switch pr:`/`mr:` is the -/// user explicitly invoking network work, so fetching at the approval gate is -/// in keeping; `execute_switch` re-fetches (idempotent). -fn switch_hook_project_config( - repo: &Repository, - plan: &SwitchPlan, -) -> anyhow::Result> { - match plan { - SwitchPlan::Existing { path, .. } => Repository::at(path)?.load_project_config(), - SwitchPlan::Create { method, branch, .. } => { - let base_ref = match method { - CreationMethod::ForkRef { - remote, ref_path, .. - } => { - repo.run_command(&["fetch", "--", remote, ref_path]) - .with_context(|| format!("Failed to fetch {ref_path} from {remote}"))?; - "FETCH_HEAD".to_string() - } - CreationMethod::Regular { - create_branch, - base_branch, - .. - } => base_ref_for_create(repo, *create_branch, base_branch.as_deref(), branch), - }; - repo.project_config_at_ref(&base_ref) - } - } -} - -/// The `Repository` whose `.config/wt.toml` a post-switch hook running in -/// `worktree_path` should read: the new/destination worktree itself. No -/// fallback — when the worktree has no `.config/wt.toml`, no project hooks -/// run, matching what [`switch_hook_project_config`] surfaced to the approval -/// prompt. -fn hook_repo_for_worktree(worktree_path: &Path) -> anyhow::Result { - Repository::at(worktree_path) -} - /// Approve switch hooks upfront and show "Commands declined" if needed. /// -/// `project_config` is the `.config/wt.toml` the post-switch hooks will run -/// against — the new/destination worktree's (or, for `--create`, the base -/// ref's), resolved by [`switch_hook_project_config`]. Passing it in (rather -/// than letting the approval read `ctx.repo`'s config) is what keeps the -/// prompt listing the exact commands `execute_switch` will run, including for -/// `wt switch pr:N` against a fork's hooks. +/// Switch hooks resolve their commands from the invoking worktree's +/// `.config/wt.toml` — the worktree `wt switch` ran in. Selecting them here, +/// at the gate, freezes the exact commands `execute_switch` will run into the +/// [`ApprovedHookPlan`]. /// /// Returns `(hooks_approved, plan)`. `hooks_approved` is `false` and the plan /// empty when `!verify` or the user declined; the covered switch hooks @@ -1478,7 +1381,6 @@ fn approve_switch_hooks( plan: &SwitchPlan, yes: bool, verify: bool, - project_config: Option<&ProjectConfig>, ) -> anyhow::Result<(bool, ApprovedHookPlan)> { if !verify { return Ok((false, ApprovedHookPlan::empty())); @@ -1489,11 +1391,12 @@ fn approve_switch_hooks( // and `approve` never needs it). let project_id = repo.project_identifier().ok(); let pid = project_id.as_deref(); + let project_config = repo.load_project_config()?; let mut builder = HookPlanBuilder::new(); builder.add( plan.worktree_path(), switch_post_hook_types(plan.is_create()), - project_config, + project_config.as_ref(), config, pid, ); @@ -1523,10 +1426,9 @@ fn spawn_switch_background_hooks( ) -> anyhow::Result<()> { // Background hooks run in the new/destination worktree. `hook_repo` roots // the *render* context there; the command set is the frozen `hook_plan` - // (anchored at the destination at the gate, from the base-ref config for - // `--create`), so the worktree's on-disk `.config/wt.toml` is never - // re-read. - let hook_repo = hook_repo_for_worktree(result.path())?; + // (selected at the gate from the invoking worktree's config), so no + // `.config/wt.toml` is re-read. + let hook_repo = Repository::at(result.path())?; let ctx = CommandContext::new(&hook_repo, config, branch, result.path(), yes); let mut announcer = HookAnnouncer::new(&hook_repo, config, false); @@ -1696,48 +1598,17 @@ impl SwitchPipeline<'_> { } })?; - // Resolve the `.config/wt.toml` the post-switch hooks will run against — - // the new/destination worktree's (for `--create`, the base ref's, read - // via `git show` since the worktree doesn't exist yet). Computed once so - // the approval prompt and the template pre-flight below agree with what - // `execute_switch` / `spawn_switch_background_hooks` resolve at run time. - // (May `git fetch` a `pr:`/`mr:` fork head — explicit network work.) - // - // Skip entirely when `--no-hooks` / `--no-verify` is in effect: with - // hooks disabled, the result is never used, and resolving it could fetch - // from a PR ref or read a primary `.config/wt.toml` the user asked us - // not to touch. - let hook_project_config = if verify { - switch_hook_project_config(repo, &plan)? - } else { - None - }; - // "Approve at the Gate": collect and approve hooks upfront. Approval // happens once at the command entry point. If the user declines, skip - // hooks but continue with the worktree operation. - let (hooks_approved, hook_plan) = approve_switch_hooks( - repo, - config, - &plan, - yes, - verify, - hook_project_config.as_ref(), - )?; + // hooks but continue with the worktree operation. Switch hooks resolve + // their config from the invoking worktree — see `approve_switch_hooks`. + let (hooks_approved, hook_plan) = approve_switch_hooks(repo, config, &plan, yes, verify)?; // Pre-flight: validate all templates before mutation (worktree // creation). Catches syntax errors and undefined variables early so a // broken template doesn't leave behind a half-created worktree that // blocks re-running. - validate_switch_templates( - repo, - config, - &plan, - execute, - execute_args, - hooks_approved, - hook_project_config.as_ref(), - )?; + validate_switch_templates(repo, config, &plan, execute, execute_args, hooks_approved)?; // Execute the validated plan. let (result, branch_info) = @@ -2050,10 +1921,9 @@ fn warn_if_execute_form_deprecated(cmd: &str, execute_args: &[String]) { /// completed successfully — template failure is a missed notification, not a /// blocking state. The user can fix the template and run `wt hook` manually. /// -/// `project_config` is the `.config/wt.toml` the post-switch hooks will run -/// against — the new/destination worktree's (for `--create`, the base ref's), -/// resolved by [`switch_hook_project_config`] — so the templates checked here -/// are the ones that will actually be expanded, not the invoking worktree's. +/// Hook templates checked here come from the invoking worktree's +/// `.config/wt.toml` — the same config the switch hooks run against — so the +/// templates validated are the ones that will actually be expanded. /// /// Validates: /// - `--execute` command template (if present) @@ -2066,7 +1936,6 @@ fn validate_switch_templates( execute: Option<&str>, execute_args: &[String], hooks_approved: bool, - project_config: Option<&ProjectConfig>, ) -> anyhow::Result<()> { // Validate --execute template and trailing args if let Some(cmd) = execute { @@ -2092,11 +1961,15 @@ fn validate_switch_templates( return Ok(()); } + let project_config = repo.load_project_config()?; let user_hooks = config.hooks(repo.project_identifier().ok().as_deref()); for &hook_type in switch_post_hook_types(plan.is_create()) { - let (user_cfg, proj_cfg) = - crate::commands::hooks::lookup_hook_configs(&user_hooks, project_config, hook_type); + let (user_cfg, proj_cfg) = crate::commands::hooks::lookup_hook_configs( + &user_hooks, + project_config.as_ref(), + hook_type, + ); for (source, cfg) in [("user", user_cfg), ("project", proj_cfg)] { if let Some(cfg) = cfg { for cmd in cfg.commands() { @@ -2279,75 +2152,4 @@ mod tests { PrProviderChoice::GitHub ); } - - /// `base_ref_for_create` names the ref the new worktree checks out, so the - /// pre-creation hook-config preview reads the same `.config/wt.toml` the - /// hooks will run against — mirroring `execute_switch`'s `git worktree add` - /// argument choice for `CreationMethod::Regular`. - #[test] - fn base_ref_for_create_picks_the_checkout_ref() { - let mut test = TestRepo::with_initial_commit(); - test.setup_remote("main"); - test.run_git(&["branch", "local-feature"]); - // A branch that lives only on the remote: push it, then drop the local ref. - test.run_git(&["branch", "remote-feature"]); - test.run_git(&["push", "origin", "remote-feature"]); - test.run_git(&["branch", "-D", "remote-feature"]); - - // `--create` with an explicit base → that base. - assert_eq!( - base_ref_for_create(&test.repo, true, Some("dev"), "feature"), - "dev" - ); - // `--create` with no resolvable default branch → `HEAD` (git's own - // fallback for `git worktree add -b ` with no base). - assert_eq!( - base_ref_for_create(&test.repo, true, None, "feature"), - "HEAD" - ); - // An existing local branch (no worktree yet) → that branch. - assert_eq!( - base_ref_for_create(&test.repo, false, None, "local-feature"), - "local-feature" - ); - // A branch that exists only on a single remote → its tracking ref. - assert_eq!( - base_ref_for_create(&test.repo, false, None, "remote-feature"), - "origin/remote-feature" - ); - // No local ref, no remote → `HEAD`. - assert_eq!( - base_ref_for_create(&test.repo, false, None, "nowhere"), - "HEAD" - ); - - // A branch on multiple remotes: with `checkout.defaultRemote` set, - // matches what `git worktree add ` would DWIM to; without it, - // git would refuse to pick — preview safely defaults to `HEAD`. - test.setup_custom_remote("upstream", "main"); - test.run_git(&["branch", "shared-feature"]); - test.run_git(&["push", "origin", "shared-feature"]); - test.run_git(&["push", "upstream", "shared-feature"]); - test.run_git(&["branch", "-D", "shared-feature"]); - // `checkout.defaultRemote` may be set in the user's global config — - // override locally so the test isn't sensitive to it. - test.repo - .set_config("checkout.defaultRemote", "no-such-remote") - .unwrap(); - let repo = Repository::at(test.root_path()).unwrap(); - // Default doesn't match any remote on the branch → fall back to HEAD. - assert_eq!( - base_ref_for_create(&repo, false, None, "shared-feature"), - "HEAD" - ); - // With `checkout.defaultRemote = upstream`, that remote's ref wins — - // matching what `git worktree add shared-feature` would DWIM to. - repo.set_config("checkout.defaultRemote", "upstream") - .unwrap(); - let repo = Repository::at(test.root_path()).unwrap(); - assert_eq!( - base_ref_for_create(&repo, false, None, "shared-feature"), - "upstream/shared-feature" - ); - } } diff --git a/src/git/repository/config.rs b/src/git/repository/config.rs index 4679898ed1..b00e5a3473 100644 --- a/src/git/repository/config.rs +++ b/src/git/repository/config.rs @@ -613,46 +613,6 @@ impl Repository { }) .map(Option::as_ref) } - - /// Parse `.config/wt.toml` as committed at `gitref` (a branch name, - /// `FETCH_HEAD`, a SHA, …), applying the structural TOML migration so - /// deprecated patterns still parse. - /// - /// Used to preview the project config a not-yet-created worktree will - /// check out: `wt switch --create`'s hook-approval prompt and template - /// pre-flight read the base ref this way so they match what the - /// post-switch hooks execute against once the worktree exists (a checkout - /// of that ref). `WORKTRUNK_PROJECT_CONFIG_PATH` overrides the path - /// regardless of the ref, the same as [`project_config_path`]. - /// - /// Unlike [`load_project_config`](Self::load_project_config) this never - /// touches the working tree and never writes a `.new` migration file — - /// it must not, since the content comes from an arbitrary ref rather than - /// the user's working copy. Returns `Ok(None)` only when the ref has no - /// `.config/wt.toml` (git exits non-zero); a present file that fails to - /// parse is surfaced as `Err` so the caller can abort rather than silently - /// fall through to another config. - /// - /// [`project_config_path`]: Self::project_config_path - pub fn project_config_at_ref(&self, gitref: &str) -> anyhow::Result> { - if std::env::var_os("WORKTRUNK_PROJECT_CONFIG_PATH").is_some() { - return self.load_project_config(); - } - // `--end-of-options` keeps a ref like `-foo` from being parsed as a - // flag (git's option parser would otherwise reject `-foo:.config/wt.toml`). - let Ok(content) = self.run_command(&[ - "show", - "--end-of-options", - &format!("{gitref}:.config/wt.toml"), - ]) else { - return Ok(None); - }; - let migrated = crate::config::migrate_content(&content); - let config = toml::from_str::(&migrated).with_context(|| { - format!("Failed to parse committed `.config/wt.toml` at `{gitref}`") - })?; - Ok(Some(config)) - } } #[cfg(test)] @@ -660,66 +620,6 @@ mod tests { use super::*; use crate::testing::TestRepo; - #[test] - fn project_config_at_ref_reads_committed_config() { - let test = TestRepo::with_initial_commit(); - let repo = Repository::at(test.root_path()).unwrap(); - - // No `.config/wt.toml` committed yet, and a nonexistent ref → Ok(None). - assert!(repo.project_config_at_ref("HEAD").unwrap().is_none()); - assert!(repo.project_config_at_ref("no-such-ref").unwrap().is_none()); - - // Commit one and read it back at that branch. - test.write_project_config(r#"post-start = "echo hi""#); - test.run_git(&["add", ".config/wt.toml"]); - test.run_git(&["commit", "-m", "Add config"]); - let cfg = repo - .project_config_at_ref("HEAD") - .unwrap() - .expect("config committed at HEAD"); - assert!(cfg.hooks.post_create.is_some()); - - // A committed file that doesn't parse → Err. Callers must abort rather - // than silently fall through to a different config. - test.write_project_config("this is not [ valid toml"); - test.run_git(&["add", ".config/wt.toml"]); - test.run_git(&["commit", "-m", "Break config"]); - let err = repo - .project_config_at_ref("HEAD") - .expect_err("malformed committed config should surface as Err"); - let msg = format!("{err:#}"); - assert!( - msg.contains("Failed to parse committed `.config/wt.toml`"), - "error should name the ref it failed to parse; got: {msg}" - ); - } - - /// A ref literally named `-foo` must round-trip through - /// [`project_config_at_ref`] without git's option parser eating the - /// leading `-`. Without `--end-of-options`, `git show` would reject - /// `-foo:.config/wt.toml` as a flag and the helper would silently - /// return `None` even though the file exists on the ref. - #[test] - fn project_config_at_ref_handles_hyphen_prefixed_ref() { - let test = TestRepo::with_initial_commit(); - let repo = Repository::at(test.root_path()).unwrap(); - - test.write_project_config(r#"post-start = "echo hi""#); - test.run_git(&["add", ".config/wt.toml"]); - test.run_git(&["commit", "-m", "Add config"]); - - // `git branch -- -foo` is rejected by recent git, but `update-ref` - // happily writes the ref — which is the worst case we want to be - // robust against. - test.run_git(&["update-ref", "refs/heads/-foo", "HEAD"]); - - let cfg = repo - .project_config_at_ref("-foo") - .unwrap() - .expect("config readable at hyphen-prefixed ref"); - assert!(cfg.hooks.post_create.is_some()); - } - #[test] fn test_get_config_regexp_no_match_returns_empty() { // Exit 1 from git config --get-regexp means "no keys matched" — must diff --git a/src/main.rs b/src/main.rs index 352146e316..d084b967e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -916,14 +916,12 @@ fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> { } // Helper: build and approve, once, the frozen hook plan the - // removal will run. `pre-remove` / `post-remove` are anchored at - // each removed worktree (their `.config/wt.toml`); `post-switch` - // at each removal's post-removal destination - // (`RemoveResult::destination_path()`, normally the primary - // worktree, cwd when the primary worktree is itself the removal - // target). No fallback between worktrees, same rule as the - // executors. `!verify` (`--no-hooks`) or a declined prompt yields - // an empty plan — every executor then runs no project hooks. + // removal will run. Every hook (`pre-remove` / `post-remove` per + // removed worktree, `post-switch` per post-removal destination) is + // selected from the invoking worktree's `.config/wt.toml` — the + // worktree `wt remove` ran in. `!verify` (`--no-hooks`) or a + // declined prompt yields an empty plan — every executor then runs + // no project hooks. let approve_remove = |removed_worktree_paths: &[&Path], destination_paths: &[&Path], yes: bool| @@ -937,13 +935,13 @@ fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> { // behaviour where the empty-batch fast path ran first. let project_id = repo.project_identifier().ok(); let pid = project_id.as_deref(); + let project_config = repo.load_project_config()?; let mut builder = HookPlanBuilder::new(); for &wt_path in removed_worktree_paths { - let cfg = Repository::at(wt_path)?.load_project_config()?; builder.add( wt_path, &[HookType::PreRemove, HookType::PostRemove], - cfg.as_ref(), + project_config.as_ref(), &config, pid, ); @@ -953,8 +951,13 @@ fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> { if !seen_dests.insert(dest) { continue; } - let cfg = Repository::at(dest)?.load_project_config()?; - builder.add(dest, &[HookType::PostSwitch], cfg.as_ref(), &config, pid); + builder.add( + dest, + &[HookType::PostSwitch], + project_config.as_ref(), + &config, + pid, + ); } match builder.finish().approve(pid, yes)? { Some(plan) => Ok(plan), diff --git a/src/output/handlers.rs b/src/output/handlers.rs index dfd3d5bded..7335ad771a 100644 --- a/src/output/handlers.rs +++ b/src/output/handlers.rs @@ -734,8 +734,8 @@ pub fn execute_user_command(command: &str, display_path: Option<&Path>) -> anyho /// /// `plan` is the frozen, approved hook set; an empty plan (`--no-hooks`, /// declined, or no project config) runs no project hooks. `pre-remove` / -/// `post-remove` / `post-switch` execute only from it — never a re-read of -/// the (by-then-gone) worktree config. +/// `post-remove` / `post-switch` execute only from it — the selection was +/// frozen at the gate, never re-read. /// /// When `silent` is true (the TUI picker — this runs while skim owns the /// terminal), a `RemovedWorktree` result is removed with no progress/success @@ -1210,9 +1210,8 @@ fn execute_pre_remove_hooks_if_needed( // `pre-remove` runs in the worktree being removed (still on disk here). // `pre_remove_repo` roots the *render* context there for template vars; - // the command set is the frozen `plan` (anchored at the removed worktree - // at the gate), so the removed worktree's `.config/wt.toml` is never - // re-read. + // the command set is the frozen `plan` selected at the gate, so no + // `.config/wt.toml` is re-read here. let pre_remove_repo = Repository::at(ctx.worktree_path)?; let command_ctx = CommandContext::new( &pre_remove_repo, diff --git a/tests/integration_tests/merge.rs b/tests/integration_tests/merge.rs index f1935ab871..0aca5aecd3 100644 --- a/tests/integration_tests/merge.rs +++ b/tests/integration_tests/merge.rs @@ -1745,23 +1745,23 @@ fn test_merge_primary_on_different_branch_dirty(mut repo: TestRepo) { )); } -/// `wt merge` resolves each hook from the worktree it acts on: `post-merge` -/// runs in the destination (target's worktree) and reads its config there; -/// `pre-remove` runs in the feature worktree (still on disk before removal) -/// and reads its config there. No fallback between worktrees — each -/// `.config/wt.toml` stands alone. -#[rstest] -fn test_merge_teardown_hooks_read_acting_worktree_config(mut repo: TestRepo) { +/// `wt merge` resolves both teardown hooks from the invoking worktree — the +/// feature worktree `wt merge` ran in. `post-merge` runs in the merge +/// destination and `pre-remove` in the feature worktree, but both select their +/// commands from the feature worktree's `.config/wt.toml`. `main` — the +/// destination — has no project config, so a destination-sourced `post-merge` +/// would select nothing and its marker would never appear. +#[rstest] +fn test_merge_teardown_hooks_read_invoking_worktree_config(mut repo: TestRepo) { use crate::common::wait_for_file_content; let pre_remove_marker = repo.root_path().join("pre-remove-ran.txt"); let post_merge_marker = repo.root_path().join("post-merge-ran.txt"); - // Commit both hooks on `main` so the feature branch inherits them. The - // feature worktree then defines them in its own `.config/wt.toml` — same - // text as main's. After the merge lands, main still has these definitions - // (it fast-forwards to the feature tip), so the `post-merge` hook on the - // destination (main) fires from main's post-merge config. + // Both hooks live only in the feature worktree's `.config/wt.toml`. `main` + // — the merge destination — has no project config, so `post-merge` fires + // only by resolving against the invoking feature worktree's config. + let feature_wt = repo.add_worktree_with_commit("feature-pm", "feature.txt", "x", "feat: x"); let config_body = format!( r#"pre-remove = "echo 'removing {{{{ branch }}}}' > {}" [post-merge] @@ -1770,13 +1770,10 @@ sync = "echo 'merged {{{{ branch }}}}' > {}" pre_remove_marker.to_slash_lossy(), post_merge_marker.to_slash_lossy(), ); - repo.write_project_config(&config_body); - repo.commit("Add merge hooks on main"); - - // The feature worktree inherits the same `.config/wt.toml` because it was - // branched off main after the commit above — the feature worktree owns its - // own `pre-remove` config locally, no fallback to main needed. - let feature_wt = repo.add_worktree_with_commit("feature-pm", "feature.txt", "x", "feat: x"); + std::fs::create_dir_all(feature_wt.join(".config")).unwrap(); + std::fs::write(feature_wt.join(".config/wt.toml"), &config_body).unwrap(); + repo.run_git_in(&feature_wt, &["add", ".config/wt.toml"]); + repo.run_git_in(&feature_wt, &["commit", "-m", "Add merge hooks"]); let output = repo .wt_command() @@ -1794,14 +1791,14 @@ sync = "echo 'merged {{{{ branch }}}}' > {}" assert_eq!( std::fs::read_to_string(&post_merge_marker).unwrap().trim(), "merged feature-pm", - "post-merge should run with the destination worktree's config" + "post-merge runs in the destination but selects its config from the \ + invoking feature worktree" ); - // pre-remove runs synchronously before removal; the feature worktree's - // own `.config/wt.toml` defines it. + // pre-remove runs synchronously in the feature worktree before removal. assert_eq!( std::fs::read_to_string(&pre_remove_marker).unwrap().trim(), "removing feature-pm", - "pre-remove should run with the feature worktree's own config" + "pre-remove should run from the invoking feature worktree's config" ); } @@ -3772,17 +3769,17 @@ fn test_merge_removes_branch_when_local_main_diverged_from_upstream( ); } -/// Approval-boundary TOCTOU regression: a `post-merge` command that exists only -/// in the *feature branch's* committed `.config/wt.toml` must not run. +/// Approval-boundary TOCTOU regression: a `post-merge` command that enters the +/// invoking worktree's `.config/wt.toml` only via the rebase — after the gate +/// froze the plan — must not run. /// -/// The merge destination (the primary worktree on `main`) has no project -/// config, so the gate freezes an empty *project* `post-merge` selection. The -/// merge then fast-forwards `main` to the feature tip, bringing the feature's -/// `.config/wt.toml` (with its injected `post-merge`) onto the destination. -/// The pre-fix executor re-read the destination's now-mutated config and ran -/// the never-approved command — remote code execution on a fresh clone. With -/// the frozen `ApprovedHookPlan` the destination config is read once, before -/// the merge, so the injected command is never selected. +/// `wt merge` selects hooks from the feature worktree it runs in, at the gate, +/// then rebases that worktree onto the target. Here the target branch carries +/// an un-approved `post-merge`; the feature branch branched before it, so the +/// gate sees a clean config and freezes an empty *project* selection. The +/// rebase then brings the target's `.config/wt.toml` into the feature worktree, +/// but the executor runs only the frozen `ApprovedHookPlan` — a re-read of the +/// now-mutated config would be remote code execution on a fresh clone. /// /// The wait is bounded *causally*, not by a fixed sleep: a user-config /// `post-merge` (no approval, runs via the same background pipeline as project @@ -3795,9 +3792,7 @@ fn test_merge_removes_branch_when_local_main_diverged_from_upstream( /// (user vs project source) differ only by a `touch`'s sub-millisecond skew /// once spawned; the short grace covers it. #[rstest] -fn test_post_merge_hook_from_merged_feature_config_does_not_run( - merge_scenario: (TestRepo, PathBuf), -) { +fn test_post_merge_hook_from_rebased_in_config_does_not_run(merge_scenario: (TestRepo, PathBuf)) { let (repo, feature_wt) = merge_scenario; // Markers live at the repo root — they outlive the feature worktree, which @@ -3812,15 +3807,15 @@ fn test_post_merge_hook_from_merged_feature_config_does_not_run( control.to_slash_lossy() )); - // Inject the malicious `post-merge` only on the feature branch. - fs::create_dir_all(feature_wt.join(".config")).unwrap(); - fs::write( - feature_wt.join(".config/wt.toml"), - format!(r#"post-merge = "touch {}""#, injected.to_slash_lossy()), - ) - .unwrap(); - repo.run_git_in(&feature_wt, &["add", ".config/wt.toml"]); - repo.run_git_in(&feature_wt, &["commit", "-m", "inject post-merge"]); + // The target branch (`main`) gains an un-approved `post-merge` after + // `feature` branched off, so the gate — reading the feature worktree — + // never sees it. The rebase will bring this `.config/wt.toml` into the + // feature worktree before the executor runs. + repo.write_project_config(&format!( + r#"post-merge = "touch {}""#, + injected.to_slash_lossy() + )); + repo.commit("inject post-merge on main"); let output = repo .wt_command() @@ -3834,41 +3829,13 @@ fn test_post_merge_hook_from_merged_feature_config_does_not_run( String::from_utf8_lossy(&output.stderr) ); - // The merge fast-forwarded `main` to the feature tip, so the destination - // worktree's `.config/wt.toml` now contains the injected hook. Wait for the - // control (post-merge ran), then a short grace for the sibling pipeline. + // Wait for the control (post-merge ran), then a short grace for the sibling + // pipeline. wait_for_file(&control); std::thread::sleep(std::time::Duration::from_millis(200)); assert!( !injected.exists(), - "an unapproved post-merge command from the merged-in feature config must not run" - ); -} - -/// `wt merge --no-hooks` runs no hooks, so it must not parse the destination's -/// `.config/wt.toml` at all. A malformed destination config must not fail a -/// hooks-disabled merge (regression: the plan rewrite read the destination -/// config unconditionally, before gating on `verify`). -#[rstest] -fn test_merge_no_hooks_ignores_malformed_destination_config(merge_scenario: (TestRepo, PathBuf)) { - let (repo, feature_wt) = merge_scenario; - - // Malformed config in the primary worktree = the merge destination. - repo.write_project_config("post-merge = = not valid toml"); - - let output = repo - .wt_command() - .current_dir(&feature_wt) - .args(["merge", "--no-hooks", "--yes"]) - .output() - .unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "--no-hooks merge must not read the destination config; stderr:\n{stderr}" - ); - assert!( - !stderr.contains("expected") && !stderr.contains("TOML parse"), - "destination .config/wt.toml must not be parsed with --no-hooks; stderr:\n{stderr}" + "a post-merge that entered the invoking worktree's config only via the \ + rebase must not run — it was never in the frozen plan" ); } diff --git a/tests/integration_tests/remove.rs b/tests/integration_tests/remove.rs index 6f1bdacd03..03f502cedb 100644 --- a/tests/integration_tests/remove.rs +++ b/tests/integration_tests/remove.rs @@ -1710,28 +1710,36 @@ approved-commands = ["echo 'hook ran' > {}"] ); } -/// `pre-remove` resolves `.config/wt.toml` from the worktree being removed, not -/// the primary worktree — so a hook added on a feature branch fires even before -/// that `.config/wt.toml` reaches the default branch. The primary worktree here -/// has no project config at all; only the removed worktree does. +/// `pre-remove` resolves `.config/wt.toml` from the invoking worktree — the one +/// `wt remove` ran in — while its template context (`{{ branch }}` etc.) is the +/// removed worktree's. The removed worktree's own config is not consulted. #[rstest] -fn test_pre_remove_hook_reads_removed_worktree_config(mut repo: TestRepo) { +fn test_pre_remove_hook_reads_invoking_worktree_config(mut repo: TestRepo) { use crate::common::wait_for_file_content; let worktree_path = repo.add_worktree("feature-local-hook"); let marker_file = repo.root_path().join("pre-remove-ran.txt"); + let wrong_marker = repo.root_path().join("wrong-marker.txt"); + + // A competing hook in the removed worktree, which must be ignored. std::fs::create_dir_all(worktree_path.join(".config")).unwrap(); std::fs::write( worktree_path.join(".config/wt.toml"), - // `{{ branch }}` proves the hook ran with the removed worktree's context. format!( - r#"pre-remove = "echo 'removed branch {{{{ branch }}}}' > {}""#, - marker_file.to_slash_lossy() + r#"pre-remove = "echo wrong > {}""#, + wrong_marker.to_slash_lossy() ), ) .unwrap(); - // `--force` because `.config/wt.toml` is untracked in the removed worktree; + // The hook that should run lives in the invoking worktree (primary, cwd). + // `{{ branch }}` proves it ran with the removed worktree's template context. + repo.write_project_config(&format!( + r#"pre-remove = "echo 'removed branch {{{{ branch }}}}' > {}""#, + marker_file.to_slash_lossy() + )); + + // `--force` because the removed worktree has an untracked `.config/wt.toml`; // `--yes` to skip the approval prompt. let output = repo .wt_command() @@ -1754,34 +1762,45 @@ fn test_pre_remove_hook_reads_removed_worktree_config(mut repo: TestRepo) { assert_eq!( std::fs::read_to_string(&marker_file).unwrap().trim(), "removed branch feature-local-hook", - "pre-remove should run with the removed worktree's config" + "pre-remove runs from the invoking worktree's config, with the removed worktree's branch" + ); + assert!( + !wrong_marker.exists(), + "the removed worktree's own config must not be consulted" ); } -/// `post-remove` reads the *removed* worktree's `.config/wt.toml`, even though -/// the worktree is gone by the time the hook runs. The config is snapshotted -/// before removal and threaded through `RemoveResult` to the executor — -/// symmetric with `pre-remove`, which reads the same file from disk while it's -/// still there. Both hooks are *about* the removed worktree. +/// `post-remove` reads the invoking worktree's `.config/wt.toml`, snapshotted at +/// the gate — its template context (`{{ branch }}` etc.) is the removed +/// worktree's, gone by the time the hook runs. The removed worktree's own +/// config is not consulted. #[rstest] -fn test_post_remove_hook_reads_removed_worktree_config(mut repo: TestRepo) { +fn test_post_remove_hook_reads_invoking_worktree_config(mut repo: TestRepo) { use crate::common::wait_for_file_content; let worktree_path = repo.add_worktree("feature-local-hook"); let marker_file = repo.root_path().join("post-remove-ran.txt"); + let wrong_marker = repo.root_path().join("wrong-marker.txt"); + + // A competing hook in the removed worktree, which must be ignored. std::fs::create_dir_all(worktree_path.join(".config")).unwrap(); std::fs::write( worktree_path.join(".config/wt.toml"), - // `{{ branch }}` proves the hook ran with the removed worktree's - // template context; the marker living outside the removed worktree - // (in the primary) lets us assert it after the worktree is gone. format!( - r#"post-remove = "echo 'post-remove of {{{{ branch }}}}' > {}""#, - marker_file.to_slash_lossy() + r#"post-remove = "echo wrong > {}""#, + wrong_marker.to_slash_lossy() ), ) .unwrap(); + // The hook that should run lives in the invoking worktree (primary, cwd). + // `{{ branch }}` proves it ran with the removed worktree's template context; + // the marker outside the removed worktree survives the removal. + repo.write_project_config(&format!( + r#"post-remove = "echo 'post-remove of {{{{ branch }}}}' > {}""#, + marker_file.to_slash_lossy() + )); + let output = repo .wt_command() .args([ @@ -1803,42 +1822,36 @@ fn test_post_remove_hook_reads_removed_worktree_config(mut repo: TestRepo) { assert_eq!( std::fs::read_to_string(&marker_file).unwrap().trim(), "post-remove of feature-local-hook", - "post-remove should run with the removed worktree's config, snapshotted before removal" + "post-remove runs from the invoking worktree's config, snapshotted before removal" ); assert!( !worktree_path.exists(), "feature worktree should be removed" ); + assert!( + !wrong_marker.exists(), + "the removed worktree's own config must not be consulted" + ); } -/// A worktree with a malformed `.config/wt.toml` makes `wt remove` abort with -/// the parse error in stderr — no silent fall-through to a different config, -/// and the worktree stays on disk so the user can fix it. +/// A malformed `.config/wt.toml` in the invoking worktree makes `wt remove` +/// abort with the parse error in stderr — no silent fall-through to a different +/// config, and the worktree stays on disk so the user can fix it. #[rstest] -fn test_remove_aborts_on_malformed_worktree_config(mut repo: TestRepo) { - let worktree_path = repo.add_worktree("feature-bad-config"); - std::fs::create_dir_all(worktree_path.join(".config")).unwrap(); - std::fs::write( - worktree_path.join(".config/wt.toml"), - "this is not [ valid toml", - ) - .unwrap(); +fn test_remove_aborts_on_malformed_invoking_config(mut repo: TestRepo) { + let worktree_path = repo.add_worktree("feature-x"); + // Malformed config in the invoking worktree (primary, cwd). + repo.write_project_config("this is not [ valid toml"); let output = repo .wt_command() - .args([ - "remove", - "--foreground", - "--force", - "--yes", - "feature-bad-config", - ]) + .args(["remove", "--foreground", "--force", "--yes", "feature-x"]) .output() .unwrap(); let stderr = String::from_utf8_lossy(&output.stderr); assert!( !output.status.success(), - "wt remove should abort on a malformed worktree config; stderr:\n{stderr}" + "wt remove should abort on a malformed invoking-worktree config; stderr:\n{stderr}" ); assert!( stderr.contains("wt.toml"), @@ -1850,52 +1863,6 @@ fn test_remove_aborts_on_malformed_worktree_config(mut repo: TestRepo) { ); } -/// Removing a worktree that has no `.config/wt.toml` does NOT fall back to the -/// primary worktree's config: neither for execution (per #2714) nor for -/// approval (`HookPlanBuilder::add` selects per removed-worktree anchor with no -/// primary fallback). Without `--yes` and without prior approval, the command -/// must succeed (the approval prompt has no commands to surface) rather than -/// block on the primary's `pre-remove`. -#[rstest] -fn test_remove_no_fallback_to_primary_pre_remove(mut repo: TestRepo) { - // Add the feature worktree first, off HEAD, so it doesn't carry the - // uncommitted config below. - let worktree_path = repo.add_worktree("feature-no-config"); - let marker_file = repo.root_path().join("pre-remove-ran.txt"); - - // Primary worktree only — uncommitted, so `feature-no-config` doesn't - // inherit it. - repo.write_project_config(&format!( - r#"pre-remove = "echo ran > {}""#, - marker_file.to_slash_lossy() - )); - - // No `--yes`: with the bug, the approval helper would surface the primary's - // `pre-remove` and `wt remove` would abort with "needs approval" because - // tests don't have a TTY. With the fix, the prompt is empty and removal - // proceeds. `--force` because the feature branch is unmerged. - let output = repo - .wt_command() - .args(["remove", "--foreground", "--force", "feature-no-config"]) - .output() - .unwrap(); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "wt remove should succeed when the removed worktree has no \ - .config/wt.toml — no fallback to the primary's; stderr:\n{stderr}" - ); - assert!( - !worktree_path.exists(), - "feature worktree should be removed" - ); - assert!( - !marker_file.exists(), - "primary's pre-remove must not fire when the removed worktree has \ - no .config/wt.toml" - ); -} - /// Removing a worktree with no project hooks must not touch approval state. /// A malformed `approvals.toml` aborts only when there is a project command to /// authorize; with an empty plan the gate never loads approvals (regression: diff --git a/tests/integration_tests/step_prune.rs b/tests/integration_tests/step_prune.rs index e2d0028756..77f9559733 100644 --- a/tests/integration_tests/step_prune.rs +++ b/tests/integration_tests/step_prune.rs @@ -985,37 +985,32 @@ cleanup = "echo done" assert_cmd_snapshot!(cmd); } -/// Commit a `pre-remove` hook on the default branch, branch a worktree off that -/// commit (so it carries the hook in its own `.config/wt.toml`), then drop the -/// hook from the default branch and advance past the worktree's commit. The -/// worktree is now integrated (an ancestor of the default branch) and clean, -/// while the cwd config no longer mentions `pre-remove` — the situation where -/// prune would otherwise approve against the wrong config. Returns the -/// worktree path and the marker file the hook writes. -fn prune_branch_local_pre_remove_setup( - repo: &mut TestRepo, -) -> (std::path::PathBuf, std::path::PathBuf) { +/// Branch a worktree, advance the default branch past it (so it's integrated +/// and prunable), and put a `pre-remove` hook in the invoking worktree (cwd) — +/// the config `wt step prune` resolves against. Returns the worktree path and +/// the marker file the hook writes. +fn prune_pre_remove_setup(repo: &mut TestRepo) -> (std::path::PathBuf, std::path::PathBuf) { use path_slash::PathExt as _; + let wt_path = repo.add_worktree("merged"); + // Advance the default branch so `merged` is strictly an ancestor — prune + // treats it as integrated and removable. + repo.commit("Advance default branch"); + // The `pre-remove` hook lives in the invoking worktree (cwd), uncommitted. let marker = repo.root_path().join("prune-pre-remove-ran.txt"); repo.write_project_config(&format!( r#"pre-remove = "echo ran > {}""#, marker.to_slash_lossy() )); - repo.commit("Add pre-remove hook"); - let wt_path = repo.add_worktree("merged-with-hook"); - repo.write_project_config("# no hooks on the default branch\n"); - repo.commit("Drop pre-remove hook"); (wt_path, marker) } -/// `wt step prune`'s approval covers each pruned worktree's own `pre-remove`, -/// not just the cwd config's. A hook that lives only in a pruned worktree's -/// branch-local `.config/wt.toml` must be approved before it runs — with no TTY -/// and no prior approval, prune aborts rather than running it silently. +/// `wt step prune`'s approval covers the `pre-remove` hooks the pruned worktrees +/// will run — resolved from the invoking worktree's config. With no TTY and no +/// prior approval, prune aborts rather than running them silently. #[rstest] -fn test_prune_branch_local_pre_remove_needs_approval(mut repo: TestRepo) { - let (wt_path, marker) = prune_branch_local_pre_remove_setup(&mut repo); +fn test_prune_pre_remove_needs_approval(mut repo: TestRepo) { + let (wt_path, marker) = prune_pre_remove_setup(&mut repo); let output = repo .wt_command() @@ -1026,11 +1021,11 @@ fn test_prune_branch_local_pre_remove_needs_approval(mut repo: TestRepo) { assert!( !output.status.success(), - "prune should abort when an un-approved branch-local pre-remove is in scope; stderr:\n{stderr}" + "prune should abort when an un-approved pre-remove is in scope; stderr:\n{stderr}" ); assert!( stderr.contains("needs approval") && stderr.contains("pre-remove"), - "prune should surface the pruned worktree's pre-remove for approval; stderr:\n{stderr}" + "prune should surface the pre-remove for approval; stderr:\n{stderr}" ); assert!( wt_path.exists(), @@ -1042,13 +1037,13 @@ fn test_prune_branch_local_pre_remove_needs_approval(mut repo: TestRepo) { ); } -/// With `--yes`, `wt step prune` runs the approved `pre-remove` hook from each -/// pruned worktree's own `.config/wt.toml`. +/// With `--yes`, `wt step prune` runs the `pre-remove` hook from the invoking +/// worktree's `.config/wt.toml` for each pruned worktree. #[rstest] -fn test_prune_runs_branch_local_pre_remove_hook(mut repo: TestRepo) { +fn test_prune_runs_pre_remove_hook(mut repo: TestRepo) { use crate::common::wait_for_file_content; - let (wt_path, marker) = prune_branch_local_pre_remove_setup(&mut repo); + let (wt_path, marker) = prune_pre_remove_setup(&mut repo); let output = repo .wt_command() diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs index 9556d10560..6da23defd8 100644 --- a/tests/integration_tests/switch.rs +++ b/tests/integration_tests/switch.rs @@ -1148,27 +1148,32 @@ fn test_switch_no_config_commands_with_yes(repo: TestRepo) { ); } -/// `wt switch --create --base ` resolves `pre-start` / `post-start` -/// from the base branch's committed `.config/wt.toml` — the worktree these hooks -/// run in is a checkout of that branch. The primary worktree (cwd) has no -/// project config at all; only `other-base` does, so a regression that read the -/// invoking worktree's config would run nothing. -#[rstest] -fn test_switch_create_reads_base_branch_config(mut repo: TestRepo) { - // Commit a `.config/wt.toml` on `other-base` only (not on `main`). +/// `wt switch --create` resolves `pre-start` / `post-start` from the +/// **invoking** worktree's `.config/wt.toml`, read from its working tree — so +/// an *uncommitted* config fires the hooks (the regression behind #2856 and +/// #2818). The base branch's committed `.config/wt.toml` is not consulted, even +/// though the new worktree is a checkout of it. +#[rstest] +fn test_switch_create_reads_invoking_worktree_config(mut repo: TestRepo) { + // The base branch carries a *committed* hook that must be ignored. let other_wt = repo.add_worktree("other-base"); fs::create_dir_all(other_wt.join(".config")).unwrap(); fs::write( other_wt.join(".config/wt.toml"), - // `{{ repo_path }}` is the main worktree root regardless of which - // worktree the hook runs in, so the markers land where the test reads. - r#"pre-start = "echo pre-start-from-base > {{ repo_path }}/pre-start-marker.txt" -post-start = "echo post-start-from-base > {{ repo_path }}/post-start-marker.txt" -"#, + r#"post-start = "echo from-base-branch > {{ repo_path }}/base-branch-marker.txt""#, ) .unwrap(); repo.run_git_in(&other_wt, &["add", ".config/wt.toml"]); - repo.run_git_in(&other_wt, &["commit", "-m", "Add hooks on other-base"]); + repo.run_git_in(&other_wt, &["commit", "-m", "Committed hook on other-base"]); + + // The invoking worktree (`main`, cwd) has an *uncommitted* `.config/wt.toml`. + // `{{ repo_path }}` is the main worktree root regardless of which worktree + // the hook runs in, so the markers land where the test reads. + repo.write_project_config( + r#"pre-start = "echo pre-start-from-invoking > {{ repo_path }}/pre-start-marker.txt" +post-start = "echo post-start-from-invoking > {{ repo_path }}/post-start-marker.txt" +"#, + ); let output = repo .wt_command() @@ -1192,31 +1197,43 @@ post-start = "echo post-start-from-base > {{ repo_path }}/post-start-marker.txt" wait_for_file_content(&pre_marker); assert_eq!( fs::read_to_string(&pre_marker).unwrap().trim(), - "pre-start-from-base", - "pre-start should run with the base branch's config" + "pre-start-from-invoking", + "pre-start should run from the invoking worktree's uncommitted config" ); let post_marker = repo.root_path().join("post-start-marker.txt"); wait_for_file_content(&post_marker); assert_eq!( fs::read_to_string(&post_marker).unwrap().trim(), - "post-start-from-base", - "post-start should run with the base branch's config" + "post-start-from-invoking", + "post-start should run from the invoking worktree's uncommitted config" + ); + + // The base branch's committed hook must never run. + std::thread::sleep(std::time::Duration::from_millis(500)); + assert!( + !repo.root_path().join("base-branch-marker.txt").exists(), + "the base branch's committed config must not be consulted for `--create`" ); } -/// `wt switch ` resolves `post-switch` from the destination worktree's -/// `.config/wt.toml`, not the worktree `wt switch` ran in. Only the destination -/// has a project config here. +/// `wt switch ` resolves `post-switch` from the **invoking** worktree's +/// `.config/wt.toml` — the worktree `wt switch` ran in — not the destination's. #[rstest] -fn test_switch_existing_reads_destination_worktree_config(mut repo: TestRepo) { +fn test_switch_existing_reads_invoking_worktree_config(mut repo: TestRepo) { + // The destination worktree carries its own hook, which must be ignored. let dest = repo.add_worktree("dest"); fs::create_dir_all(dest.join(".config")).unwrap(); fs::write( dest.join(".config/wt.toml"), - r#"post-switch = "echo post-switch-from-dest > {{ repo_path }}/post-switch-marker.txt""#, + r#"post-switch = "echo from-dest > {{ repo_path }}/dest-marker.txt""#, ) .unwrap(); + // The invoking worktree (`main`, cwd) carries the hook that should run. + repo.write_project_config( + r#"post-switch = "echo from-invoking > {{ repo_path }}/post-switch-marker.txt""#, + ); + let output = repo .wt_command() .args(["switch", "dest", "--yes"]) @@ -1232,20 +1249,26 @@ fn test_switch_existing_reads_destination_worktree_config(mut repo: TestRepo) { wait_for_file_content(&marker); assert_eq!( fs::read_to_string(&marker).unwrap().trim(), - "post-switch-from-dest", - "post-switch should run with the destination worktree's config" + "from-invoking", + "post-switch should run with the invoking worktree's config" + ); + + // The destination worktree's own hook must never run. + std::thread::sleep(std::time::Duration::from_millis(500)); + assert!( + !repo.root_path().join("dest-marker.txt").exists(), + "the destination worktree's config must not be consulted" ); } -/// A destination worktree with a malformed `.config/wt.toml` makes `wt switch -/// ` abort with the parse error in stderr — no silent fall-through -/// to a different config. The path is surfaced so the user can find and fix -/// the offending file. +/// A malformed `.config/wt.toml` in the invoking worktree makes `wt switch` +/// abort with the parse error in stderr — no silent fall-through to a different +/// config. The path is surfaced so the user can find and fix the offending file. #[rstest] -fn test_switch_existing_aborts_on_malformed_destination_config(mut repo: TestRepo) { - let dest = repo.add_worktree("dest"); - fs::create_dir_all(dest.join(".config")).unwrap(); - fs::write(dest.join(".config/wt.toml"), "this is not [ valid toml").unwrap(); +fn test_switch_aborts_on_malformed_invoking_config(mut repo: TestRepo) { + repo.add_worktree("dest"); + // Malformed config in the invoking worktree (`main`, cwd). + repo.write_project_config("this is not [ valid toml"); let output = repo .wt_command() @@ -1255,7 +1278,7 @@ fn test_switch_existing_aborts_on_malformed_destination_config(mut repo: TestRep let stderr = String::from_utf8_lossy(&output.stderr); assert!( !output.status.success(), - "wt switch should abort on a malformed destination config; stderr:\n{stderr}" + "wt switch should abort on a malformed invoking-worktree config; stderr:\n{stderr}" ); assert!( stderr.contains("wt.toml"), @@ -1263,9 +1286,10 @@ fn test_switch_existing_aborts_on_malformed_destination_config(mut repo: TestRep ); } -/// `WORKTRUNK_PROJECT_CONFIG_PATH` overrides the path for *every* config read — -/// including `wt switch --create`'s base-ref preview — so the override file's -/// hooks run, not the base branch's committed `.config/wt.toml`. +/// `WORKTRUNK_PROJECT_CONFIG_PATH` overrides the path for *every* config read, +/// so `wt switch --create` resolves its hooks from the override file. The base +/// branch's committed `.config/wt.toml`, which the new worktree checks out, is +/// not consulted. #[rstest] fn test_switch_create_honors_project_config_path_override(mut repo: TestRepo) { // The base branch carries a committed hook that must be ignored. @@ -2616,24 +2640,10 @@ fn test_switch_pr_fork(#[from(repo_with_remote)] repo: TestRepo) { fn test_switch_pr_hooks_see_pr_vars(#[from(repo_with_remote)] repo: TestRepo) { // Set up the same fork-PR scenario as test_switch_pr_fork: a refs/pull/42/head on the // bare remote, origin redirected to GitHub-style URL, and `gh api` mocked. - // The PR commit carries the project config so the post-switch hooks read it - // from the new worktree (a checkout of refs/pull/42/head) directly. repo.run_git(&["checkout", "-b", "pr-source"]); fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap(); - // Each hook writes its own marker in the primary worktree so we can verify - // post-switch + post-start (background) populate pr_number/pr_url too. - // {{ repo_path }} is always the main repo's working tree. - fs::create_dir_all(repo.root_path().join(".config")).unwrap(); - fs::write( - repo.root_path().join(".config/wt.toml"), - r#"pre-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/pre_start.txt" -post-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_start.txt" -post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_switch.txt" -"#, - ) - .unwrap(); - repo.run_git(&["add", "pr-file.txt", ".config/wt.toml"]); - repo.run_git(&["commit", "-m", "PR commit with hook config"]); + repo.run_git(&["add", "pr-file.txt"]); + repo.run_git(&["commit", "-m", "PR commit"]); let commit_sha = repo .git_command() @@ -2646,6 +2656,21 @@ post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_pa repo.run_git(&["push", "origin", &format!("{}:refs/pull/42/head", sha)]); repo.run_git(&["checkout", "main"]); + // The hooks live in the invoking worktree (`main`), where `wt switch pr:42` + // runs — the `pr_*` template vars come from PR resolution, independent of + // where the config lives. Each hook writes its own marker in the primary + // worktree so we can verify post-switch + post-start (background) populate + // pr_number/pr_url too. {{ repo_path }} is always the main repo's working tree. + fs::create_dir_all(repo.root_path().join(".config")).unwrap(); + fs::write( + repo.root_path().join(".config/wt.toml"), + r#"pre-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/pre_start.txt" +post-start = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_start.txt" +post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_path }}/post_switch.txt" +"#, + ) + .unwrap(); + let bare_url = String::from_utf8_lossy( &repo .git_command() @@ -2711,17 +2736,16 @@ post-switch = "echo 'pr_number={{ pr_number }} pr_url={{ pr_url }}' > {{ repo_pa } } -/// `wt switch pr:N` resolves `post-start` etc. from the PR's tree — the fetched -/// head ref, potentially from a fork — and the approval prompt reads that same -/// PR config, so what the prompt lists can't diverge from what runs. The local -/// repo has no project config; only the PR commit does. Without `--yes` the -/// prompt lists the PR's hook and bails (non-interactive); with `--yes` the -/// hook executes against the new worktree (a checkout of the PR head). +/// `wt switch pr:N` resolves `post-start` etc. from the **invoking** worktree's +/// `.config/wt.toml` — not the PR's own committed config, even though the new +/// worktree is a checkout of the PR head. The approval prompt lists those same +/// invoking-worktree hooks, so the prompt can't diverge from what runs. The PR +/// commit here carries a *different* hook that must never run. #[rstest] -fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) { +fn test_switch_pr_reads_invoking_worktree_config(#[from(repo_with_remote)] repo: TestRepo) { // Fork-PR scenario (mirrors `test_switch_pr_hooks_see_pr_vars`): a // refs/pull/42/head on the bare remote, origin redirected to a GitHub URL, - // `gh api` mocked — but the PR commit carries a `.config/wt.toml`. + // `gh api` mocked. The PR commit carries its own `.config/wt.toml`. repo.run_git(&["checkout", "-b", "pr-source"]); fs::write(repo.root_path().join("pr-file.txt"), "PR content").unwrap(); fs::create_dir_all(repo.root_path().join(".config")).unwrap(); @@ -2744,11 +2768,17 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) .trim() .to_string(); repo.run_git(&["push", "origin", &format!("{sha}:refs/pull/42/head")]); - // Back on `main`, which never had `.config/wt.toml` committed — `pr-source` - // branched off before that commit, so `git checkout main` drops it from the - // working tree, leaving the local repo with no project config. + // Back on `main`: `pr-source` branched off before the PR commit, so + // `git checkout main` drops the PR's `.config/wt.toml` from the working tree. repo.run_git(&["checkout", "main"]); + // The invoking worktree (`main`) carries its own — uncommitted — hook. This + // is the only `.config/wt.toml` on disk here, and the one `wt switch pr:42` + // must resolve against. + repo.write_project_config( + r#"post-start = "echo INVOKING-HOOK-RAN > {{ repo_path }}/invoking-hook-marker.txt""#, + ); + let bare_url = String::from_utf8_lossy( &repo .git_command() @@ -2788,27 +2818,32 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) }"#; let mock_bin = setup_mock_gh_for_pr(&repo, Some(gh_response)); - // (a) Without `--yes`: the approval prompt prints the PR's command template - // to stderr, then bails (stdin is not a TTY in tests). No worktree, no hook. + // (a) Without `--yes`: the approval prompt lists the *invoking* worktree's + // hook (never the PR's), then bails (stdin is not a TTY in tests). let mut cmd = repo.wt_command(); cmd.args(["switch", "pr:42"]); configure_mock_cli_env(&mut cmd, &mock_bin); let output = cmd.output().expect("wt switch pr:42 should run"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("PR-CONFIG-HOOK-RAN"), - "approval prompt should list the PR ref's post-start hook; stderr:\n{stderr}" + stderr.contains("INVOKING-HOOK-RAN"), + "approval prompt should list the invoking worktree's post-start hook; stderr:\n{stderr}" + ); + assert!( + !stderr.contains("PR-CONFIG-HOOK-RAN"), + "the PR ref's committed hook must not appear in the prompt; stderr:\n{stderr}" ); assert!( !output.status.success(), "non-interactive approval should bail without running the hook" ); assert!( - !repo.root_path().join("pr-hook-marker.txt").exists(), + !repo.root_path().join("invoking-hook-marker.txt").exists(), "a declined approval must not run the hook" ); - // (b) With `--yes`: the PR's `post-start` runs against the new worktree. + // (b) With `--yes`: the invoking worktree's `post-start` runs; the PR's + // committed hook never does. let mut cmd = repo.wt_command(); cmd.args(["switch", "pr:42", "--yes"]); configure_mock_cli_env(&mut cmd, &mock_bin); @@ -2819,12 +2854,17 @@ fn test_switch_pr_reads_pr_ref_config(#[from(repo_with_remote)] repo: TestRepo) String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ); - let marker = repo.root_path().join("pr-hook-marker.txt"); + let marker = repo.root_path().join("invoking-hook-marker.txt"); wait_for_file_content(&marker); assert_eq!( fs::read_to_string(&marker).unwrap().trim(), - "PR-CONFIG-HOOK-RAN", - "post-start should run with the PR ref's config" + "INVOKING-HOOK-RAN", + "post-start should run from the invoking worktree's config" + ); + std::thread::sleep(std::time::Duration::from_millis(500)); + assert!( + !repo.root_path().join("pr-hook-marker.txt").exists(), + "the PR ref's committed config must not be consulted" ); } From ffefa6e158fadd7e3119f10e959e9f62c2924632 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Thu, 21 May 2026 19:18:22 -0700 Subject: [PATCH 25/34] fix(picker): identify worktree rows by path, not branch name (#2866) The interactive picker's alt-r removal signal carried each row's branch name; detached worktrees all report `(detached)`, so two detached rows collided and alt-r could remove the wrong one. Worktree-backed rows now identify by their unique `worktree-path:` token, decoded to an exact removal/switch target; branch-only rows keep the branch name. --- src/commands/picker/items.rs | 16 +- src/commands/picker/mod.rs | 381 ++++++++++++++++++++++++----------- 2 files changed, 278 insertions(+), 119 deletions(-) diff --git a/src/commands/picker/items.rs b/src/commands/picker/items.rs index 1bfc236233..8e7879abe5 100644 --- a/src/commands/picker/items.rs +++ b/src/commands/picker/items.rs @@ -28,6 +28,11 @@ pub(super) type PreviewCacheKey = (String, PreviewMode); /// Shared across all WorktreeSkimItems for background pre-computation. pub(super) type PreviewCache = Arc>; +/// Prefix on a worktree-backed item's `output()` token. Detached worktrees +/// all share the `(detached)` branch label, so `output()` returns the +/// worktree path (which is unique) behind this prefix instead. +pub(super) const WORKTREE_OUTPUT_PREFIX: &str = "worktree-path:"; + /// Header item for column names (non-selectable) pub(super) struct HeaderSkimItem { pub display_text: String, @@ -101,8 +106,7 @@ pub(super) struct WorktreeSkimItem { /// Current ANSI-colored display line. Starts as the skeleton render; /// replaced in place as data arrives. pub rendered: Arc>, - /// Branch name — also what `output()` returns when this item is - /// selected. + /// Branch name used by switch selection and preview cache keys. pub branch_name: String, /// Skeleton-snapshot of the underlying ListItem. Preview computation /// reads only skeleton-time fields (`branch_name`, `head`, @@ -130,7 +134,13 @@ impl SkimItem for WorktreeSkimItem { } fn output(&self) -> Cow<'_, str> { - Cow::Borrowed(&self.branch_name) + match self.item.worktree_path() { + Some(path) => Cow::Owned(format!( + "{WORKTREE_OUTPUT_PREFIX}{}", + path.to_string_lossy() + )), + None => Cow::Borrowed(&self.branch_name), + } } fn preview(&self, context: PreviewContext<'_>) -> ItemPreview { diff --git a/src/commands/picker/mod.rs b/src/commands/picker/mod.rs index 0042d586e7..a1fe50dffc 100644 --- a/src/commands/picker/mod.rs +++ b/src/commands/picker/mod.rs @@ -120,7 +120,7 @@ use crate::cli::SwitchFormat; use crate::output::handle_remove_output; use worktrunk::git::{BranchDeletionMode, delete_branch_if_safe}; -use items::{PreviewCache, WorktreeSkimItem}; +use items::{PreviewCache, WORKTREE_OUTPUT_PREFIX}; use preview::{PreviewLayout, PreviewMode, PreviewState}; use preview_orchestrator::PreviewOrchestrator; @@ -142,6 +142,52 @@ enum PickerAction { Create, } +/// The alt-r removal target parsed back out of a row's `output()` token. +/// +/// A worktree-backed row's token is `worktree-path:` (paths are +/// unique — detached worktrees would otherwise collide on the shared +/// `(detached)` label); a branch-only row's token is the bare branch name. +enum PickerRemovalTarget { + WorktreePath(PathBuf), + Branch(String), +} + +impl PickerRemovalTarget { + fn from_signal(signal: &str) -> Option { + let signal = signal.trim(); + if signal.is_empty() { + return None; + } + if let Some(path) = signal.strip_prefix(WORKTREE_OUTPUT_PREFIX) { + if path.is_empty() { + return None; + } + return Some(Self::WorktreePath(PathBuf::from(path))); + } + Some(Self::Branch(signal.to_string())) + } +} + +/// Resolve the switch identifier for a selected picker row, decoded from its +/// `output()` token: the worktree path for any worktree-backed row, the branch +/// name for a branch-only row. +/// +/// `wt switch` accepts a worktree path for any existing worktree (`plan_switch` +/// phase 2b), so a worktree-backed row always switches by its unique path — +/// detached *and* branched alike. A branch-only row has no worktree, so its +/// branch name is the only handle. +/// +/// Decoding `output()` rather than `downcast_ref::()` also +/// sidesteps skim 0.20's cross-thread `TypeId` mismatch, which makes the +/// downcast fail when the item originates on the reader thread. +fn picker_item_identifier(item: &dyn SkimItem) -> String { + let output = item.output().to_string(); + match PickerRemovalTarget::from_signal(&output) { + Some(PickerRemovalTarget::WorktreePath(path)) => path.to_string_lossy().into_owned(), + _ => output, + } +} + /// Custom command collector for skim's `reload` action. /// /// When alt-r is pressed, skim runs `execute-silent` to write the selected branch @@ -170,7 +216,14 @@ struct PickerCollector { impl PickerCollector { /// Build removal state from a fresh `Repository` so picker reloads after a /// background removal do not reuse the startup worktree inventory cache. - fn prepare_removal(&self, selected_output: &str) -> anyhow::Result<(Repository, RemoveResult)> { + /// + /// `target` carries the exact worktree path or branch name decoded from + /// the row's `output()` token — no `git worktree list` lookup, so a + /// detached row can't be confused with another detached row. + fn prepare_removal( + &self, + target: &PickerRemovalTarget, + ) -> anyhow::Result<(Repository, RemoveResult)> { let repo = Repository::at(self.repo.discovery_path())?; // Validate removal before touching the list. prepare_worktree_removal @@ -178,26 +231,14 @@ impl PickerCollector { // Only remove the item and spawn background deletion if this succeeds. let caller_path = repo.current_worktree().root().ok(); - // Resolve removal target by path when possible (handles both - // branched and detached worktrees). Branch-only items won't match any - // worktree path, so they fall through to Branch. - let worktree_path = repo.list_worktrees().ok().and_then(|wts| { - // Match by branch first, then fall back to detached (branch: None). - let by_branch = wts - .iter() - .find(|wt| wt.branch.as_deref() == Some(selected_output)); - let matched = by_branch.or_else(|| wts.iter().find(|wt| wt.branch.is_none())); - matched.map(|wt| wt.path.clone()) - }); - let result = { let config = repo.user_config(); - let target = match &worktree_path { - Some(path) => RemoveTarget::Path(path), - None => RemoveTarget::Branch(selected_output), + let remove_target = match target { + PickerRemovalTarget::WorktreePath(path) => RemoveTarget::Path(path), + PickerRemovalTarget::Branch(branch) => RemoveTarget::Branch(branch), }; repo.prepare_worktree_removal( - target, + remove_target, BranchDeletionMode::SafeDelete, false, config, @@ -288,15 +329,19 @@ impl CommandCollector for PickerCollector { _cmd: &str, components_to_stop: Arc, ) -> (SkimItemReceiver, Sender) { - // Read the removal signal (item output text written by execute-silent) + // Read the removal signal (item output token written by execute-silent) if let Ok(signal) = std::fs::read_to_string(&self.signal_path) { let selected_output = signal.trim().to_string(); - if !selected_output.is_empty() { - let preparation = self.prepare_removal(&selected_output); + if let Some(removal_target) = PickerRemovalTarget::from_signal(&selected_output) { + let preparation = self.prepare_removal(&removal_target); match preparation { Ok((planning_repo, result)) => { - // Removal validated — remove item from the picker list. + // Removal validated — remove the selected item from the + // picker list. The `output()` token is unique per row + // (a `worktree-path:` path for worktrees), so this + // drops exactly the selected row even when several + // detached rows share the `(detached)` branch label. // // Note: skim's `as_any().downcast_ref::()` fails // at runtime (TypeId mismatch between reader thread and main thread @@ -780,21 +825,12 @@ pub fn handle_picker( let should_create = matches!(action, PickerAction::Create); - // Get branch name: from query if creating new, from selected item if switching. - // For detached worktrees, use the path (same as `wt switch /path` from CLI). + // Get the switch identifier: the query if creating new, otherwise the + // selected item. `picker_item_identifier` yields a worktree path for + // any worktree-backed row and the branch name for a branch-only row + // (same as `wt switch` from CLI) — never the raw `worktree-path:` token. let selected = out.selected_items.first(); - let selected_name = selected.map(|item| { - if !should_create - && let Some(data) = item - .as_any() - .downcast_ref::() - .and_then(|s| s.item.worktree_data()) - .filter(|d| d.detached) - { - return data.path.to_string_lossy().into_owned(); - } - item.output().to_string() - }); + let selected_name = selected.map(|item| picker_item_identifier(item.as_ref())); let query = out.query.trim().to_string(); let identifier = resolve_identifier(&action, query, selected_name)?; @@ -871,11 +907,21 @@ fn resolve_identifier( #[cfg(test)] pub mod tests { + use super::items::WorktreeSkimItem; use super::preview::{PreviewLayout, PreviewMode, PreviewStateData}; - use super::{PickerAction, PickerCollector, drain_stashed_warnings, resolve_identifier}; + use super::{ + PickerAction, PickerCollector, PickerRemovalTarget, drain_stashed_warnings, + picker_item_identifier, resolve_identifier, + }; + use crate::commands::list::model::{ItemKind, ListItem, WorktreeData}; use crate::commands::worktree::RemoveResult; + use skim::prelude::SkimItem; + use skim::reader::CommandCollector; use std::fs; + use std::path::Path; + use std::sync::atomic::AtomicUsize; use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; use worktrunk::config::Approvals; use worktrunk::git::BranchDeletionMode; @@ -966,6 +1012,46 @@ pub mod tests { assert!(result.unwrap_err().to_string().contains("no branch name")); } + /// `from_signal` rejects tokens that carry no usable target: a blank or + /// whitespace-only signal, and a bare `worktree-path:` prefix with no path + /// after it. A non-empty branch token and a prefixed path both parse. + #[test] + fn test_picker_removal_target_from_signal() { + assert!(PickerRemovalTarget::from_signal("").is_none()); + assert!(PickerRemovalTarget::from_signal(" ").is_none()); + assert!(PickerRemovalTarget::from_signal("worktree-path:").is_none()); + + assert!(matches!( + PickerRemovalTarget::from_signal("feature/foo"), + Some(PickerRemovalTarget::Branch(branch)) if branch == "feature/foo" + )); + assert!(matches!( + PickerRemovalTarget::from_signal("worktree-path:/tmp/wt"), + Some(PickerRemovalTarget::WorktreePath(path)) if path == std::path::Path::new("/tmp/wt") + )); + } + + /// `picker_item_identifier` yields the worktree path for every + /// worktree-backed row — branched as well as detached — and the branch name + /// for a branch-only row, matching what each row's `output()` token carries. + #[test] + fn test_picker_item_identifier() { + let branched = branched_picker_item("feature/foo", Path::new("/tmp/wt-branched")); + assert_eq!( + picker_item_identifier(branched.as_ref()), + "/tmp/wt-branched" + ); + + let detached = detached_picker_item(Path::new("/tmp/wt-detached")); + assert_eq!( + picker_item_identifier(detached.as_ref()), + "/tmp/wt-detached" + ); + + let branch_only = branch_only_picker_item("feature/bar"); + assert_eq!(picker_item_identifier(branch_only.as_ref()), "feature/bar"); + } + #[test] fn test_do_removal_removes_worktree_and_branch() { let test = worktrunk::testing::TestRepo::with_initial_commit(); @@ -1096,88 +1182,15 @@ pub mod tests { assert!(!wt_path.exists(), "detached worktree should be removed"); } - #[test] - fn test_prepare_removal_refreshes_stale_detached_worktree_cache() { - let test = worktrunk::testing::TestRepo::with_initial_commit(); - let repo = worktrunk::git::Repository::at(test.path()).unwrap(); - let wt_dir = tempfile::tempdir().unwrap(); - let first_path = wt_dir.path().join("detached-one"); - let second_path = wt_dir.path().join("detached-two"); - - for (branch, path) in [ - ("to-detach-one", first_path.as_path()), - ("to-detach-two", second_path.as_path()), - ] { - repo.run_command(&["worktree", "add", "-b", branch, path.to_str().unwrap()]) - .unwrap(); - worktrunk::shell_exec::Cmd::new("git") - .args(["checkout", "--detach", "HEAD"]) - .current_dir(path) - .run() - .unwrap(); - } - - let stale_repo = worktrunk::git::Repository::at(test.path()).unwrap(); - let detached_count = stale_repo - .list_worktrees() - .unwrap() - .iter() - .filter(|wt| wt.branch.is_none()) - .count(); - assert_eq!(detached_count, 2); - - let first_result = RemoveResult::RemovedWorktree { - main_path: test.path().to_path_buf(), - worktree_path: first_path.clone(), - changed_directory: false, - branch_name: None, - deletion_mode: BranchDeletionMode::SafeDelete, - target_branch: Some("main".to_string()), - integration_reason: None, - force_worktree: false, - expected_path: None, - removed_commit: None, - }; - PickerCollector::do_removal(&stale_repo, &first_result, &Approvals::default()).unwrap(); - assert!( - !first_path.exists(), - "first detached worktree should be removed" - ); - assert!( - second_path.exists(), - "second detached worktree should remain" - ); - - let state_dir = tempfile::tempdir().unwrap(); - let collector = PickerCollector { - items: Arc::new(Mutex::new(Vec::new())), - signal_path: state_dir.path().join("remove"), - repo: stale_repo, - approvals: Arc::new(Approvals::default()), - }; - - let (_planning_repo, result) = collector.prepare_removal("(detached)").unwrap(); - let canonical_second = dunce::canonicalize(&second_path).unwrap(); - assert!( - matches!( - &result, - RemoveResult::RemovedWorktree { worktree_path, branch_name: None, .. } - if dunce::canonicalize(worktree_path).unwrap() == canonical_second - ), - "detached picker removal should resolve to the surviving detached worktree" - ); - } - - /// A branch with no worktree resolves to `RemoveTarget::Branch` — it - /// matches no worktree path, so `prepare_removal` falls through to the - /// branch-only disposition rather than a worktree removal. + /// A branch-only row's signal carries the bare branch name, which + /// `PickerRemovalTarget::from_signal` decodes to `Branch`; `prepare_removal` + /// then resolves it to the branch-only disposition. #[test] fn test_prepare_removal_resolves_branch_only_item() { let test = worktrunk::testing::TestRepo::with_initial_commit(); let repo = worktrunk::git::Repository::at(test.path()).unwrap(); - // A branch at the same commit as main, with no worktree. There are no - // detached worktrees, so the detached fallback can't capture it. + // A branch at the same commit as main, with no worktree. repo.run_command(&["branch", "branch-only-feature"]) .unwrap(); @@ -1189,7 +1202,8 @@ pub mod tests { approvals: Arc::new(Approvals::default()), }; - let (_planning_repo, result) = collector.prepare_removal("branch-only-feature").unwrap(); + let target = PickerRemovalTarget::from_signal("branch-only-feature").unwrap(); + let (_planning_repo, result) = collector.prepare_removal(&target).unwrap(); assert!( matches!(&result, RemoveResult::BranchOnly { branch_name, .. } if branch_name == "branch-only-feature"), "a branch with no worktree should resolve to BranchOnly" @@ -1214,8 +1228,9 @@ pub mod tests { // `RemoveResult` isn't `Debug`; drop the Ok payload so `unwrap_err` // (which needs `T: Debug`) can report a failure cleanly. + let target = PickerRemovalTarget::from_signal("no-such-branch").unwrap(); let err = collector - .prepare_removal("no-such-branch") + .prepare_removal(&target) .map(|_| ()) .expect_err("unknown removal target should fail validation"); assert!( @@ -1274,4 +1289,138 @@ pub mod tests { assert!(!wt_path.exists(), "worktree should be removed"); assert!(!marker.exists(), "unapproved pre-remove hook must not run"); } + + /// Build a `WorktreeSkimItem` from a snapshot `ListItem`. + fn picker_item(branch_name: &str, item: ListItem) -> Arc { + Arc::new(WorktreeSkimItem { + search_text: branch_name.to_string(), + rendered: Arc::new(Mutex::new(String::new())), + branch_name: branch_name.to_string(), + item: Arc::new(item), + preview_cache: Arc::new(dashmap::DashMap::new()), + }) as Arc + } + + /// Build a `WorktreeSkimItem` standing in for a detached-worktree row. + fn detached_picker_item(path: &Path) -> Arc { + let mut item = ListItem::new_branch("abc123".to_string(), "(detached)".to_string()); + item.branch = None; + item.kind = ItemKind::Worktree(Box::new(WorktreeData { + path: path.to_path_buf(), + detached: true, + ..Default::default() + })); + picker_item("(detached)", item) + } + + /// Build a `WorktreeSkimItem` standing in for a branched-worktree row. + fn branched_picker_item(branch: &str, path: &Path) -> Arc { + let mut item = ListItem::new_branch("abc123".to_string(), branch.to_string()); + item.kind = ItemKind::Worktree(Box::new(WorktreeData { + path: path.to_path_buf(), + ..Default::default() + })); + picker_item(branch, item) + } + + /// Build a `WorktreeSkimItem` standing in for a branch-only row (no worktree). + fn branch_only_picker_item(branch: &str) -> Arc { + picker_item( + branch, + ListItem::new_branch("abc123".to_string(), branch.to_string()), + ) + } + + /// Two detached worktrees both render the branch label `(detached)`, but + /// each row's `output()` token carries its unique path. alt-r on the + /// second row must remove exactly that worktree — not the first detached + /// one a branch-name match would resolve to — and drop only its row. + #[test] + fn test_invoke_removes_selected_detached_worktree_by_path_token() { + let test = worktrunk::testing::TestRepo::with_initial_commit(); + let repo = worktrunk::git::Repository::at(test.path()).unwrap(); + let wt_dir = tempfile::tempdir().unwrap(); + let first_path = wt_dir.path().join("detached-one"); + let second_path = wt_dir.path().join("detached-two"); + + for (branch, path) in [ + ("to-detach-one", first_path.as_path()), + ("to-detach-two", second_path.as_path()), + ] { + repo.run_command(&["worktree", "add", "-b", branch, path.to_str().unwrap()]) + .unwrap(); + worktrunk::shell_exec::Cmd::new("git") + .args(["checkout", "--detach", "HEAD"]) + .current_dir(path) + .run() + .unwrap(); + } + + let reported_paths: Vec<_> = repo + .list_worktrees() + .unwrap() + .iter() + .filter(|wt| wt.branch.is_none()) + .map(|wt| wt.path.clone()) + .collect(); + let first_reported = reported_paths + .iter() + .find(|path| path.file_name().is_some_and(|name| name == "detached-one")) + .unwrap(); + let second_reported = reported_paths + .iter() + .find(|path| path.file_name().is_some_and(|name| name == "detached-two")) + .unwrap(); + + let first_item = detached_picker_item(first_reported); + let second_item = detached_picker_item(second_reported); + let first_output = first_item.output().to_string(); + let second_output = second_item.output().to_string(); + assert_ne!(first_output, second_output); + assert_eq!( + picker_item_identifier(second_item.as_ref()), + second_reported.to_string_lossy() + ); + + let signal_dir = tempfile::tempdir().unwrap(); + let signal_path = signal_dir.path().join("remove-signal"); + fs::write(&signal_path, &second_output).unwrap(); + + let items = Arc::new(Mutex::new(vec![ + Arc::clone(&first_item), + Arc::clone(&second_item), + ])); + let mut collector = PickerCollector { + items: Arc::clone(&items), + signal_path, + repo: repo.clone(), + approvals: Arc::new(Approvals::default()), + }; + + let (_rx, _interrupt) = collector.invoke("remove", Arc::new(AtomicUsize::new(0))); + + let remaining: Vec<_> = items + .lock() + .unwrap() + .iter() + .map(|item| item.output().to_string()) + .collect(); + assert_eq!(remaining, vec![first_output]); + + let deadline = Instant::now() + Duration::from_secs(5); + while second_path.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(first_path.exists(), "first detached worktree should remain"); + assert!( + !second_path.exists(), + "selected detached worktree should be removed" + ); + } + + // Note: skim's `as_any().downcast_ref::()` fails at + // runtime due to TypeId mismatch between skim's reader thread and the main + // compilation unit (skim 0.20 bug). The invoke() code path uses output() + // matching instead. Full TUI tests require interactive skim — verified + // via tmux-cli during development. } From a756bfbec3d5b490f1fa3cf193c70b15401fe196 Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Fri, 22 May 2026 08:12:05 -0700 Subject: [PATCH 26/34] Release v0.53.0 (#2876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release v0.53.0 — version bump and changelog for the 48 commits since v0.52.0. Highlights: - **`wt switch --execute`** enters the warn phase of its move to an argv input model — a shell command line as the `-x` value now warns, with a copy-pasteable migration. - **`wt config show`** reports the project identifier (the key for `[projects."…"]`); new Gemini CLI extension detection. - **Hooks resolve project config from the invoking worktree** — an uncommitted or branch-local `.config/wt.toml` now fires creation hooks. - Correctness and data-safety fixes: atomic no-overwrite `--clobber` backups, wedged/orphaned fsmonitor daemon reaping, squash-merge detection immune to `diff.*` git config, and config-migration data-loss fixes. Full details in the `## 0.53.0` section of `CHANGELOG.md`. `cargo-semver-checks` reports breaking library API changes (renamed `HooksConfig` fields, removed `Repository::project_config_at_ref`), so this is a minor bump per the project's pre-1.0 versioning. --- CHANGELOG.md | 48 +++++++++++++++++++++++++++++++++++++++++++----- Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4504777289..e1fc9763f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,58 @@ # Changelog -## Unreleased +## 0.53.0 ### Improved -- **`pre-create`/`post-create` accepted as aliases for the worktree-creation hooks**: `pre-create` and `post-create` now load as silent aliases for the canonical `pre-start`/`post-start` hooks, in config (top-level, `[hooks.*]`, and per-project sections, in string, table, and array-of-tables form) and on the `wt hook` command line. The fatal load error for pre-0.32.0 `post-create` configs is removed — `post-create` is now valid TOML and loads as the background creation hook (formerly named `post-start`). The semantic flip to watch: before v0.32.0 `post-create` named a _blocking_ creation hook; it now names the _background_ one. A pre-0.32.0 `post-create` config has been a fatal load error since v0.44.0, so any such config was forced to migrate well before the name was reclaimed. Docs continue to recommend `pre-start`/`post-start` while users update; the canonical form may switch in a later release. Full plan: [#2838](https://github.com/max-sixty/worktrunk/issues/2838). +- **`wt switch --execute` deprecates shell command lines**: A future release will switch `--execute` (`-x`) to an argv input model — a single program, with arguments after `--`, run with no implicit shell. This release is the warn phase: `-x` now warns when its value is a shell command line, multiple words, or template markup, and the hint shows a copy-pasteable migration (`--execute sh -- -c '…'`) plus a link to comment on the cutover if the new form would regress a workflow. A single program name stays silent. ([#2852](https://github.com/max-sixty/worktrunk/pull/2852), [#2863](https://github.com/max-sixty/worktrunk/pull/2863)) + +- **`wt config show` reports the project identifier**: The PROJECT CONFIG section now prints the project identifier (`//` from the primary remote, or the canonical repo path), so you can find the key for a `[projects."…"]` block in your user config without deriving it by hand. `wt config show --format=json` gains a matching `identifier` field. Closes [#2826](https://github.com/max-sixty/worktrunk/issues/2826). ([#2827](https://github.com/max-sixty/worktrunk/pull/2827), thanks @airtonix for the request) + +- **Gemini CLI extension detection**: `wt config show` now renders a GEMINI CLI section reporting whether the worktrunk Gemini extension is installed. The agent-integration docs gained install instructions for OpenCode and Gemini CLI alongside Claude Code and Codex. ([#2819](https://github.com/max-sixty/worktrunk/pull/2819)) + +- **`pre-create`/`post-create` hook aliases**: The worktree-creation hooks `pre-start`/`post-start` now also accept `pre-create`/`post-create` as silent aliases — in config (top-level, `[hooks.*]`, and per-project sections, in string, table, and array-of-tables form) and on the `wt hook` command line. Docs continue to recommend `pre-start`/`post-start`; the canonical names may switch in a later release. Full plan: [#2838](https://github.com/max-sixty/worktrunk/issues/2838). ([#2840](https://github.com/max-sixty/worktrunk/pull/2840), [#2857](https://github.com/max-sixty/worktrunk/pull/2857)) ### Fixed +- **Hooks resolve project config from the invoking worktree**: Worktrunk resolved each hook's `.config/wt.toml` from a different worktree depending on the hook, and `wt switch --create` read the base ref's _committed_ config via `git show` — so an uncommitted or branch-local `.config/wt.toml` silently failed to fire creation hooks, and `wt config show` disagreed with what actually ran. Every hook now resolves its commands from the `.config/wt.toml` of the worktree `wt` ran in — the same file `wt config show` displays. In the common case of a committed, repo-wide config this is unchanged; it diverges only when a branch carries its own working-tree edits. Fixes [#2856](https://github.com/max-sixty/worktrunk/issues/2856) and [#2818](https://github.com/max-sixty/worktrunk/issues/2818). ([#2873](https://github.com/max-sixty/worktrunk/pull/2873), thanks @Oxygen66 and @sirianni for reporting) + - **Picker prompts for approval before running project `pre-switch` hooks**: Selecting a worktree in the interactive picker (`wt switch` with no argument) ran a project-defined `pre-switch` hook from `.config/wt.toml` without the approval prompt that gates every other hook — unapproved code from a freshly cloned repo executing silently. The picker now routes `pre-switch` hooks through the same approval gate as `wt switch ` and as its own `post-switch`/`pre-start`/`post-start` hooks. ([#2858](https://github.com/max-sixty/worktrunk/pull/2858)) -- **Interactive picker switches with `cd = false`**: With `[switch] cd = false` (or `wt switch --no-cd`), opening the picker (`wt switch` with no branch argument) and selecting a worktree printed the branch name and exited — no switch, no hooks, and `Alt-c` created nothing. The picker now runs the same switch pipeline as `wt switch `, suppressing only the cd directive: `pre-switch`/`post-switch` hooks fire and `Alt-c` creates the worktree. `--format=json` works in the picker too, and replaces the old print-only output for scripting — it both switches and prints a structured result (`action`, `branch`, `path`) to stdout. ([#2837](https://github.com/max-sixty/worktrunk/issues/2837)) +- **Interactive picker switches with `cd = false`**: With `[switch] cd = false` (or `wt switch --no-cd`), opening the picker (`wt switch` with no branch argument) and selecting a worktree printed the branch name and exited — no switch, no hooks, and `Alt-c` created nothing. The picker now runs the same switch pipeline as `wt switch `, suppressing only the cd directive: `pre-switch`/`post-switch` hooks fire and `Alt-c` creates the worktree. `--format=json` works in the picker too, and replaces the old print-only output for scripting — it both switches and prints a structured result (`action`, `branch`, `path`) to stdout. ([#2845](https://github.com/max-sixty/worktrunk/pull/2845), thanks @endigma for the discussion in [#2837](https://github.com/max-sixty/worktrunk/issues/2837)) + +- **`alt-r` in the picker removes the right worktree**: The interactive picker identified each row by branch name for its `alt-r` removal signal; detached worktrees all report `(detached)`, so two detached rows collided and `alt-r` could remove the wrong worktree. Rows backed by a worktree now carry a unique path-based identity. ([#2866](https://github.com/max-sixty/worktrunk/pull/2866)) + +- **`--clobber` backs up blocked paths atomically**: `wt switch --clobber` and `wt step relocate --clobber` back up a path blocking the target before clobbering it. Both used an `exists()` check followed by `std::fs::rename`, which silently overwrites an existing destination — a time-of-check/time-of-use race that could destroy a just-created backup. They now share one helper that moves the blocker with an atomic no-overwrite rename and counts up through `-2`, `-3`, … suffixes on a name collision instead of failing. (`wt step relocate`'s backup name changes from `.bak-` to the extension-aware `.bak.` form.) ([#2849](https://github.com/max-sixty/worktrunk/pull/2849), [#2865](https://github.com/max-sixty/worktrunk/pull/2865)) + +- **Squash-merge detection ignores `diff.*` git config**: Worktrunk's squash-merge integration check compared `git patch-id` hashes computed from two different diff generators — one plumbing (ignores `diff.*` config), one porcelain (honors it). For anyone with a non-default `diff.context` or `diff.algorithm`, the two never agreed, so a genuinely squash-merged branch was reported as not integrated — breaking `wt remove` ("Branch unmerged"), the `wt list` integration symbol, and `wt step prune`. Both sides now use plumbing, immune to every `diff.*` setting. ([#2821](https://github.com/max-sixty/worktrunk/pull/2821)) + +- **Wedged and orphaned fsmonitor daemons are reaped**: With `core.fsmonitor=true`, git runs a per-worktree `git fsmonitor--daemon`; a wedged one stops answering its IPC socket — which hangs `git status` and `wt list` — and ignores the `stop` request `wt remove` sends, so it leaks once its worktree is gone (dozens can accumulate). `wt remove` now resolves the daemon's PID from its IPC socket and force-terminates it (SIGTERM, brief wait, SIGKILL) when `stop` doesn't take, and its background internal sweep additionally reaps any daemon whose socket no longer resolves to a live worktree — covering daemons orphaned by `git worktree remove`, a manual `rm`, or a crashed `wt`. A daemon serving a live worktree is never reaped. ([#2813](https://github.com/max-sixty/worktrunk/pull/2813), [#2814](https://github.com/max-sixty/worktrunk/pull/2814)) + +- **Config migration no longer silently drops deprecated config**: A deprecated section (`[commit-generation]`, `[select]`, …) was discarded without writing its canonical replacement when the canonical key already existed as a scalar or an inline-table value — real data loss, now fixed for both shapes. Deprecated template variables are rewritten only inside `{{ }}`/`{% %}` tags, so literal command text and `{% set %}` locals are left intact. System config now passes through the same deprecation-warning gate as user config. ([#2788](https://github.com/max-sixty/worktrunk/pull/2788), [#2851](https://github.com/max-sixty/worktrunk/pull/2851)) + +- **Hook filtering and the command-approval store are hardened**: `--only project:deploy user:lint` matched filter names across the project/user split, so a name given for one source could select an unintended hook from the other; the approval gate and executor now share one source-scoped predicate. Template variables are detected by parsing the template rather than substring matching (`{{ vars["env"] }}` and bare `{{ vars }}` were missed), and an undefined variable in a `{% if %}` predicate is now a clear error instead of being silently ignored. The approvals trust store is written atomically, rejects unknown keys instead of silently dropping approvals, and its migration is locked and validated before it runs. ([#2841](https://github.com/max-sixty/worktrunk/pull/2841)) + +- **`wt` no longer panics on non-UTF-8 arguments**, and `--format` passed to a config-state write action now reports the conflict through normal error handling instead of exiting before diagnostics and output run. ([#2788](https://github.com/max-sixty/worktrunk/pull/2788)) + +- **Picker, `wt switch`, and statusline correctness**: The picker now plans each `alt-r` removal against fresh repository state rather than a cache left stale by the previous removal. `wt switch` prefers an exact local branch over stripping a remote prefix (a local branch literally named `origin/foo` was retargeted), and fails closed on a malformed `forge.platform` instead of silently falling back to GitHub. A single-row statusline skips the repo-wide ahead/behind scan, a speedup on large repositories. ([#2842](https://github.com/max-sixty/worktrunk/pull/2842)) + +- **Shell-correct escaping for the `--execute` payload**: `wt switch -x` builds its payload as a shell-escaped string evaluated by the active shell wrapper. POSIX single-quote escaping was applied unconditionally, but PowerShell (`Invoke-Expression`) and fish (`eval`) don't share POSIX quoting — under fish a backslash in the payload was silently dropped and a trailing backslash aborted evaluation, and under PowerShell the `'\''` idiom is invalid. Escaping now keys on the active directive shell. Separately, every other `shell_escape` call site is pinned to POSIX escaping rather than the crate's platform-sensitive entry point, which on Windows could pick cmd-style quoting that mis-escapes arguments spliced into a POSIX shell. ([#2843](https://github.com/max-sixty/worktrunk/pull/2843), [#2815](https://github.com/max-sixty/worktrunk/pull/2815)) + +- **`wt hook show` lists per-project user hooks**: `wt hook show` displayed only global user hooks, omitting per-project hooks defined under `[projects."…"]` in the user config; it now merges both, matching what actually runs. ([#2844](https://github.com/max-sixty/worktrunk/pull/2844)) + +- **Statusline reserves a fixed margin instead of 20% of width**: When `wt list statusline` runs as a Claude Code subprocess it can't detect the terminal directly and walks the process tree for a TTY; that fallback reserved 20% of the detected width for Claude Code's own UI, giving up 40 columns on a 200-column terminal. It now reserves a fixed 5 columns. ([#2871](https://github.com/max-sixty/worktrunk/pull/2871)) + +- **User-output consistency**: An audit against the project's output conventions corrected six messages — state-acknowledging messages ("All shells already configured", the version-check "Up to date") use the info marker rather than success; the `wt step relocate` summary keys its message type on whether anything was relocated; "Diagnostic saved" reports as a success with the `@`-path convention; and a stray trailing period and a cross-message pronoun were removed. ([#2867](https://github.com/max-sixty/worktrunk/pull/2867)) + +- **Repo-wide internal hook logs are written as top-level files**: Branch-agnostic internal-operation logs were written into a top-level `internal/` directory, which `wt config state` then misclassified as a branch; they now write to `internal-{op}.log` files alongside the other shared logs. ([#2851](https://github.com/max-sixty/worktrunk/pull/2851)) + +- **Nix flake includes `gemini-extension.json`**: The flake's source filter omitted `gemini-extension.json`, so a Nix build produced a package missing the Gemini CLI extension manifest. ([#2834](https://github.com/max-sixty/worktrunk/pull/2834)) + +### Documentation -- **`wt remove` reaps a wedged fsmonitor daemon**: When `core.fsmonitor=true`, git runs a per-worktree `git fsmonitor--daemon`. Removal already sent `git fsmonitor--daemon stop`, but `stop` is an IPC request to the daemon itself, so a daemon that had stopped answering its socket ignored it and then leaked forever once its worktree was gone (dozens could accumulate, and one wedged daemon hangs `wt list`). Removal now resolves the daemon's PID from its IPC socket and force-terminates it (SIGTERM, brief wait, SIGKILL) when `stop` doesn't take. The signal only ever targets the daemon whose socket resolves to the worktree being removed. +- **`wt remove --force` help and FAQ**: Both said `--force` overrides the untracked-files check "for build artifacts"; `--force` actually discards staged and modified _tracked_ files too. The help text and FAQ now state that `--force` discards staged, modified, and untracked files. ([#2869](https://github.com/max-sixty/worktrunk/pull/2869)) -- **Orphaned fsmonitor daemons are reaped**: `wt remove`'s background internal sweep now terminates (`SIGTERM`, then `SIGKILL` after a short bounded wait) any `git fsmonitor--daemon` whose IPC socket no longer resolves to a live worktree. This reclaims daemons orphaned by paths that bypass `wt remove`'s synchronous daemon stop (plain `git worktree remove`, manual `rm -rf`, or a crashed `wt`), which otherwise accumulate until reboot and can hang `wt list` when wedged. A daemon serving a live worktree is never reaped. +- **cmux recipe**: Re-added a verified cmux integration recipe to Tips & Patterns. ([#2836](https://github.com/max-sixty/worktrunk/pull/2836), thanks @endigma for the verified config) ## 0.52.0 diff --git a/Cargo.lock b/Cargo.lock index 0d90a12e12..907d69b07c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3181,7 +3181,7 @@ checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "worktrunk" -version = "0.52.0" +version = "0.53.0" dependencies = [ "ansi-str", "ansi-to-html", diff --git a/Cargo.toml b/Cargo.toml index b858594a57..ab9f03fdbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ default-members = [".", "tests/helpers/mock-stub", "tests/helpers/wt-perf"] [package] name = "worktrunk" -version = "0.52.0" +version = "0.53.0" edition = "2024" rust-version = "1.94" build = "build.rs" From effb72cfbb4bc2da5ab6486baeaafe138b17f76c Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Fri, 22 May 2026 08:16:47 -0700 Subject: [PATCH 27/34] test(hooks): exercise the pre-create alias direction post-revert (#2877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #2857. That PR reverted the docs half of the `pre-start`/`post-start` → `pre-create`/`post-create` hook rename while keeping the code that accepts both names. Its self-review landed 26 seconds after the merge, so none of its findings were addressed — several tests still described the pre-revert direction and exercised only canonical names, so they would pass even if the deprecated alias broke. ## Changes The three deprecated-alias tests now use `pre-create`/`post-create` in their fixtures (or invoke `wt hook pre-create`), so they exercise the migration and the CLI alias rather than canonical names: `test_hook_show_accepts_deprecated_create_hooks`, `test_deprecated_create_hook_key_runs_silently`, `test_standalone_hook_create_alias_runs_silently`. `test_parse_hook_type_aliases` had the same defect — it parsed only the canonical names — and now parses both forms and asserts they map to the same `HookType`. Added unit tests for `migrate_create_hooks_doc` (every value shape, per-project tables, skip-when-canonical-exists, invalid TOML) plus a migration-diff snapshot; the revert removed the `migrate_start_hooks_doc` tests with no equivalent. Deleted `Deprecations::pre_start`/`post_start` — always-false dead fields once the revert removed their detection. Deleted `test_config_show_displays_start_hook_migration`: post-revert there is no detection for `pre-create`, so `wt config show` shows no migration diff and the test verified nothing (its regenerated snapshot confirmed this). Also corrected reversed-direction comments in `deprecation.rs` and `hook_commands.rs`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- src/cli/hook.rs | 22 ++-- src/commands/hook_commands.rs | 2 +- src/config/deprecation.rs | 102 +++++++++++++++--- ...sts__snapshot_migrate_create_to_start.snap | 10 ++ tests/integration_tests/config_show.rs | 66 ++++-------- .../integration_tests/post_start_commands.rs | 20 ++-- tests/integration_tests/user_hooks.rs | 20 ++-- ...ig_show_displays_start_hook_migration.snap | 75 ------------- 8 files changed, 155 insertions(+), 162 deletions(-) create mode 100644 src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_create_to_start.snap delete mode 100644 tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap diff --git a/src/cli/hook.rs b/src/cli/hook.rs index 500a4bb482..d115bd3931 100644 --- a/src/cli/hook.rs +++ b/src/cli/hook.rs @@ -459,12 +459,22 @@ mod tests { #[test] fn test_parse_hook_type_aliases() { - // `pre-start`/`post-start` are deprecated aliases for - // `pre-create`/`post-create`. - let opts = parse(&["pre-start"]).unwrap(); - assert_eq!(opts.hook_type, HookType::PreCreate); - let opts = parse(&["post-start"]).unwrap(); - assert_eq!(opts.hook_type, HookType::PostCreate); + // `pre-create`/`post-create` are deprecated aliases for the canonical + // `pre-start`/`post-start`; both forms parse to the same `HookType`. + for name in ["pre-start", "pre-create"] { + assert_eq!( + parse(&[name]).unwrap().hook_type, + HookType::PreCreate, + "{name}" + ); + } + for name in ["post-start", "post-create"] { + assert_eq!( + parse(&[name]).unwrap().hook_type, + HookType::PostCreate, + "{name}" + ); + } } #[test] diff --git a/src/commands/hook_commands.rs b/src/commands/hook_commands.rs index 1ed70bdd52..453e32baa5 100644 --- a/src/commands/hook_commands.rs +++ b/src/commands/hook_commands.rs @@ -382,7 +382,7 @@ pub fn handle_hook_show( let project_id = repo.project_identifier().ok(); // Parse hook type filter if provided. clap's value parser already - // validated the string (canonical name or deprecated `-start` alias); + // validated the string (canonical name or deprecated `-create` alias); // `parse_hook_type` maps both to the canonical `HookType`. let filter: Option = hook_type_filter .map(crate::cli::parse_hook_type) diff --git a/src/config/deprecation.rs b/src/config/deprecation.rs index 4ad33c7bc9..fb726adaad 100644 --- a/src/config/deprecation.rs +++ b/src/config/deprecation.rs @@ -475,10 +475,6 @@ pub struct Deprecations { pub approved_commands: bool, /// Has `[select]` section (moved to `[switch.picker]`) pub select: bool, - /// Reserved for future hook-name deprecation tracking; always false. - pub pre_start: bool, - /// Reserved for future hook-name deprecation tracking; always false. - pub post_start: bool, /// Has `[ci]` section (moved to `[forge]`) pub ci_section: bool, /// Has `no-ff` in `[merge]` section (use `ff` instead) @@ -498,8 +494,6 @@ impl Deprecations { && self.commit_gen.is_empty() && !self.approved_commands && !self.select - && !self.pre_start - && !self.post_start && !self.ci_section && !self.no_ff && !self.no_cd @@ -533,8 +527,6 @@ fn detect_deprecations_from_doc( commit_gen: find_commit_generation_from_doc(doc), approved_commands: find_approved_commands_from_doc(doc), select: find_select_from_doc(doc), - pre_start: false, - post_start: false, ci_section: find_ci_section_from_doc(doc), no_ff: find_negated_bool_from_doc(doc, "merge", "no-ff", "ff"), no_cd: find_negated_bool_from_doc(doc, "switch", "no-cd", "cd"), @@ -1569,7 +1561,7 @@ pub fn check_and_migrate( // For non-config-show commands, emit per-kind warnings but skip the diff. // The diff is reserved for `wt config show`, where the user has opted into details. // - // Some deprecations migrate silently (e.g. the `-start` → `-create` hook + // Some deprecations migrate silently (e.g. the `-create` → `-start` hook // rename): `format_deprecation_warnings` emits nothing for them. The hint is // gated on the warning text being non-empty so a silently-migrated config // produces no stray "run wt config show" line with nothing above it. @@ -3453,8 +3445,6 @@ approved-commands = ["npm install"] commit_gen: CommitGenerationDeprecations::default(), approved_commands: true, select: false, - pre_start: false, - post_start: false, ci_section: false, no_ff: false, no_cd: false, @@ -3855,8 +3845,6 @@ pager = "delta --paging=never" commit_gen: CommitGenerationDeprecations::default(), approved_commands: false, select: true, - pre_start: false, - post_start: false, ci_section: false, no_ff: false, no_cd: false, @@ -3895,6 +3883,92 @@ pager = "delta --paging=never" ); } + fn migrate_create_hooks(content: &str) -> String { + let Ok(mut doc) = content.parse::() else { + return content.to_string(); + }; + if migrate_create_hooks_doc(&mut doc) { + doc.to_string() + } else { + content.to_string() + } + } + + /// `migrate_create_hooks_doc` renames the deprecated `pre-create`/`post-create` + /// keys to canonical `pre-start`/`post-start`, preserving the value shape + /// (string, `[table]`, `[[array-of-tables]]`) at both the top level and + /// inside `[projects."..."]`. + #[test] + fn test_migrate_create_hooks_renames_every_shape() { + let content = r#"pre-create = "npm install" + +[[post-create]] +lint = "cargo clippy" + +[projects."my-project"] +pre-create = "cargo build" + +[projects."my-project".post-create] +server = "npm run dev" +"#; + let result = migrate_create_hooks(content); + assert!( + !result.contains("pre-create") && !result.contains("post-create"), + "no deprecated key may remain; got:\n{result}" + ); + assert!( + result.contains(r#"pre-start = "npm install""#), + "top-level string renamed; got:\n{result}" + ); + assert!( + result.contains("[[post-start]]"), + "top-level array-of-tables renamed; got:\n{result}" + ); + assert!( + result.contains(r#"pre-start = "cargo build""#), + "per-project string renamed; got:\n{result}" + ); + assert!( + result.contains(r#"[projects."my-project".post-start]"#), + "per-project table renamed; got:\n{result}" + ); + } + + /// When the canonical `-start` key already exists, the migrator leaves the + /// deprecated `-create` key alone rather than clobbering the user's value. + #[test] + fn test_migrate_create_hooks_skips_when_start_exists() { + let content = r#"pre-create = "old" +pre-start = "new" + +[projects."my-project"] +post-create = "old" +post-start = "new" +"#; + assert_eq!( + migrate_create_hooks(content), + content, + "must not clobber an existing canonical key" + ); + } + + #[test] + fn test_migrate_create_hooks_invalid_toml() { + let content = "this is { not valid toml"; + assert_eq!(migrate_create_hooks(content), content); + } + + #[test] + fn snapshot_migrate_create_to_start() { + let content = r#"pre-create = "npm install" + +[post-create] +server = "npm run dev" +"#; + let migrated = compute_migrated_content(content); + insta::assert_snapshot!(migration_diff(content, &migrated)); + } + fn migrate_switch_picker_timeout(content: &str) -> String { let Ok(mut doc) = content.parse::() else { return content.to_string(); @@ -4039,8 +4113,6 @@ pager = "delta" commit_gen: CommitGenerationDeprecations::default(), approved_commands: false, select: false, - pre_start: false, - post_start: false, ci_section: false, no_ff: true, no_cd: true, diff --git a/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_create_to_start.snap b/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_create_to_start.snap new file mode 100644 index 0000000000..c2b4169838 --- /dev/null +++ b/src/config/snapshots/worktrunk__config__deprecation__tests__snapshot_migrate_create_to_start.snap @@ -0,0 +1,10 @@ +--- +source: src/config/deprecation.rs +expression: "migration_diff(content, &migrated)" +--- +-pre-create = "npm install" ++pre-start = "npm install" + +-[post-create] ++[post-start] + server = "npm run dev" diff --git a/tests/integration_tests/config_show.rs b/tests/integration_tests/config_show.rs index 34ec72feff..172ae3ae1a 100644 --- a/tests/integration_tests/config_show.rs +++ b/tests/integration_tests/config_show.rs @@ -362,33 +362,34 @@ my-lint = "my-lint-tool" ); } -/// A current-worktree `.config/wt.toml` that still uses the deprecated -/// `pre-start`/`post-start` hook keys keeps working through `wt hook show`: -/// `ProjectConfig::load` migrates the keys to `pre-start`/`post-start` -/// before deserializing, the value parser accepts both the canonical type -/// argument and the deprecated alias, and Phase 1 of the rename (issue #2838) -/// migrates silently — no deprecation warning. +/// A current-worktree `.config/wt.toml` that uses the deprecated +/// `pre-create`/`post-create` hook keys keeps working through `wt hook show`: +/// `ProjectConfig::load` migrates the keys to canonical `pre-start`/`post-start` +/// before deserializing, and the type-argument value parser accepts both the +/// canonical name and the deprecated alias. The rename is paused at Phase 1 +/// (issue #2838), so the migration is silent — no deprecation warning. #[rstest] -fn test_hook_show_accepts_deprecated_start_hooks(repo: TestRepo) { +fn test_hook_show_accepts_deprecated_create_hooks(repo: TestRepo) { // Single-token commands so the bash highlighter doesn't split them with // ANSI codes (same shape as the deep-merge `hook show` tests above). repo.write_project_config( - r#"[pre-start] -deps = "pre-start-tool" + r#"[pre-create] +deps = "pre-create-tool" -[post-start] -deps = "post-start-tool" +[post-create] +deps = "post-create-tool" "#, ); - // The `-create` arg exercises the load-time migration (the `[*-start]` key - // must deserialize into the `*_create` field); the `-start` arg additionally - // exercises the value parser accepting the deprecated alias. + // The `-start` arg passes the canonical type name; the `-create` arg + // exercises the value parser accepting the deprecated alias. Both resolve + // to the same hook because `ProjectConfig::load` migrates the `[*-create]` + // config keys to canonical `[*-start]` before deserializing. let cases = [ - ("pre-start", "pre-start-tool"), - ("pre-start", "pre-start-tool"), - ("post-start", "post-start-tool"), - ("post-start", "post-start-tool"), + ("pre-start", "pre-create-tool"), + ("pre-create", "pre-create-tool"), + ("post-start", "post-create-tool"), + ("post-create", "post-create-tool"), ]; for (type_arg, expected) in cases { let mut cmd = repo.wt_command(); @@ -652,7 +653,7 @@ fn test_system_config_deprecations_pass_through_gate(repo: TestRepo) { let system_config_path = system_config_dir.path().join("config.toml"); fs::write( &system_config_path, - r#"post-start = "npm install" + r#"post-create = "npm install" [select] pager = "delta --paging=never" @@ -680,7 +681,7 @@ pager = "delta --paging=never" let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("npm install"), - "post-start in system config should migrate to post-start and stay active, got: {stdout}" + "deprecated post-create key should migrate to post-start and stay active, got: {stdout}" ); } @@ -3448,31 +3449,6 @@ pager = "delta --paging=never" }); } -/// `wt config show` reveals the silent `-start` → `-create` hook migration as a -/// diff. Phase 1 stays warning-free (see issue #2838), so the output carries no -/// "is deprecated" line — only the proposed diff and the apply hint. -#[rstest] -fn test_config_show_displays_start_hook_migration(mut repo: TestRepo, temp_home: TempDir) { - repo.setup_mock_ci_tools_unauthenticated(); - - let global_config_dir = temp_home.path().join(".config").join("worktrunk"); - fs::create_dir_all(&global_config_dir).unwrap(); - let config_path = global_config_dir.join("config.toml"); - fs::write(&config_path, "pre-start = \"npm install\"\n").unwrap(); - - let settings = setup_snapshot_settings_with_home(&repo, &temp_home); - settings.bind(|| { - let mut cmd = wt_command(); - repo.configure_wt_cmd(&mut cmd); - repo.configure_mock_commands(&mut cmd); - cmd.arg("config").arg("show").current_dir(repo.root_path()); - set_temp_home_env(&mut cmd, temp_home.path()); - set_xdg_config_path(&mut cmd, temp_home.path()); - - assert_cmd_snapshot!(cmd); - }); -} - /// `wt config show` displays deprecation details for `[merge] no-ff` → `ff` (inverted). #[rstest] fn test_config_show_displays_no_ff_deprecation(mut repo: TestRepo, temp_home: TempDir) { diff --git a/tests/integration_tests/post_start_commands.rs b/tests/integration_tests/post_start_commands.rs index a9322d6a3f..e764fe197e 100644 --- a/tests/integration_tests/post_start_commands.rs +++ b/tests/integration_tests/post_start_commands.rs @@ -60,13 +60,13 @@ approved-commands = ["echo 'Setup complete'"] snapshot_switch("post_start_single_command", &repo, &["--create", "feature"]); } -/// A config that still uses the deprecated `pre-start` hook key loads and runs: -/// the key is silently migrated to `pre-start`. Phase 1 of the rename emits no -/// warning (see https://github.com/max-sixty/worktrunk/issues/2838). +/// 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_start_hook_key_runs_silently(repo: TestRepo) { - repo.write_project_config(r#"pre-start = "echo ran > marker.txt""#); - repo.commit("Add deprecated pre-start hook"); +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"] @@ -82,15 +82,15 @@ approved-commands = ["echo ran > marker.txt"] assert!( output.status.success(), - "switch with a deprecated pre-start hook should succeed, stderr: {}", + "switch with a deprecated pre-create hook should succeed, stderr: {}", String::from_utf8_lossy(&output.stderr) ); - // The hook ran — the `pre-start` key was migrated to `pre-start`. + // 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-start hook should have run" + "deprecated pre-create hook should have run" ); // Phase 1 migrates silently: no deprecation warning, and no stray @@ -142,7 +142,7 @@ approved-commands = ["exit 1"] "#, ); - // Failing pre-start hook (via deprecated pre-start name) aborts with FailFast + // A failing pre-start hook aborts with FailFast snapshot_switch( "post_start_failing_command", &repo, diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index 18e6e323cd..ad73e7c4fb 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -166,9 +166,8 @@ failing = "exit 1" "#, ); - // Failing pre-start hook (via deprecated pre-start name) aborts with FailFast. - // The worktree is already created before pre-start runs (it was renamed from - // pre-start), so the worktree exists but the command exits non-zero. + // 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_post_start_failure", &repo, &["--create", "feature"]); // Worktree exists (created before pre-start ran) but the command failed @@ -1592,20 +1591,21 @@ fn test_standalone_hook_pre_start(repo: TestRepo) { } #[rstest] -fn test_standalone_hook_start_alias_runs_silently(repo: TestRepo) { - // `wt hook pre-start` is a deprecated alias for `wt hook pre-start`. It - // still runs, and Phase 1 of the rename emits no warning (issue #2838). - repo.write_project_config(r#"pre-start = "echo 'START_ALIAS' > hook_ran.txt""#); +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-start", "--yes"]); + cmd.args(["hook", "pre-create", "--yes"]); let output = cmd.output().unwrap(); assert!( output.status.success(), - "wt hook pre-start should succeed (alias for pre-start)" + "wt hook pre-create should succeed (alias for pre-start)" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -1617,7 +1617,7 @@ fn test_standalone_hook_start_alias_runs_silently(repo: TestRepo) { 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("START_ALIAS")); + assert!(content.contains("CREATE_ALIAS")); } #[rstest] diff --git a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap b/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap deleted file mode 100644 index 27cf558194..0000000000 --- a/tests/snapshots/integration__integration_tests__config_show__config_show_displays_start_hook_migration.snap +++ /dev/null @@ -1,75 +0,0 @@ ---- -source: tests/integration_tests/config_show.rs -info: - program: wt - args: - - config - - show - env: - APPDATA: "[TEST_CONFIG_HOME]" - CLICOLOR_FORCE: "1" - COLUMNS: "500" - GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" - GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" - GIT_COMMON_DIR: "" - GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" - GIT_CONFIG_SYSTEM: /dev/null - GIT_DIR: "" - GIT_EDITOR: "" - GIT_INDEX_FILE: "" - GIT_OBJECT_DIRECTORY: "" - GIT_TERMINAL_PROMPT: "0" - GIT_WORK_TREE: "" - HOME: "[TEST_HOME]" - LANG: C - LC_ALL: C - LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" - MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" - NO_COLOR: "" - OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" - PATH: "[PATH]" - PSModulePath: "" - RUST_LOG: warn - SHELL: "" - TERM: alacritty - USERPROFILE: "[TEST_HOME]" - WORKTRUNK_APPROVALS_PATH: "[TEST_APPROVALS]" - WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" - WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" - WORKTRUNK_TEST_BASH_INSTALLED: "0" - WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" - WORKTRUNK_TEST_CODEX_INSTALLED: "0" - WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" - WORKTRUNK_TEST_EPOCH: "1735776000" - WORKTRUNK_TEST_FISH_INSTALLED: "0" - WORKTRUNK_TEST_GEMINI_INSTALLED: "0" - WORKTRUNK_TEST_NUSHELL_ENV: "0" - WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" - WORKTRUNK_TEST_POWERSHELL_ENV: "0" - WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" - WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" - WORKTRUNK_TEST_ZSH_INSTALLED: "0" - XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" ---- -success: true -exit_code: 0 ------ stdout ----- -USER CONFIG @ ~/.config/worktrunk/config.toml -  pre-start = "npm install" -↳ Optional system config not found @ /etc/xdg/worktrunk/config.toml - -PROJECT CONFIG @ _REPO_/.config/wt.toml -○ Identifier: ../origin -↳ Not found - -SHELL INTEGRATION -▲ Shell integration not configured -↳ To configure, run wt config shell install -  Invoked as: [PROJECT_ROOT]/target/[BUILD_MODE]/wt - -OTHER -○ wt: [VERSION] -○ git: [VERSION] -○ Hyperlinks: inactive - ------ stderr ----- From 013cc5c1a7a33be1c7c6f52e073ff4c08f0a161d Mon Sep 17 00:00:00 2001 From: Maximilian Roos <5635139+max-sixty@users.noreply.github.com> Date: Sat, 23 May 2026 00:13:38 -0700 Subject: [PATCH 28/34] fix(hooks): correct pre-start docstring; rename mismatched test (#2879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small follow-ups noticed while fixing the test comments in #2877. **`pre_create` docstring was wrong.** It read "Commands to execute before worktree creation (blocking)", but `pre-start` actually runs *after* the worktree is created (`switch.rs:1142` executes it against "the new worktree (created just above)", and the renamed test below asserts the worktree exists after a failing pre-start). The wrong wording entered in #2840 as part of the mechanical `pre-start` → `pre-create` rename — someone "corrected" the docstring to match the new name. #2857 reverted the name but left the docstring. Restoring "after worktree creation (blocking, fail-fast)" matches the actual behavior, `docs/content/hook.md` ("Runs once when a new worktree is created, blocking…"), and the parallel `post_create` docstring. Because `HooksConfig` derives `JsonSchema`, this docstring is user-visible in generated schema descriptions. **`test_user_post_start_hook_failure` was misnamed.** The fixture is `[pre-start]`, the comment and assertion describe a failing pre-start, and only the function name said post-start. Renamed to `test_user_pre_start_hook_failure` along with its snapshot name and file (`git mv`, so the rename shows as a rename). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- src/config/hooks.rs | 2 +- tests/integration_tests/user_hooks.rs | 4 ++-- ...ntegration_tests__user_hooks__user_pre_start_failure.snap} | 0 3 files changed, 3 insertions(+), 3 deletions(-) rename tests/snapshots/{integration__integration_tests__user_hooks__user_post_start_failure.snap => integration__integration_tests__user_hooks__user_pre_start_failure.snap} (100%) diff --git a/src/config/hooks.rs b/src/config/hooks.rs index 44ed7a04e8..dda57b0780 100644 --- a/src/config/hooks.rs +++ b/src/config/hooks.rs @@ -24,7 +24,7 @@ pub struct HooksConfig { )] pub post_switch: Option, - /// Commands to execute before worktree creation (blocking) + /// Commands to execute after worktree creation (blocking, fail-fast) #[serde( default, rename = "pre-start", diff --git a/tests/integration_tests/user_hooks.rs b/tests/integration_tests/user_hooks.rs index ad73e7c4fb..090b5b7245 100644 --- a/tests/integration_tests/user_hooks.rs +++ b/tests/integration_tests/user_hooks.rs @@ -158,7 +158,7 @@ approved-commands = ["echo 'PROJECT_HOOK' > project_marker.txt"] } #[rstest] -fn test_user_post_start_hook_failure(repo: TestRepo) { +fn test_user_pre_start_hook_failure(repo: TestRepo) { // Write user config with failing hook repo.write_test_config( r#"[pre-start] @@ -168,7 +168,7 @@ 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_post_start_failure", &repo, &["--create", "feature"]); + 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"); diff --git a/tests/snapshots/integration__integration_tests__user_hooks__user_post_start_failure.snap b/tests/snapshots/integration__integration_tests__user_hooks__user_pre_start_failure.snap similarity index 100% rename from tests/snapshots/integration__integration_tests__user_hooks__user_post_start_failure.snap rename to tests/snapshots/integration__integration_tests__user_hooks__user_pre_start_failure.snap From ff118b3f2ae92fc70858338f9c20ccfa9964952f Mon Sep 17 00:00:00 2001 From: Worktrunk Bot Date: Sat, 23 May 2026 00:56:01 -0700 Subject: [PATCH 29/34] chore(output): emit deprecation warning when only legacy directive var is set (#2880) --- src/output/global.rs | 45 +++++++++++++------ ...ectives__switch_legacy_directive_file.snap | 14 ++++++ ...es__switch_legacy_directive_file_fish.snap | 2 + ...itch_legacy_directive_file_powershell.snap | 14 ++++++ ...ch_legacy_directive_file_with_execute.snap | 2 + 5 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/output/global.rs b/src/output/global.rs index 75e94010ba..31eab7b014 100644 --- a/src/output/global.rs +++ b/src/output/global.rs @@ -41,7 +41,8 @@ //! Users who upgrade wt without restarting their shell still run the previous //! release's shell wrapper, which only sets `WORKTRUNK_DIRECTIVE_FILE`. When //! only that variable is set, wt falls back to the pre-split protocol (shell -//! commands written to the single file) silently. For bash, zsh, fish, and +//! commands written to the single file) and emits a one-shot deprecation +//! warning hinting at `wt config shell install`. For bash, zsh, fish, and //! PowerShell a shell restart picks up the new wrapper automatically; nushell //! is the only shell where users have to rerun `wt config shell install` //! because its wrapper is a static file. Remove the legacy path in the next @@ -270,23 +271,41 @@ fn compute_directive_mode() -> DirectiveMode { exec_file: exec, }, None => match legacy { - // Silent fallback: bash/zsh/fish/PowerShell self-update on restart, - // and nushell is the only shell that needs a manual reinstall. A - // global "your wrapper is old" warning would hit everyone else with - // noise they can't avoid until their next terminal restart. - // - // TODO(2026-05): emit a deprecation warning here. By then the - // self-healing shells (bash/zsh/fish/PowerShell) have had a - // release to cycle, so anything still hitting this branch is - // almost certainly an outdated nushell wrapper whose user needs - // to rerun `wt config shell install nu` before the legacy - // fallback is removed in the following release. - Some(file) => DirectiveMode::Legacy { file }, + // Hitting this branch means the active shell wrapper still sets + // only `WORKTRUNK_DIRECTIVE_FILE` (pre-split protocol). bash, zsh, + // fish, and PowerShell wrappers self-update on shell restart, so + // any wt invocation still landing here is using an outdated + // wrapper — most often nushell, where the wrapper is a static + // file the user must reinstall via `wt config shell install`. + // Warn once per process so the user is prompted to refresh it + // before the legacy fallback is removed in a future release. + Some(file) => { + warn_legacy_directive(); + DirectiveMode::Legacy { file } + } None => DirectiveMode::Interactive, }, } } +/// Warn that the active shell wrapper is using the pre-split directive-file +/// protocol. The caller (`compute_directive_mode`) runs once per process from +/// `OUTPUT_STATE.get_or_init`, so this naturally fires at most once. +fn warn_legacy_directive() { + eprintln!( + "{}", + warning_message(cformat!( + "Shell wrapper uses the legacy directive-file protocol; it will be removed in a future release" + )) + ); + eprintln!( + "{}", + hint_message(cformat!( + "To update, run wt config shell install" + )) + ); +} + /// Warn that `--execute` was refused because we're running inside an alias or /// hook body with the EXEC file scrubbed. Fires at most once per process so /// repeated refusals don't spam the terminal. diff --git a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file.snap b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file.snap index eecbd21b2e..41c9177dec 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file.snap @@ -11,13 +11,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -31,13 +37,19 @@ info: WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -45,4 +57,6 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ Shell wrapper uses the legacy directive-file protocol; it will be removed in a future release +↳ To update, run wt config shell install ○ Switched to worktree for feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_fish.snap b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_fish.snap index a750ea0545..18f0138765 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_fish.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_fish.snap @@ -58,4 +58,6 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ Shell wrapper uses the legacy directive-file protocol; it will be removed in a future release +↳ To update, run wt config shell install ○ Switched to worktree for feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_powershell.snap b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_powershell.snap index 43c1b36199..bafda8789a 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_powershell.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_powershell.snap @@ -11,13 +11,19 @@ info: COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_COMMON_DIR: "" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" GIT_CONFIG_SYSTEM: /dev/null + GIT_DIR: "" GIT_EDITOR: "" + GIT_INDEX_FILE: "" + GIT_OBJECT_DIRECTORY: "" GIT_TERMINAL_PROMPT: "0" + GIT_WORK_TREE: "" HOME: "[TEST_HOME]" LANG: C LC_ALL: C + LLVM_PROFILE_FILE: "[LLVM_PROFILE_FILE]" MOCK_CONFIG_DIR: "[MOCK_CONFIG_DIR]" NO_COLOR: "" OPENCODE_CONFIG_DIR: "[TEST_OPENCODE_CONFIG]" @@ -32,13 +38,19 @@ info: WORKTRUNK_DIRECTIVE_FILE: "[DIRECTIVE_FILE]" WORKTRUNK_SHELL: powershell WORKTRUNK_SYSTEM_CONFIG_PATH: "[TEST_SYSTEM_CONFIG]" + WORKTRUNK_TEST_BASH_INSTALLED: "0" WORKTRUNK_TEST_CLAUDE_INSTALLED: "0" + WORKTRUNK_TEST_CODEX_INSTALLED: "0" WORKTRUNK_TEST_DELAYED_STREAM_MS: "-1" WORKTRUNK_TEST_EPOCH: "1735776000" + WORKTRUNK_TEST_FISH_INSTALLED: "0" + WORKTRUNK_TEST_GEMINI_INSTALLED: "0" WORKTRUNK_TEST_NUSHELL_ENV: "0" WORKTRUNK_TEST_OPENCODE_INSTALLED: "0" WORKTRUNK_TEST_POWERSHELL_ENV: "0" + WORKTRUNK_TEST_POWERSHELL_INSTALLED: "0" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + WORKTRUNK_TEST_ZSH_INSTALLED: "0" XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" --- success: true @@ -46,4 +58,6 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- +▲ Shell wrapper uses the legacy directive-file protocol; it will be removed in a future release +↳ To update, run wt config shell install ○ Switched to worktree for feature @ _REPO_.feature diff --git a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap index a79a59bc17..added5fe23 100644 --- a/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap +++ b/tests/snapshots/integration__integration_tests__directives__switch_legacy_directive_file_with_execute.snap @@ -62,6 +62,8 @@ exit_code: 0 ----- stderr ----- ▲ --execute will change in a future release: it will run a single program, with arguments after --, not a shell command line ↳ Comment at https://github.com/max-sixty/worktrunk/issues/2860 if the new single-program form would make a workflow worse; to run this command line unchanged, pass it to a shell: --execute sh -- -c 'echo hello' +▲ Shell wrapper uses the legacy directive-file protocol; it will be removed in a future release +↳ To update, run wt config shell install ✓ Created branch exec-legacy from main and worktree @ _REPO_.exec-legacy ↳ To customize worktree locations, run wt config create ◎ Executing (--execute): From 9f3b2c902f52948292f1cee10627eff2c8bd0175 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 24 May 2026 16:33:18 -0700 Subject: [PATCH 30/34] fix(shell): drop --cmd from uninstall, scan all worktrunk-managed integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uninstall previously located Fish/Nushell wrapper files by name (`{cmd}.fish` / `{cmd}.nu`), so removing integration installed under an alternate binary name (e.g. `git-wt`) required the user to pass `--cmd` matching the install. That symmetry leaks an implementation detail — uninstall already had the content-marker (is_worktrunk_managed_content) needed to recognize wrapper files regardless of name. Uninstall now scans the wrapper directories (~/.config/fish/functions, conf.d, completions; the nushell config candidates' vendor/autoload) and admits every file whose content matches the cmd-agnostic marker — `# worktrunk shell integration for ` (always written by the wrapper templates), with a regex fallback for older installs. For Bash/Zsh/PowerShell (line-based integration), is_shell_integration_line_for_uninstall_any_cmd extracts binary-name candidates from each line and reuses the execution-context check. `--cmd` is dropped from `wt config shell uninstall`; `install` keeps it (installing under an alternate name is still a deliberate per-cmd act). Per-cmd uninstall scoping is removed (niche use case; `uninstall ` already provides coarse scoping by shell). Co-Authored-By: Claude Opus 4.7 --- src/cli/config.rs | 15 +- src/commands/configure_shell.rs | 349 ++++++++++++--------- src/main.rs | 9 +- src/shell/detection.rs | 26 ++ src/shell/mod.rs | 8 +- src/shell/paths.rs | 2 +- tests/integration_tests/configure_shell.rs | 61 ++-- 7 files changed, 262 insertions(+), 208 deletions(-) diff --git a/src/cli/config.rs b/src/cli/config.rs index 4faa6ad2c5..0ff1256382 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -108,15 +108,10 @@ Skip confirmation prompt: $ wt config shell uninstall --yes ``` -Uninstall an alternate command name: -```console -$ wt config shell uninstall zsh --cmd=git-wt -``` - ## Version tolerance -Detects various forms of the integration pattern regardless of: -- Command prefix (wt, worktree, etc.) +Uninstall removes every worktrunk-managed integration it finds, regardless of: +- Binary name it was installed under (`wt`, `git-wt`, …) - Minor syntax variations between versions"# )] Uninstall { @@ -127,12 +122,6 @@ Detects various forms of the integration pattern regardless of: /// Show what would be changed #[arg(long)] dry_run: bool, - - /// Command name for shell integration (defaults to binary name) - /// - /// Use this to remove shell integration installed for an alternate command name. - #[arg(long)] - cmd: Option, }, /// Show output theme samples diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs index 81e0718896..358f3f962b 100644 --- a/src/commands/configure_shell.rs +++ b/src/commands/configure_shell.rs @@ -120,6 +120,26 @@ fn is_worktrunk_managed_content(content: &str, cmd: &str) -> bool { content.contains(&format!("{cmd} config shell init")) && content.contains("| source") } +/// Cmd-agnostic content check: matches a worktrunk-generated wrapper file +/// regardless of the binary name embedded in it. +/// +/// Used by `uninstall` to recognize wrapper files installed under any binary +/// name (e.g. `wt.fish`, `git-wt.fish`, `git-wt.nu`) when scanning the wrapper +/// directories. The canonical marker is the `# worktrunk shell integration for +/// ` comment at the top of every wrapper template; a regex fallback +/// catches older installs that predate the marker. +fn is_worktrunk_managed_content_any_cmd(content: &str) -> bool { + if content.contains("# worktrunk shell integration for") { + return true; + } + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + regex::Regex::new(r"\b[\w.-]+?(?:\.exe)?\s+config\s+shell\s+init\b") + .expect("static regex is valid") + }); + re.is_match(content) && content.contains("| source") +} + /// Clean up legacy fish conf.d file after installing to functions/ /// /// Previously, fish shell integration was installed to `~/.config/fish/conf.d/{cmd}.fish`. @@ -909,12 +929,9 @@ pub fn handle_unconfigure_shell( shell_filter: Option, skip_confirmation: bool, dry_run: bool, - cmd: &str, ) -> Result { - shell::validate_shell_command_name(cmd)?; - // First, do a dry-run to see what would be changed - let preview = scan_for_uninstall(shell_filter, true, cmd)?; + let preview = scan_for_uninstall(shell_filter, true)?; // If nothing to do, return early if preview.results.is_empty() && preview.completion_results.is_empty() { @@ -935,7 +952,7 @@ pub fn handle_unconfigure_shell( } // User confirmed (or --yes flag was used), now actually apply the changes - scan_for_uninstall(shell_filter, false, cmd) + scan_for_uninstall(shell_filter, false) } /// Remove a config file with a context-rich error message. @@ -944,13 +961,18 @@ fn remove_config_file(path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("Failed to remove {}: {e}", format_path_for_display(path))) } +/// Uninstall scans the shell-owned directories and removes every worktrunk-managed +/// file or line, regardless of the binary name it was installed under. +/// +/// For Fish/Nushell wrapper files (one file per binary name), this lists the +/// wrapper directories and admits any file whose content matches +/// `is_worktrunk_managed_content_any_cmd`. For Bash/Zsh/PowerShell (line-based), +/// it scans the rc/profile files and uses `is_shell_integration_line_for_uninstall_any_cmd`. +/// No `--cmd` is needed because the marker is the content, not the file name. fn scan_for_uninstall( shell_filter: Option, dry_run: bool, - cmd: &str, ) -> Result { - shell::validate_shell_command_name(cmd)?; - // For uninstall, always include PowerShell to clean up any existing profiles let default_shells = vec![ Shell::Bash, @@ -962,172 +984,149 @@ fn scan_for_uninstall( let shells = shell_filter.map_or(default_shells, |shell| vec![shell]); + let home = + shell::home_dir_required().map_err(|e| format!("Cannot determine home directory: {e}"))?; + let mut results = Vec::new(); let mut not_found = Vec::new(); + // Wrapper file names found (e.g. `wt.fish`, `git-wt.fish`); the matching + // completion files in `completions/` share these names. + let mut fish_wrapper_names: HashSet = HashSet::new(); for &shell in &shells { - let paths = shell - .config_paths(cmd) - .map_err(|e| format!("Failed to get config paths for {shell}: {e}"))?; - - // For Fish, delete entire {cmd}.fish file (check both canonical and legacy locations) - if matches!(shell, Shell::Fish) { - let mut found_any = false; - - // Check canonical location (functions/) - // Only remove if it contains worktrunk markers to avoid deleting user's custom file - if let Some(fish_path) = paths.first() - && fish_path.exists() - { - let is_worktrunk_managed = fs::read_to_string(fish_path) - .map(|content| is_worktrunk_managed_content(&content, cmd)) - .unwrap_or(false); - - if is_worktrunk_managed { - found_any = true; - if dry_run { - results.push(UninstallResult { - shell, - path: fish_path.clone(), - action: UninstallAction::WouldRemove, - superseded_by: None, - }); - } else { - remove_config_file(fish_path)?; - results.push(UninstallResult { - shell, - path: fish_path.clone(), - action: UninstallAction::Removed, - superseded_by: None, - }); + match shell { + Shell::Fish => { + let functions_dir = home.join(".config").join("fish").join("functions"); + let confd_dir = home.join(".config").join("fish").join("conf.d"); + + let canonical = scan_fish_wrappers(&functions_dir)?; + let legacy = scan_fish_wrappers(&confd_dir)?; + let found_any = !canonical.is_empty() || !legacy.is_empty(); + + for path in &canonical { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + fish_wrapper_names.insert(name.to_string()); } - } - } - - // Also check legacy location (conf.d/) - issue #566 - // Only remove if it contains worktrunk markers to avoid deleting user's custom file - let canonical_path = paths.first().cloned(); - if let Ok(legacy_path) = Shell::legacy_fish_conf_d_path(cmd) - && legacy_path.exists() - { - let is_worktrunk_managed = fs::read_to_string(&legacy_path) - .map(|content| is_worktrunk_managed_content(&content, cmd)) - .unwrap_or(false); - - if is_worktrunk_managed { - found_any = true; - if dry_run { - results.push(UninstallResult { - shell, - path: legacy_path.clone(), - action: UninstallAction::WouldRemove, - superseded_by: canonical_path.clone(), - }); + let action = if dry_run { + UninstallAction::WouldRemove } else { - remove_config_file(&legacy_path)?; - results.push(UninstallResult { - shell, - path: legacy_path, - action: UninstallAction::Removed, - superseded_by: canonical_path, - }); - } - } - } - - if !found_any && let Some(fish_path) = paths.first() { - not_found.push((shell, fish_path.clone())); - } - continue; - } - - // For Nushell, delete config files from all candidate locations. - // Installation might have written to a different path than what we'd pick now - // (e.g., `nu` was in PATH during install but not during uninstall). - if matches!(shell, Shell::Nushell) { - let mut found_any = false; - for config_path in &paths { - if !config_path.exists() { - continue; - } - found_any = true; - if dry_run { + remove_config_file(path)?; + UninstallAction::Removed + }; results.push(UninstallResult { shell, - path: config_path.clone(), - action: UninstallAction::WouldRemove, + path: path.clone(), + action, superseded_by: None, }); - } else { - remove_config_file(config_path)?; + } + + for path in &legacy { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + fish_wrapper_names.insert(name.to_string()); + } + let superseded_by = path.file_name().map(|n| functions_dir.join(n)); + let action = if dry_run { + UninstallAction::WouldRemove + } else { + remove_config_file(path)?; + UninstallAction::Removed + }; results.push(UninstallResult { shell, - path: config_path.clone(), - action: UninstallAction::Removed, - superseded_by: None, + path: path.clone(), + action, + superseded_by, }); } + + if !found_any { + not_found.push((shell, functions_dir)); + } } - if !found_any && let Some(config_path) = paths.first() { - not_found.push((shell, config_path.clone())); + + Shell::Nushell => { + let mut found_any = false; + let candidates = shell::nushell_config_candidates(&home); + for config_dir in &candidates { + let autoload_dir = config_dir.join("vendor").join("autoload"); + let nu_files = scan_nushell_wrappers(&autoload_dir)?; + for path in &nu_files { + found_any = true; + let action = if dry_run { + UninstallAction::WouldRemove + } else { + remove_config_file(path)?; + UninstallAction::Removed + }; + results.push(UninstallResult { + shell, + path: path.clone(), + action, + superseded_by: None, + }); + } + } + if !found_any { + // Report the first candidate's autoload dir as the expected location + if let Some(first) = candidates.first() { + not_found.push((shell, first.join("vendor").join("autoload"))); + } + } } - continue; - } - // For Bash/Zsh, scan config files - let mut found = false; + Shell::Bash | Shell::Zsh | Shell::PowerShell => { + let paths = line_based_config_paths(shell, &home); + let mut found = false; - for path in &paths { - if !path.exists() { - continue; - } + for path in &paths { + if !path.exists() { + continue; + } - match uninstall_from_file(shell, path, dry_run, cmd) { - Ok(Some(result)) => { - results.push(result); - found = true; - break; // Only process first matching file per shell + match uninstall_from_file(shell, path, dry_run) { + Ok(Some(result)) => { + results.push(result); + found = true; + break; // Only process first matching file per shell + } + Ok(None) => {} // No integration found in this file + Err(e) => return Err(e), + } } - Ok(None) => {} // No integration found in this file - Err(e) => return Err(e), - } - } - if !found && let Some(first_path) = paths.first() { - not_found.push((shell, first_path.clone())); + if !found && let Some(first_path) = paths.first() { + not_found.push((shell, first_path.clone())); + } + } } } - // Fish has a separate completion file that needs to be removed + // Fish completion files share names with the wrappers; clean up the same set. let mut completion_results = Vec::new(); let mut completion_not_found = Vec::new(); - - for &shell in &shells { - if shell != Shell::Fish { - continue; - } - - let completion_path = shell - .completion_path(cmd) - .map_err(|e| format!("Failed to get completion path for {}: {}", shell, e))?; - - if completion_path.exists() { - if dry_run { - completion_results.push(CompletionUninstallResult { - shell, - path: completion_path, - action: UninstallAction::WouldRemove, - }); - } else { - remove_config_file(&completion_path)?; + if shells.contains(&Shell::Fish) { + let completions_dir = home.join(".config").join("fish").join("completions"); + let mut completion_found_any = false; + for name in &fish_wrapper_names { + let comp_path = completions_dir.join(name); + if comp_path.exists() { + completion_found_any = true; + let action = if dry_run { + UninstallAction::WouldRemove + } else { + remove_config_file(&comp_path)?; + UninstallAction::Removed + }; completion_results.push(CompletionUninstallResult { - shell, - path: completion_path, - action: UninstallAction::Removed, + shell: Shell::Fish, + path: comp_path, + action, }); } - } else { - completion_not_found.push((shell, completion_path)); + } + if !completion_found_any { + completion_not_found.push((Shell::Fish, completions_dir)); } } @@ -1139,11 +1138,67 @@ fn scan_for_uninstall( }) } +/// Compute line-based config paths (Bash/Zsh/PowerShell) inline so uninstall +/// doesn't need a cmd to drive `Shell::config_paths` (which uses cmd only for +/// wrapper-shell file names). +fn line_based_config_paths(shell: Shell, home: &Path) -> Vec { + match shell { + Shell::Bash => vec![home.join(".bashrc")], + Shell::Zsh => { + let zdotdir = std::env::var("ZDOTDIR") + .map(PathBuf::from) + .unwrap_or_else(|_| home.to_path_buf()); + vec![zdotdir.join(".zshrc")] + } + Shell::PowerShell => shell::powershell_profile_paths(home), + Shell::Fish | Shell::Nushell => Vec::new(), + } +} + +/// List `*.fish` files in `dir` whose content matches a worktrunk wrapper +/// (regardless of binary name). Returns empty if `dir` does not exist. +fn scan_fish_wrappers(dir: &Path) -> Result, String> { + scan_wrapper_directory(dir, "fish") +} + +/// List `*.nu` files in `dir` whose content matches a worktrunk wrapper. +fn scan_nushell_wrappers(dir: &Path) -> Result, String> { + scan_wrapper_directory(dir, "nu") +} + +fn scan_wrapper_directory(dir: &Path, extension: &str) -> Result, String> { + if !dir.exists() { + return Ok(Vec::new()); + } + let entries = fs::read_dir(dir) + .map_err(|e| format!("Failed to read {}: {e}", format_path_for_display(dir)))?; + let mut out = Vec::new(); + for entry in entries { + let entry = + entry.map_err(|e| format!("Failed to read {}: {e}", format_path_for_display(dir)))?; + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some(extension) { + continue; + } + if !path.is_file() { + continue; + } + let content = match fs::read_to_string(&path) { + Ok(s) => s, + Err(_) => continue, // unreadable file: skip, don't fail the whole uninstall + }; + if is_worktrunk_managed_content_any_cmd(&content) { + out.push(path); + } + } + out.sort(); + Ok(out) +} + fn uninstall_from_file( shell: Shell, path: &Path, dry_run: bool, - cmd: &str, ) -> Result, String> { let content = fs::read_to_string(path) .map_err(|e| format!("Failed to read {}: {}", format_path_for_display(path), e))?; @@ -1152,7 +1207,7 @@ fn uninstall_from_file( let integration_lines: Vec<(usize, &str)> = lines .iter() .enumerate() - .filter(|(_, line)| shell::is_shell_integration_line_for_uninstall(line, cmd)) + .filter(|(_, line)| shell::is_shell_integration_line_for_uninstall_any_cmd(line)) .map(|(i, line)| (i, *line)) .collect(); diff --git a/src/main.rs b/src/main.rs index d084b967e3..a682d92799 100644 --- a/src/main.rs +++ b/src/main.rs @@ -554,14 +554,9 @@ fn handle_config_shell_command(action: ConfigShellCommand, yes: bool) -> anyhow: Ok(()) }) } - ConfigShellCommand::Uninstall { - shell, - dry_run, - cmd, - } => { + ConfigShellCommand::Uninstall { shell, dry_run } => { let explicit_shell = shell.is_some(); - let cmd = cmd.unwrap_or_else(binary_name); - handle_unconfigure_shell(shell, yes, dry_run, &cmd) + handle_unconfigure_shell(shell, yes, dry_run) .map_err(|e| anyhow::anyhow!("{}", e)) .map(|result| { if !dry_run { diff --git a/src/shell/detection.rs b/src/shell/detection.rs index d00f3ab9e7..082daf37e3 100644 --- a/src/shell/detection.rs +++ b/src/shell/detection.rs @@ -96,6 +96,32 @@ pub fn is_shell_integration_line_for_uninstall(line: &str, cmd: &str) -> bool { is_shell_integration_line_impl(line, cmd, false) } +/// Cmd-agnostic line detection: matches any ` config shell init` line +/// in an execution context, regardless of binary name. Extracts binary-name +/// candidates from the line via regex and delegates to +/// `is_shell_integration_line_for_uninstall` for the execution-context check. +/// +/// Used by `uninstall` when scanning to remove worktrunk-managed shell +/// integration regardless of the binary name it was installed under. +pub fn is_shell_integration_line_for_uninstall_any_cmd(line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.starts_with('#') || trimmed.starts_with("<#") { + return false; + } + static RE: std::sync::OnceLock = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| { + regex::Regex::new(r"([\w.-]+?)(?:\.exe)?\s+config\s+shell\s+init\b") + .expect("static regex is valid") + }); + for cap in re.captures_iter(trimmed) { + let candidate = &cap[1]; + if is_shell_integration_line_for_uninstall(line, candidate) { + return true; + } + } + false +} + fn is_shell_integration_line_impl(line: &str, cmd: &str, strict: bool) -> bool { let trimmed = line.trim(); diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 421e64fe83..253dcf7b2b 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -16,9 +16,13 @@ use askama::Template; // Re-export public types and functions pub use detection::{ BypassAlias, DetectedLine, FileDetectionResult, is_shell_integration_line, - is_shell_integration_line_for_uninstall, scan_for_detection_details, + is_shell_integration_line_for_uninstall, is_shell_integration_line_for_uninstall_any_cmd, + scan_for_detection_details, +}; +pub use paths::{ + completion_path, config_paths, home_dir_required, legacy_fish_conf_d_path, + nushell_config_candidates, powershell_profile_paths, }; -pub use paths::{completion_path, config_paths, legacy_fish_conf_d_path}; pub use utils::{current_shell, detect_zsh_compinit, extract_filename_from_path}; /// Validate a command name before embedding it in shell syntax or shell-owned paths. diff --git a/src/shell/paths.rs b/src/shell/paths.rs index 13c147852c..9ba569f6e7 100644 --- a/src/shell/paths.rs +++ b/src/shell/paths.rs @@ -72,7 +72,7 @@ fn nushell_config_dir(home: &std::path::Path) -> PathBuf { /// /// Returns paths in priority order: queried path first, then fallbacks. /// Callers that pick `first()` to write get the same path as `nushell_config_dir()`. -fn nushell_config_candidates(home: &std::path::Path) -> Vec { +pub fn nushell_config_candidates(home: &std::path::Path) -> Vec { let mut candidates = vec![]; // Best path: query nu directly (same source of truth as nushell_config_dir) diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index b4ca415a40..581ca72ba6 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -951,9 +951,9 @@ fn test_uninstall_shell(repo: TestRepo, temp_home: TempDir) { ----- stderr ----- ✓ Removed shell extension & completions for zsh @ ~/.zshrc ↳ No bash shell extension & completions in ~/.bashrc - ↳ No fish shell extension in ~/.config/fish/functions/wt.fish - ↳ No nu shell extension & completions in ~/.config/nushell/vendor/autoload/wt.nu - ↳ No fish completions in ~/.config/fish/completions/wt.fish + ↳ No fish shell extension in ~/.config/fish/functions + ↳ No nu shell extension & completions in ~/.config/nushell/vendor/autoload + ↳ No fish completions in ~/.config/fish/completions ✓ Removed integration from 1 shell ↳ Restart shell to complete uninstall @@ -1008,9 +1008,9 @@ fn test_uninstall_shell_multiple(repo: TestRepo, temp_home: TempDir) { ----- stderr ----- ✓ Removed shell extension & completions for bash @ ~/.bashrc ✓ Removed shell extension & completions for zsh @ ~/.zshrc - ↳ No fish shell extension in ~/.config/fish/functions/wt.fish - ↳ No nu shell extension & completions in ~/.config/nushell/vendor/autoload/wt.nu - ↳ No fish completions in ~/.config/fish/completions/wt.fish + ↳ No fish shell extension in ~/.config/fish/functions + ↳ No nu shell extension & completions in ~/.config/nushell/vendor/autoload + ↳ No fish completions in ~/.config/fish/completions ✓ Removed integration from 2 shells ↳ Restart shell to complete uninstall @@ -1168,7 +1168,7 @@ fn test_install_uninstall_roundtrip(repo: TestRepo, temp_home: TempDir) { } #[rstest] -fn test_uninstall_shell_custom_cmd_removes_matching_zsh_line(repo: TestRepo, temp_home: TempDir) { +fn test_uninstall_scan_removes_all_worktrunk_zsh_lines(repo: TestRepo, temp_home: TempDir) { let zshrc_path = temp_home.path().join(".zshrc"); fs::write( &zshrc_path, @@ -1201,15 +1201,7 @@ fn test_uninstall_shell_custom_cmd_removes_matching_zsh_line(repo: TestRepo, tem set_temp_home_env(&mut uninstall_cmd, temp_home.path()); uninstall_cmd.env("SHELL", "/bin/zsh"); uninstall_cmd - .args([ - "config", - "shell", - "uninstall", - "zsh", - "--yes", - "--cmd", - "git-wt", - ]) + .args(["config", "shell", "uninstall", "zsh", "--yes"]) .current_dir(repo.root_path()); let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall"); @@ -1225,13 +1217,17 @@ fn test_uninstall_shell_custom_cmd_removes_matching_zsh_line(repo: TestRepo, tem "Custom command integration should be removed" ); assert!( - content.contains("wt config shell init zsh"), - "Default command integration should be preserved" + !content.contains("wt config shell init zsh"), + "Default command integration should also be removed (scan-all)" + ); + assert!( + content.contains("# Existing config"), + "Unrelated comment should be preserved" ); } #[rstest] -fn test_uninstall_shell_custom_cmd_removes_fish_files(repo: TestRepo, temp_home: TempDir) { +fn test_uninstall_scan_removes_custom_cmd_fish_files(repo: TestRepo, temp_home: TempDir) { let fish_functions = temp_home.path().join(".config/fish/functions"); let fish_completions = temp_home.path().join(".config/fish/completions"); fs::create_dir_all(&fish_functions).unwrap(); @@ -1267,15 +1263,7 @@ fn test_uninstall_shell_custom_cmd_removes_fish_files(repo: TestRepo, temp_home: set_temp_home_env(&mut uninstall_cmd, temp_home.path()); uninstall_cmd.env("SHELL", "/bin/fish"); uninstall_cmd - .args([ - "config", - "shell", - "uninstall", - "fish", - "--yes", - "--cmd", - "git-wt", - ]) + .args(["config", "shell", "uninstall", "fish", "--yes"]) .current_dir(repo.root_path()); let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall"); @@ -1285,6 +1273,9 @@ fn test_uninstall_shell_custom_cmd_removes_fish_files(repo: TestRepo, temp_home: String::from_utf8_lossy(&uninstall_output.stderr) ); + // Scan-all removes git-wt.fish (worktrunk-managed by content) but leaves the + // hand-written wt.fish stub alone — it lacks the `config shell init … | source` + // marker so it doesn't look like a worktrunk wrapper. assert!(!custom_function.exists()); assert!(!custom_completion.exists()); assert!(default_function.exists()); @@ -1292,7 +1283,7 @@ fn test_uninstall_shell_custom_cmd_removes_fish_files(repo: TestRepo, temp_home: } #[rstest] -fn test_uninstall_shell_custom_cmd_removes_nushell_file(repo: TestRepo, temp_home: TempDir) { +fn test_uninstall_scan_removes_custom_cmd_nushell_file(repo: TestRepo, temp_home: TempDir) { let home = std::fs::canonicalize(temp_home.path()).unwrap(); let nu_autoload = home .join(".config") @@ -1327,15 +1318,7 @@ fn test_uninstall_shell_custom_cmd_removes_nushell_file(repo: TestRepo, temp_hom set_temp_home_env(&mut uninstall_cmd, temp_home.path()); uninstall_cmd.env("SHELL", "/bin/nu"); uninstall_cmd - .args([ - "config", - "shell", - "uninstall", - "nu", - "--yes", - "--cmd", - "git-wt", - ]) + .args(["config", "shell", "uninstall", "nu", "--yes"]) .current_dir(repo.root_path()); let uninstall_output = uninstall_cmd.output().expect("Failed to execute uninstall"); @@ -1345,6 +1328,8 @@ fn test_uninstall_shell_custom_cmd_removes_nushell_file(repo: TestRepo, temp_hom String::from_utf8_lossy(&uninstall_output.stderr) ); + // Scan-all removes git-wt.nu (worktrunk-managed by content) but leaves the + // hand-written wt.nu stub alone (no `config shell init … | source` marker). assert!(!custom_config.exists()); assert!(default_config.exists()); } From afbe4d4f0bf9014e1a420b18416dc97d0e302763 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Sun, 31 May 2026 12:36:11 -0700 Subject: [PATCH 31/34] test(shell): cover uninstall scan helpers; simplify error propagation Close the codecov/patch gap on the uninstall-scan paths added by this branch: - line_based_config_paths: the Fish/Nushell empty-list arm - scan_wrapper_directory: wrong-extension, non-file, and unreadable-file skips - is_shell_integration_line_for_uninstall_any_cmd: the positive match path Also replace the explicit `Err(e) => return Err(e)` arm in the uninstall loop with `?` propagation, removing an untestable error arm. --- src/commands/configure_shell.rs | 49 +++++++++++++++++++++++++++------ src/shell/detection.rs | 17 ++++++++++++ 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs index 0d9804bf1e..e37bc239aa 100644 --- a/src/commands/configure_shell.rs +++ b/src/commands/configure_shell.rs @@ -1073,14 +1073,11 @@ fn scan_for_uninstall( continue; } - match uninstall_from_file(shell, path, dry_run) { - Ok(Some(result)) => { - results.push(result); - found = true; - break; // Only process first matching file per shell - } - Ok(None) => {} // No integration found in this file - Err(e) => return Err(e), + // Only process the first matching file per shell. + if let Some(result) = uninstall_from_file(shell, path, dry_run)? { + results.push(result); + found = true; + break; } } @@ -1529,4 +1526,40 @@ mod tests { let new = "# Docs: https://worktrunk.dev/config/#shell-integration\nfunction wt\n command wt $argv\nend"; assert_eq!(fish_code_lines(old), fish_code_lines(new)); } + + #[test] + fn test_line_based_config_paths_fish_and_nushell_have_no_line_files() { + // Fish and Nushell are configured via wrapper files, not line-based rc + // edits, so the line-based path list is empty for them. + let home = Path::new("/home/user"); + assert!(line_based_config_paths(Shell::Fish, home).is_empty()); + assert!(line_based_config_paths(Shell::Nushell, home).is_empty()); + } + + #[test] + fn test_scan_wrapper_directory_skips_wrong_extension_and_directories() { + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path(); + // Wrong extension: skipped. + fs::write(root.join("notes.txt"), "anything").unwrap(); + // A directory carrying the wrapper extension: skipped (not a file). + fs::create_dir(root.join("nested.fish")).unwrap(); + assert!(scan_fish_wrappers(root).unwrap().is_empty()); + } + + #[cfg(unix)] + #[test] + fn test_scan_wrapper_directory_skips_unreadable_file() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::TempDir::new().unwrap(); + let root = dir.path(); + let unreadable = root.join("locked.fish"); + fs::write(&unreadable, "function wt\nend\n").unwrap(); + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap(); + // An unreadable wrapper file is skipped, not surfaced as an error. + let found = scan_fish_wrappers(root); + // Restore perms so TempDir cleanup can remove the file. + fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(found.unwrap().is_empty()); + } } diff --git a/src/shell/detection.rs b/src/shell/detection.rs index 082daf37e3..801a97c915 100644 --- a/src/shell/detection.rs +++ b/src/shell/detection.rs @@ -814,6 +814,23 @@ mod tests { ); } + #[test] + fn test_any_cmd_detects_managed_line_regardless_of_binary_name() { + // The cmd-agnostic detector extracts the binary candidate from the line + // and confirms it via the execution-context check, matching a wrapper + // installed under any binary name. + assert!(is_shell_integration_line_for_uninstall_any_cmd( + "eval \"$(wt config shell init bash)\"" + )); + assert!(is_shell_integration_line_for_uninstall_any_cmd( + "eval \"$(git-wt config shell init zsh)\"" + )); + // A bare mention with no execution context is not a managed line. + assert!(!is_shell_integration_line_for_uninstall_any_cmd( + "echo wt config shell init bash" + )); + } + // ------------------------------------------------------------------------ // FALSE NEGATIVE: PowerShell block comments // Note: This is actually a FALSE POSITIVE risk (comments matching) From 453f60c7ea3265cbd379ade69038c7d105c9cf14 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Wed, 22 Jul 2026 20:20:53 -0700 Subject: [PATCH 32/34] test(shell): pin nushell vendor-autoload dir in custom-cmd uninstall-scan test The prior merge commit staged this test's fix in the working tree but never re-added it to the index, so the committed version still hardcoded the pre-#2878 config-dir path. Pin WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR like the sibling nushell tests so the target is deterministic. --- tests/integration_tests/configure_shell.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index 82ceda65f9..8c8b014c6f 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -1298,12 +1298,10 @@ fn test_uninstall_scan_removes_custom_cmd_fish_files(repo: TestRepo, temp_home: #[rstest] fn test_uninstall_scan_removes_custom_cmd_nushell_file(repo: TestRepo, temp_home: TempDir) { - let home = std::fs::canonicalize(temp_home.path()).unwrap(); - let nu_autoload = home - .join(".config") - .join("nushell") - .join("vendor") - .join("autoload"); + let home = canonical_temp_home(&temp_home); + // Pin the vendor-autoload dir so the target is deterministic across + // platforms and independent of whether `nu` is on the runner's PATH. + let nu_autoload = home.join(".local/share/nushell/vendor/autoload"); fs::create_dir_all(&nu_autoload).unwrap(); let default_config = nu_autoload.join("wt.nu"); fs::write(&default_config, "def --env --wrapped wt [] {}\n").unwrap(); @@ -1311,6 +1309,7 @@ fn test_uninstall_scan_removes_custom_cmd_nushell_file(repo: TestRepo, temp_home let mut install_cmd = wt_command(); repo.configure_wt_cmd(&mut install_cmd); set_temp_home_env(&mut install_cmd, temp_home.path()); + install_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &nu_autoload); install_cmd.env("SHELL", "/bin/nu"); install_cmd .args([ @@ -1330,6 +1329,7 @@ fn test_uninstall_scan_removes_custom_cmd_nushell_file(repo: TestRepo, temp_home let mut uninstall_cmd = wt_command(); repo.configure_wt_cmd(&mut uninstall_cmd); set_temp_home_env(&mut uninstall_cmd, temp_home.path()); + uninstall_cmd.env("WORKTRUNK_TEST_NU_VENDOR_AUTOLOAD_DIR", &nu_autoload); uninstall_cmd.env("SHELL", "/bin/nu"); uninstall_cmd .args(["config", "shell", "uninstall", "nu", "--yes"]) From 07f73df9ceefdb308a6a40da8236eb0a1df29d81 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 23 Jul 2026 17:11:32 -0700 Subject: [PATCH 33/34] refactor(shell): read the binary name off the line instead of matching it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both cmd-agnostic detectors used a regex to guess which token was the binary name. Neither needed to. Inside `has_init_pattern_with_prefix_check` the execution-context test (`eval`, `source`, `iex`, …) never looked at the match position — it is a whole-line property that only appeared position-dependent by living in the loop. Hoisting it to `is_execution_context` leaves the name question standing alone, and `config shell init` is a fixed literal, so the name is the run of command-name characters ending at it. That run is held to `validate_shell_command_name`, so detection and generation share one definition and cannot drift; `.exe` needs no special case, since `.` is already a name character. Two behavior fixes follow from dropping the regexes rather than from patching around them: - Uninstall now removes a hand-written `git wt config shell init` line. The old code delegated to the cmd-specific predicate, inheriting its `git ` prefix exclusion — which answers "which install owns this line?", not the question uninstall asks. - A wrapper-directory file is no longer condemned by a mention in one place plus a `| source` of something else in another. That file-wide match would delete a user's own fish function that merely referenced the integration in a comment. The check is per line now. The headerless legacy `conf.d/{cmd}.fish` still matches, through the rc-file line detector rather than a second pattern of its own. Co-Authored-By: Claude Opus 4.8 --- src/commands/configure_shell.rs | 58 +++++-- src/shell/detection.rs | 175 ++++++++++++--------- tests/integration_tests/configure_shell.rs | 11 +- 3 files changed, 150 insertions(+), 94 deletions(-) diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs index 5f46742927..9ea0fb5688 100644 --- a/src/commands/configure_shell.rs +++ b/src/commands/configure_shell.rs @@ -124,35 +124,36 @@ fn is_worktrunk_managed_content(content: &str, cmd: &str) -> bool { content.contains(&format!("{cmd} config shell init")) && content.contains("| source") } +/// The header comment every wrapper template worktrunk has shipped opens with, +/// and which a user's own `wt.fish` / `wt.nu` would not carry. +const WRAPPER_MARKER: &str = "worktrunk shell integration for"; + /// Cmd-agnostic content check: matches a worktrunk-generated wrapper file /// regardless of the binary name embedded in it. /// /// Used by `uninstall` to recognize wrapper files installed under any binary /// name (e.g. `wt.fish`, `git-wt.fish`, `git-wt.nu`) when scanning the wrapper -/// directories. The canonical marker is the `# worktrunk shell integration for -/// ` comment at the top of every wrapper template; a regex fallback -/// catches older installs that predate the marker. +/// directories. Every wrapper template worktrunk renders carries the header, so +/// that answers first; the fallback covers the legacy `conf.d/{cmd}.fish`, which +/// predates the templates and is a bare init line. Reusing the rc-file line +/// detector for that fallback keeps one definition of "an integration line" — +/// and, being per-line, it does not admit a file on the strength of a mention in +/// one place and a `| source` of something else in another. fn is_worktrunk_managed_content_any_cmd(content: &str) -> bool { - if content.contains("# worktrunk shell integration for") { - return true; - } - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let re = RE.get_or_init(|| { - regex::Regex::new(r"\b[\w.-]+?(?:\.exe)?\s+config\s+shell\s+init\b") - .expect("static regex is valid") - }); - re.is_match(content) && content.contains("| source") + content.contains(WRAPPER_MARKER) + || content + .lines() + .any(shell::is_shell_integration_line_for_uninstall_any_cmd) } /// Check if a Nushell wrapper file is worktrunk-managed. /// /// The Nushell wrapper is a complete autoload file (not a `source` line), so it -/// carries no `config shell init` marker. Every version worktrunk has shipped -/// opens with this header comment, which a user's own `wt.nu` would not — so it -/// is a safe signal that the file is ours to remove during stranded-file -/// cleanup (issue #2878). +/// carries no `config shell init` marker — the header comment is the whole +/// signal that the file is ours to remove during stranded-file cleanup +/// (issue #2878). fn is_worktrunk_managed_nushell(content: &str) -> bool { - content.contains("worktrunk shell integration for nushell") + content.contains(&format!("{WRAPPER_MARKER} nushell")) } /// Clean up legacy fish conf.d file after installing to functions/ @@ -1632,6 +1633,29 @@ mod tests { assert_eq!(fish_code_lines(old), fish_code_lines(new)); } + #[test] + fn test_managed_content_any_cmd_matches_wrappers_not_mentions() { + // Ours: the header every wrapper template carries, under any binary + // name, plus the headerless legacy `conf.d/{cmd}.fish` init line. + for content in [ + "# worktrunk shell integration for fish\nfunction git-wt\nend\n", + "# worktrunk shell integration for nushell\ndef --env wt [] {}\n", + "wt config shell init fish | source", + ] { + assert!(is_worktrunk_managed_content_any_cmd(content), "{content}"); + } + + // Not ours: uninstall walks directories the user owns, so a file that + // only mentions the command must survive — including one that sources + // something unrelated elsewhere, which a file-wide match would delete. + for content in [ + "function notes\n echo run wt config shell init fish to set up\nend\n", + "# reminder: wt config shell init fish\nfunction helpers\n cat ~/.aliases | source\nend\n", + ] { + assert!(!is_worktrunk_managed_content_any_cmd(content), "{content}"); + } + } + #[test] fn test_line_based_config_paths_fish_and_nushell_have_no_line_files() { // Fish and Nushell are configured via wrapper files, not line-based rc diff --git a/src/shell/detection.rs b/src/shell/detection.rs index a7d4add8a9..219147595c 100644 --- a/src/shell/detection.rs +++ b/src/shell/detection.rs @@ -96,30 +96,75 @@ pub fn is_shell_integration_line_for_uninstall(line: &str, cmd: &str) -> bool { is_shell_integration_line_impl(line, cmd, false) } -/// Cmd-agnostic line detection: matches any ` config shell init` line -/// in an execution context, regardless of binary name. Extracts binary-name -/// candidates from the line via regex and delegates to -/// `is_shell_integration_line_for_uninstall` for the execution-context check. +/// The invariant part of every integration line. Only the binary name in front +/// of it varies, so it anchors both the cmd-specific and cmd-agnostic searches. +const INIT_MARKER: &str = " config shell init"; + +/// Cmd-agnostic line detection: matches an integration line whatever binary name +/// it was installed under. +/// +/// Where the cmd-specific detectors search for a known name and check what +/// precedes it, this reads the name off the line: it is the run of command-name +/// characters ending at `INIT_MARKER`. Holding that run to +/// [`validate_shell_command_name`](super::validate_shell_command_name) — the +/// same rule governing what worktrunk is willing to write into shell code — +/// keeps detection and generation from drifting apart. /// -/// Used by `uninstall` when scanning to remove worktrunk-managed shell -/// integration regardless of the binary name it was installed under. +/// Unlike the cmd-specific detectors this deliberately does not exclude a `git ` +/// prefix: `git wt config shell init` is worktrunk's own integration too, and +/// uninstall asks only whether a line is ours, not which install it belongs to. +/// +/// Used by `wt config shell uninstall`, which removes every worktrunk-managed +/// integration it finds. pub fn is_shell_integration_line_for_uninstall_any_cmd(line: &str) -> bool { let trimmed = line.trim(); if trimmed.starts_with('#') || trimmed.starts_with("<#") { return false; } - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - let re = RE.get_or_init(|| { - regex::Regex::new(r"([\w.-]+?)(?:\.exe)?\s+config\s+shell\s+init\b") - .expect("static regex is valid") - }); - for cap in re.captures_iter(trimmed) { - let candidate = &cap[1]; - if is_shell_integration_line_for_uninstall(line, candidate) { - return true; - } + is_execution_context(trimmed, false) + && trimmed + .match_indices(INIT_MARKER) + .any(|(pos, _)| command_name_precedes(trimmed, pos)) +} + +/// Does a plausible command name end at `pos`? +/// +/// Reads backwards over command-name characters, which stops at the shell +/// punctuation wrapping the invocation (`"$(`, `<(`) and at the directory +/// separator of an absolute path, leaving the bare name. An empty run means the +/// marker stands alone with nothing invoking it. +fn command_name_precedes(line: &str, pos: usize) -> bool { + let before = &line[..pos]; + before + .char_indices() + .rev() + .take_while(|(_, c)| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + .map(|(i, _)| i) + .last() + .is_some_and(|start| super::validate_shell_command_name(&before[start..]).is_ok()) +} + +/// Does the line run the command's output, rather than merely mention it? +/// +/// A property of the whole line, not of where the command sits within it. +/// +/// PowerShell is checked first, case-insensitively. It needs `| Out-String` to +/// work — without it `Invoke-Expression` fails with "Cannot convert +/// 'System.Object[]'" (issue #885). Strict mode leaves an old config lacking it +/// undetected so `wt config shell install` will rewrite it; permissive mode +/// (uninstall) matches it so it can be removed. +fn is_execution_context(line: &str, strict: bool) -> bool { + let line_lower = line.to_lowercase(); + if line_lower.contains("invoke-expression") || line_lower.contains("iex") { + return !strict || line_lower.contains("out-string"); } - false + + // POSIX shells (bash, zsh, fish) and nushell + line.contains("eval") + || line.contains("source") + || line.contains(". <(") // POSIX dot command with process substitution + || line.contains(". =(") // zsh dot command with =() substitution + || line.contains("save") // nushell pipe to save } fn is_shell_integration_line_impl(line: &str, cmd: &str, strict: bool) -> bool { @@ -165,59 +210,21 @@ fn has_init_invocation(line: &str, cmd: &str, strict: bool) -> bool { /// When `strict` is true, PowerShell lines must include `| Out-String`. /// When `strict` is false (for uninstall), old PowerShell lines also match. fn has_init_pattern_with_prefix_check(line: &str, cmd: &str, strict: bool) -> bool { - // Search for both plain command and .exe variant (Windows Git Bash) - let patterns = [ - format!("{cmd} config shell init"), - format!("{cmd}.exe config shell init"), - ]; + if !is_execution_context(line, strict) { + return false; + } - for init_pattern in &patterns { - // Determine the command portion for position checking - // For ".exe" pattern, the command in the line includes ".exe" - let cmd_in_line = if init_pattern.contains(".exe") { - format!("{cmd}.exe") - } else { - cmd.to_string() - }; + // Search for both plain command and .exe variant (Windows Git Bash) + for cmd_in_line in [cmd.to_string(), format!("{cmd}.exe")] { + let init_pattern = format!("{cmd_in_line}{INIT_MARKER}"); let mut search_start = 0; - while let Some(pos) = line[search_start..].find(init_pattern.as_str()) { + while let Some(pos) = line[search_start..].find(&init_pattern) { let absolute_pos = search_start + pos; // Check what precedes the match if is_valid_command_position(line, absolute_pos, &cmd_in_line) { - // Must be in an execution context (eval, source, dot command, PowerShell, etc.) - // - // PowerShell detection is checked FIRST and uses case-insensitive matching. - // PowerShell requires | Out-String to work correctly (issue #885). - // Without it, Invoke-Expression fails with "Cannot convert 'System.Object[]'". - // In strict mode, we don't detect old configs without Out-String so that - // `wt config shell install` will update them. - // In permissive mode (uninstall), we match old configs so they can be removed. - let line_lower = line.to_lowercase(); - let has_invoke = - line_lower.contains("invoke-expression") || line_lower.contains("iex"); - if has_invoke { - // PowerShell line - if !strict || line_lower.contains("out-string") { - return true; - } - // Strict mode: old PowerShell config without Out-String, don't detect - // Skip to next pattern search position - search_start = absolute_pos + 1; - continue; - } - - // POSIX shells (bash, zsh, fish) and nushell - let is_shell_exec = line.contains("eval") - || line.contains("source") - || line.contains(". <(") // POSIX dot command with process substitution - || line.contains(". =(") // zsh dot command with =() substitution - || line.contains("save"); // nushell pipe to save - - if is_shell_exec { - return true; - } + return true; } // Continue searching after this match @@ -824,19 +831,35 @@ mod tests { #[test] fn test_any_cmd_detects_managed_line_regardless_of_binary_name() { - // The cmd-agnostic detector extracts the binary candidate from the line - // and confirms it via the execution-context check, matching a wrapper - // installed under any binary name. - assert!(is_shell_integration_line_for_uninstall_any_cmd( - "eval \"$(wt config shell init bash)\"" - )); - assert!(is_shell_integration_line_for_uninstall_any_cmd( - "eval \"$(git-wt config shell init zsh)\"" - )); - // A bare mention with no execution context is not a managed line. - assert!(!is_shell_integration_line_for_uninstall_any_cmd( - "echo wt config shell init bash" - )); + // The name is read off the line, so every form worktrunk writes matches, + // as does the `git wt` dispatch form a cmd-specific detector rejects. + for line in [ + r#"eval "$(wt config shell init bash)""#, + r#"eval "$(command git-wt config shell init zsh)""#, + r#"eval "$(git wt config shell init bash)""#, + r#"eval "$(git-wt.exe config shell init bash)""#, + r#"eval "$(/opt/homebrew/bin/my.tool_1 config shell init bash)""#, + "if type -q wt; command wt config shell init fish | source; end", + "iex (wt config shell init powershell | Out-String)", + ] { + assert!( + is_shell_integration_line_for_uninstall_any_cmd(line), + "should detect: {line}" + ); + } + + // Not managed: a mention that never runs, a commented-out line, and a + // marker with no command in front of it. + for line in [ + "echo wt config shell init bash", + r#"# eval "$(wt config shell init bash)""#, + r#"eval "$( config shell init bash)""#, + ] { + assert!( + !is_shell_integration_line_for_uninstall_any_cmd(line), + "should not detect: {line}" + ); + } } // ------------------------------------------------------------------------ diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index 8c8b014c6f..56ddec6dff 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -1210,6 +1210,15 @@ fn test_uninstall_scan_removes_all_worktrunk_zsh_lines(repo: TestRepo, temp_home let installed = fs::read_to_string(&zshrc_path).unwrap(); assert!(installed.contains("git-wt config shell init zsh")); + // A hand-written line in the `git wt` dispatch form, which install never + // emits: uninstall recognizes worktrunk's integration by whatever name it + // is invoked under, not only the names install writes. + fs::write( + &zshrc_path, + format!("{installed}eval \"$(git wt config shell init zsh)\"\n"), + ) + .unwrap(); + let mut uninstall_cmd = wt_command(); repo.configure_wt_cmd(&mut uninstall_cmd); set_temp_home_env(&mut uninstall_cmd, temp_home.path()); @@ -1232,7 +1241,7 @@ fn test_uninstall_scan_removes_all_worktrunk_zsh_lines(repo: TestRepo, temp_home ); assert!( !content.contains("wt config shell init zsh"), - "Default command integration should also be removed (scan-all)" + "Default command and `git wt` integration should also be removed (scan-all)" ); assert!( content.contains("# Existing config"), From a7ffaabb1c749bb015d0de7cc954d7c459bfedae Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 23 Jul 2026 18:50:19 -0700 Subject: [PATCH 34/34] refactor(shell): tighten uninstall deletion criteria; drop dead detector Review follow-ups on the scan-all uninstall: - A headerless wrapper-directory file is worktrunk's only when every non-blank, non-comment line is an integration line, so a user's own file that runs `wt config shell init` amid other code survives the directory scan (the legacy conf.d file holds nothing but the line). - Fish completion files are recognized by their own `# worktrunk completions for` header instead of by found-wrapper names, so an orphaned completion is cleaned up again and removal order is stable. - Detection reads only the code portion of a line: a trailing `#` comment can mention an integration line without running it, so uninstall no longer deletes an unrelated eval/source line over a comment mention, and install no longer treats one as configured. - The cmd-specific permissive detector lost its last caller when uninstall went cmd-agnostic; delete it along with the now-constant strict parameter it threaded through the cmd-specific chain. - Line-based rc paths move to shell::paths so install and uninstall share one definition (Bash/Zsh/PowerShell), and uninstall cleans every matching profile rather than only the first. - FAQ's delete-inventory documents the scan-all surface. Co-Authored-By: Claude Fable 5 --- docs/content/faq.md | 2 +- .../skills/worktrunk/reference/faq.md | 2 +- skills/worktrunk/reference/faq.md | 2 +- src/commands/configure_shell.rs | 176 +++++++++--------- src/shell/detection.rs | 124 ++++++------ src/shell/mod.rs | 5 +- src/shell/paths.rs | 33 +++- tests/integration_tests/configure_shell.rs | 91 ++++++++- 8 files changed, 251 insertions(+), 184 deletions(-) diff --git a/docs/content/faq.md b/docs/content/faq.md index d7f1877281..c10af37785 100644 --- a/docs/content/faq.md +++ b/docs/content/faq.md @@ -196,7 +196,7 @@ Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch - `wt remove` — besides the target worktree, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`) - `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash) - `wt config shell install` — when migrating an integration to a new location, removes the worktrunk-managed file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`) -- `wt config shell uninstall` — removes shell integration from rc files +- `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Files are recognized by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone See [What files does Worktrunk create?](#what-files-does-worktrunk-create) for details. diff --git a/plugins/worktrunk/skills/worktrunk/reference/faq.md b/plugins/worktrunk/skills/worktrunk/reference/faq.md index aa412f05ae..638c2004b7 100644 --- a/plugins/worktrunk/skills/worktrunk/reference/faq.md +++ b/plugins/worktrunk/skills/worktrunk/reference/faq.md @@ -191,7 +191,7 @@ Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch - `wt remove` — besides the target worktree, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`) - `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash) - `wt config shell install` — when migrating an integration to a new location, removes the worktrunk-managed file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`) -- `wt config shell uninstall` — removes shell integration from rc files +- `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Files are recognized by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone See [What files does Worktrunk create?](#what-files-does-worktrunk-create) for details. diff --git a/skills/worktrunk/reference/faq.md b/skills/worktrunk/reference/faq.md index aa412f05ae..638c2004b7 100644 --- a/skills/worktrunk/reference/faq.md +++ b/skills/worktrunk/reference/faq.md @@ -191,7 +191,7 @@ Use `-D` to force-delete branches with unmerged changes. Use `--no-delete-branch - `wt remove` — besides the target worktree, two cleanup mechanisms run. The removed worktree's own `git fsmonitor--daemon` (git's per-worktree filesystem watcher under `core.fsmonitor=true`, which would leak once its worktree is gone) is sent `git fsmonitor--daemon stop`, then force-terminated (`SIGTERM`, then `SIGKILL`) via the PID resolved from its IPC socket if it didn't exit. A background sweep then deletes `.git/wt/trash/` entries older than 24 hours (directories orphaned when a previous background removal was interrupted) and terminates fsmonitor daemons whose worktree no longer exists (orphans from `git worktree remove`, `rm -rf`, or a crashed `wt`) - `wt config state clear` — removes all worktrunk data from `.git/` (config keys, caches, markers, hints, variables, logs, stale trash) - `wt config shell install` — when migrating an integration to a new location, removes the worktrunk-managed file left at the old one: fish `conf.d/wt.fish` (now `functions/wt.fish`) and nushell wrappers stranded under `/vendor/autoload` (now `/vendor/autoload`) -- `wt config shell uninstall` — removes shell integration from rc files +- `wt config shell uninstall` — removes integration lines from bash/zsh/PowerShell rc files, and deletes worktrunk's wrapper and completion files (fish `functions/`, `conf.d/`, and `completions/`; nushell `vendor/autoload`). Files are recognized by worktrunk's own content markers, whatever binary name they were installed under; files without the markers are left alone See [What files does Worktrunk create?](#what-files-does-worktrunk-create) for details. diff --git a/src/commands/configure_shell.rs b/src/commands/configure_shell.rs index 9ea0fb5688..04ff6bebd1 100644 --- a/src/commands/configure_shell.rs +++ b/src/commands/configure_shell.rs @@ -128,22 +128,31 @@ fn is_worktrunk_managed_content(content: &str, cmd: &str) -> bool { /// and which a user's own `wt.fish` / `wt.nu` would not carry. const WRAPPER_MARKER: &str = "worktrunk shell integration for"; +/// The header every fish completion file worktrunk has shipped opens with. +const COMPLETION_MARKER: &str = "# worktrunk completions for"; + /// Cmd-agnostic content check: matches a worktrunk-generated wrapper file /// regardless of the binary name embedded in it. /// /// Used by `uninstall` to recognize wrapper files installed under any binary /// name (e.g. `wt.fish`, `git-wt.fish`, `git-wt.nu`) when scanning the wrapper /// directories. Every wrapper template worktrunk renders carries the header, so -/// that answers first; the fallback covers the legacy `conf.d/{cmd}.fish`, which -/// predates the templates and is a bare init line. Reusing the rc-file line -/// detector for that fallback keeps one definition of "an integration line" — -/// and, being per-line, it does not admit a file on the strength of a mention in -/// one place and a `| source` of something else in another. +/// that answers first. The fallback covers the legacy `conf.d/{cmd}.fish`, which +/// predates the templates and holds nothing but the init line: a file qualifies +/// only when every non-blank, non-comment line is an integration line +/// (per the rc-file line detector, keeping one definition of "an integration +/// line"). Uninstall walks directories the user owns, so a user's own file that +/// runs `wt config shell init` amid other code — or merely mentions it — must +/// survive. fn is_worktrunk_managed_content_any_cmd(content: &str) -> bool { - content.contains(WRAPPER_MARKER) - || content - .lines() - .any(shell::is_shell_integration_line_for_uninstall_any_cmd) + if content.contains(WRAPPER_MARKER) { + return true; + } + let code_lines = fish_code_lines(content); + !code_lines.is_empty() + && code_lines + .into_iter() + .all(shell::is_shell_integration_line_for_uninstall_any_cmd) } /// Check if a Nushell wrapper file is worktrunk-managed. @@ -674,10 +683,12 @@ fn is_install_shell_integration_line(line: &str, shell: Shell, cmd: &str) -> boo .contains(&format!("config shell init {shell}")) } -/// Extract non-comment, non-blank lines from fish source for comparison. +/// Extract non-comment, non-blank lines from fish/nushell source. /// -/// This lets us detect existing installations even when comment text has changed -/// between versions (e.g. updated documentation URLs). +/// Install compares wrapper code lines so it detects existing installations even +/// when comment text has changed between versions (e.g. updated documentation +/// URLs); uninstall classifies a headerless file by whether its code lines are +/// all integration lines. fn fish_code_lines(source: &str) -> Vec<&str> { source .lines() @@ -948,8 +959,6 @@ pub fn process_shell_completions( dry_run: bool, cmd: &str, ) -> Result, String> { - shell::validate_shell_command_name(cmd)?; - let mut results = Vec::new(); let fish_completion = fish_completion_content(cmd); @@ -1065,9 +1074,10 @@ fn remove_config_file(path: &std::path::Path) -> Result<(), String> { /// Uninstall scans the shell-owned directories and removes every worktrunk-managed /// file or line, regardless of the binary name it was installed under. /// -/// For Fish/Nushell wrapper files (one file per binary name), this lists the -/// wrapper directories and admits any file whose content matches -/// `is_worktrunk_managed_content_any_cmd`. For Bash/Zsh/PowerShell (line-based), +/// For Fish/Nushell wrapper files and Fish completion files (one file per binary +/// name), this lists the owning directories and admits any file whose content +/// matches the worktrunk marker (`is_worktrunk_managed_content_any_cmd`, or the +/// completion header for `completions/`). For Bash/Zsh/PowerShell (line-based), /// it scans the rc/profile files and uses `is_shell_integration_line_for_uninstall_any_cmd`. /// No `--cmd` is needed because the marker is the content, not the file name. fn scan_for_uninstall( @@ -1085,9 +1095,6 @@ fn scan_for_uninstall( let mut results = Vec::new(); let mut not_found = Vec::new(); - // Wrapper file names found (e.g. `wt.fish`, `git-wt.fish`); the matching - // completion files in `completions/` share these names. - let mut fish_wrapper_names: HashSet = HashSet::new(); for &shell in &shells { match shell { @@ -1095,14 +1102,16 @@ fn scan_for_uninstall( let functions_dir = home.join(".config").join("fish").join("functions"); let confd_dir = home.join(".config").join("fish").join("conf.d"); - let canonical = scan_fish_wrappers(&functions_dir)?; - let legacy = scan_fish_wrappers(&confd_dir)?; + let canonical = scan_managed_files( + &functions_dir, + "fish", + is_worktrunk_managed_content_any_cmd, + )?; + let legacy = + scan_managed_files(&confd_dir, "fish", is_worktrunk_managed_content_any_cmd)?; let found_any = !canonical.is_empty() || !legacy.is_empty(); for path in &canonical { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - fish_wrapper_names.insert(name.to_string()); - } let action = if dry_run { UninstallAction::WouldRemove } else { @@ -1118,9 +1127,6 @@ fn scan_for_uninstall( } for path in &legacy { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - fish_wrapper_names.insert(name.to_string()); - } let superseded_by = path.file_name().map(|n| functions_dir.join(n)); let action = if dry_run { UninstallAction::WouldRemove @@ -1145,7 +1151,11 @@ fn scan_for_uninstall( let mut found_any = false; let candidates = shell::nushell_autoload_candidates(&home); for autoload_dir in &candidates { - let nu_files = scan_nushell_wrappers(autoload_dir)?; + let nu_files = scan_managed_files( + autoload_dir, + "nu", + is_worktrunk_managed_content_any_cmd, + )?; for path in &nu_files { found_any = true; let action = if dry_run { @@ -1171,7 +1181,7 @@ fn scan_for_uninstall( } Shell::Bash | Shell::Zsh | Shell::PowerShell => { - let paths = line_based_config_paths(shell, &home); + let paths = shell::line_based_config_paths(shell, &home); let mut found = false; for path in &paths { @@ -1179,11 +1189,9 @@ fn scan_for_uninstall( continue; } - // Only process the first matching file per shell. if let Some(result) = uninstall_from_file(shell, path, dry_run)? { results.push(result); found = true; - break; } } @@ -1194,32 +1202,31 @@ fn scan_for_uninstall( } } - // Fish completion files share names with the wrappers; clean up the same set. + // Fish completion files carry their own header marker, so they are scanned + // the same way as the wrappers — a completion whose wrapper is already gone + // is still cleaned up. let mut completion_results = Vec::new(); let mut completion_not_found = Vec::new(); if shells.contains(&Shell::Fish) { let completions_dir = home.join(".config").join("fish").join("completions"); - let mut completion_found_any = false; - for name in &fish_wrapper_names { - let comp_path = completions_dir.join(name); - if comp_path.exists() { - completion_found_any = true; - let action = if dry_run { - UninstallAction::WouldRemove - } else { - remove_config_file(&comp_path)?; - UninstallAction::Removed - }; - completion_results.push(CompletionUninstallResult { - shell: Shell::Fish, - path: comp_path, - action, - }); - } - } - if !completion_found_any { + let completions = + scan_managed_files(&completions_dir, "fish", |c| c.contains(COMPLETION_MARKER))?; + if completions.is_empty() { completion_not_found.push((Shell::Fish, completions_dir)); } + for path in completions { + let action = if dry_run { + UninstallAction::WouldRemove + } else { + remove_config_file(&path)?; + UninstallAction::Removed + }; + completion_results.push(CompletionUninstallResult { + shell: Shell::Fish, + path, + action, + }); + } } Ok(UninstallScanResult { @@ -1230,35 +1237,13 @@ fn scan_for_uninstall( }) } -/// Compute line-based config paths (Bash/Zsh/PowerShell) inline so uninstall -/// doesn't need a cmd to drive `Shell::config_paths` (which uses cmd only for -/// wrapper-shell file names). -fn line_based_config_paths(shell: Shell, home: &Path) -> Vec { - match shell { - Shell::Bash => vec![home.join(".bashrc")], - Shell::Zsh => { - let zdotdir = std::env::var("ZDOTDIR") - .map(PathBuf::from) - .unwrap_or_else(|_| home.to_path_buf()); - vec![zdotdir.join(".zshrc")] - } - Shell::PowerShell => shell::powershell_profile_paths(home), - Shell::Fish | Shell::Nushell => Vec::new(), - } -} - -/// List `*.fish` files in `dir` whose content matches a worktrunk wrapper -/// (regardless of binary name). Returns empty if `dir` does not exist. -fn scan_fish_wrappers(dir: &Path) -> Result, String> { - scan_wrapper_directory(dir, "fish") -} - -/// List `*.nu` files in `dir` whose content matches a worktrunk wrapper. -fn scan_nushell_wrappers(dir: &Path) -> Result, String> { - scan_wrapper_directory(dir, "nu") -} - -fn scan_wrapper_directory(dir: &Path, extension: &str) -> Result, String> { +/// List `*.{extension}` files in `dir` whose content `is_managed` accepts. +/// Returns empty if `dir` does not exist; sorted so removal order is stable. +fn scan_managed_files( + dir: &Path, + extension: &str, + is_managed: fn(&str) -> bool, +) -> Result, String> { if !dir.exists() { return Ok(Vec::new()); } @@ -1279,7 +1264,7 @@ fn scan_wrapper_directory(dir: &Path, extension: &str) -> Result, S Ok(s) => s, Err(_) => continue, // unreadable file: skip, don't fail the whole uninstall }; - if is_worktrunk_managed_content_any_cmd(&content) { + if is_managed(&content) { out.push(path); } } @@ -1636,21 +1621,26 @@ mod tests { #[test] fn test_managed_content_any_cmd_matches_wrappers_not_mentions() { // Ours: the header every wrapper template carries, under any binary - // name, plus the headerless legacy `conf.d/{cmd}.fish` init line. + // name, plus the headerless legacy `conf.d/{cmd}.fish`, whose code + // lines are nothing but the init invocation. for content in [ "# worktrunk shell integration for fish\nfunction git-wt\nend\n", "# worktrunk shell integration for nushell\ndef --env wt [] {}\n", "wt config shell init fish | source", + "# added by worktrunk\nif type -q wt; command wt config shell init fish | source; end\n", ] { assert!(is_worktrunk_managed_content_any_cmd(content), "{content}"); } // Not ours: uninstall walks directories the user owns, so a file that - // only mentions the command must survive — including one that sources - // something unrelated elsewhere, which a file-wide match would delete. + // only mentions the command must survive — as must a user's own file + // that runs the init line amid other code, which a per-line any-match + // would delete whole. for content in [ "function notes\n echo run wt config shell init fish to set up\nend\n", "# reminder: wt config shell init fish\nfunction helpers\n cat ~/.aliases | source\nend\n", + "function helper\n command wt config shell init fish | source\nend\n", + "", ] { assert!(!is_worktrunk_managed_content_any_cmd(content), "{content}"); } @@ -1661,24 +1651,28 @@ mod tests { // Fish and Nushell are configured via wrapper files, not line-based rc // edits, so the line-based path list is empty for them. let home = Path::new("/home/user"); - assert!(line_based_config_paths(Shell::Fish, home).is_empty()); - assert!(line_based_config_paths(Shell::Nushell, home).is_empty()); + assert!(shell::line_based_config_paths(Shell::Fish, home).is_empty()); + assert!(shell::line_based_config_paths(Shell::Nushell, home).is_empty()); } #[test] - fn test_scan_wrapper_directory_skips_wrong_extension_and_directories() { + fn test_scan_managed_files_skips_wrong_extension_and_directories() { let dir = tempfile::TempDir::new().unwrap(); let root = dir.path(); // Wrong extension: skipped. fs::write(root.join("notes.txt"), "anything").unwrap(); // A directory carrying the wrapper extension: skipped (not a file). fs::create_dir(root.join("nested.fish")).unwrap(); - assert!(scan_fish_wrappers(root).unwrap().is_empty()); + assert!( + scan_managed_files(root, "fish", is_worktrunk_managed_content_any_cmd) + .unwrap() + .is_empty() + ); } #[cfg(unix)] #[test] - fn test_scan_wrapper_directory_skips_unreadable_file() { + fn test_scan_managed_files_skips_unreadable_file() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::TempDir::new().unwrap(); let root = dir.path(); @@ -1686,7 +1680,7 @@ mod tests { fs::write(&unreadable, "function wt\nend\n").unwrap(); fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o000)).unwrap(); // An unreadable wrapper file is skipped, not surfaced as an error. - let found = scan_fish_wrappers(root); + let found = scan_managed_files(root, "fish", is_worktrunk_managed_content_any_cmd); // Restore perms so TempDir cleanup can remove the file. fs::set_permissions(&unreadable, fs::Permissions::from_mode(0o644)).unwrap(); assert!(found.unwrap().is_empty()); diff --git a/src/shell/detection.rs b/src/shell/detection.rs index 219147595c..0bbc7e5c2b 100644 --- a/src/shell/detection.rs +++ b/src/shell/detection.rs @@ -85,15 +85,27 @@ use super::paths::{home_dir_required, powershell_profile_paths}; /// This means false negatives only cause incorrect messaging in `wt config show` /// and when users run the binary directly before restarting their shell. pub fn is_shell_integration_line(line: &str, cmd: &str) -> bool { - is_shell_integration_line_impl(line, cmd, true) + has_init_invocation(line_code_portion(line), cmd) } -/// Permissive version for uninstall - matches old PowerShell configs without `| Out-String`. +/// The code portion of a config line: empty for comment lines, otherwise the +/// text before any trailing comment — a comment can mention an integration line +/// without running it, so detection reads only the code. /// -/// Used by `wt config shell uninstall` to find and remove outdated config lines -/// that would otherwise be left behind. -pub fn is_shell_integration_line_for_uninstall(line: &str, cmd: &str) -> bool { - is_shell_integration_line_impl(line, cmd, false) +/// A `#` opens a comment only at a word start (line start or after whitespace); +/// `$#` and PowerShell's `<#` stay code. Quote-blind by design: no integration +/// form worktrunk writes contains `#`. +fn line_code_portion(line: &str) -> &str { + let trimmed = line.trim(); + // Comment lines (# for POSIX shells, <# #> for PowerShell block comments) + if trimmed.starts_with('#') || trimmed.starts_with("<#") { + return ""; + } + let end = trimmed + .char_indices() + .find(|&(i, c)| c == '#' && trimmed[..i].ends_with(char::is_whitespace)) + .map_or(trimmed.len(), |(i, _)| i); + &trimmed[..end] } /// The invariant part of every integration line. Only the binary name in front @@ -117,14 +129,11 @@ const INIT_MARKER: &str = " config shell init"; /// Used by `wt config shell uninstall`, which removes every worktrunk-managed /// integration it finds. pub fn is_shell_integration_line_for_uninstall_any_cmd(line: &str) -> bool { - let trimmed = line.trim(); - if trimmed.starts_with('#') || trimmed.starts_with("<#") { - return false; - } - is_execution_context(trimmed, false) - && trimmed + let code = line_code_portion(line); + is_execution_context(code, false) + && code .match_indices(INIT_MARKER) - .any(|(pos, _)| command_name_precedes(trimmed, pos)) + .any(|(pos, _)| command_name_precedes(code, pos)) } /// Does a plausible command name end at `pos`? @@ -150,9 +159,10 @@ fn command_name_precedes(line: &str, pos: usize) -> bool { /// /// PowerShell is checked first, case-insensitively. It needs `| Out-String` to /// work — without it `Invoke-Expression` fails with "Cannot convert -/// 'System.Object[]'" (issue #885). Strict mode leaves an old config lacking it -/// undetected so `wt config shell install` will rewrite it; permissive mode -/// (uninstall) matches it so it can be removed. +/// 'System.Object[]'" (issue #885). Strict mode (the cmd-specific install-side +/// detector) leaves an old config lacking it undetected so `wt config shell +/// install` will rewrite it; the cmd-agnostic uninstall detector passes +/// `strict: false` so the old line can be removed. fn is_execution_context(line: &str, strict: bool) -> bool { let line_lower = line.to_lowercase(); if line_lower.contains("invoke-expression") || line_lower.contains("iex") { @@ -167,36 +177,21 @@ fn is_execution_context(line: &str, strict: bool) -> bool { || line.contains("save") // nushell pipe to save } -fn is_shell_integration_line_impl(line: &str, cmd: &str, strict: bool) -> bool { - let trimmed = line.trim(); - - // Skip comments (# for POSIX shells, <# #> for PowerShell block comments) - if trimmed.starts_with('#') || trimmed.starts_with("<#") { - return false; - } - - // Check for eval/source line pattern - has_init_invocation(trimmed, cmd, strict) -} - /// Check if line contains `{cmd} config shell init` as a command invocation. /// /// For `wt`: matches `wt config shell init` but NOT `git wt` or `git-wt`. /// For `git-wt`: matches `git-wt config shell init` OR `git wt config shell init`. -/// -/// When `strict` is true, PowerShell lines must include `| Out-String` to match. -/// When `strict` is false (for uninstall), old PowerShell lines without it also match. -fn has_init_invocation(line: &str, cmd: &str, strict: bool) -> bool { +fn has_init_invocation(line: &str, cmd: &str) -> bool { // For git-wt, we need to match both "git-wt config shell init" AND "git wt config shell init" // because users invoke it both ways (and git dispatches "git wt" to "git-wt") if cmd == "git-wt" { // Match either form, with boundary check for "git" in "git wt" form - return has_init_pattern_with_prefix_check(line, "git-wt", strict) - || has_init_pattern_with_prefix_check(line, "git wt", strict); + return has_init_pattern_with_prefix_check(line, "git-wt") + || has_init_pattern_with_prefix_check(line, "git wt"); } // For other commands, use normal matching with prefix exclusion - has_init_pattern_with_prefix_check(line, cmd, strict) + has_init_pattern_with_prefix_check(line, cmd) } /// Check if line has the init pattern, with prefix exclusion for non-git-wt commands. @@ -206,11 +201,8 @@ fn has_init_invocation(line: &str, cmd: &str, strict: bool) -> bool { /// ```text /// eval "$(git-wt.exe config shell init bash)" /// ``` -/// -/// When `strict` is true, PowerShell lines must include `| Out-String`. -/// When `strict` is false (for uninstall), old PowerShell lines also match. -fn has_init_pattern_with_prefix_check(line: &str, cmd: &str, strict: bool) -> bool { - if !is_execution_context(line, strict) { +fn has_init_pattern_with_prefix_check(line: &str, cmd: &str) -> bool { + if !is_execution_context(line, true) { return false; } @@ -796,37 +788,25 @@ mod tests { ); } - /// Permissive mode (for uninstall) SHOULD detect old PowerShell lines without | Out-String + /// A trailing comment can mention an integration line without running it; + /// detection reads only the code portion of the line. #[test] - fn test_powershell_permissive_mode_for_uninstall() { - // Old configs should be detected by the permissive function (for uninstall) - assert!( - is_shell_integration_line_for_uninstall("iex (wt config shell init powershell)", "wt"), - "Permissive mode should detect old PowerShell config" - ); - assert!( - is_shell_integration_line_for_uninstall( - "Invoke-Expression (& wt config shell init powershell)", - "wt" - ), - "Permissive mode should detect old Invoke-Expression config" - ); - // The exact old canonical line - assert!( - is_shell_integration_line_for_uninstall( - "if (Get-Command wt -ErrorAction SilentlyContinue) { Invoke-Expression (& wt config shell init powershell) }", - "wt" - ), - "Permissive mode should detect exact old canonical PowerShell line" - ); - // New configs should also be detected - assert!( - is_shell_integration_line_for_uninstall( - "iex (wt config shell init powershell | Out-String)", - "wt" - ), - "Permissive mode should also detect new PowerShell config" - ); + fn test_trailing_comment_is_not_code() { + // The comment mentions the init call; the code runs something else. + // Deleting this line would take the user's direnv hook with it. + let line = r#"eval "$(direnv hook bash)" # see: wt config shell init bash"#; + assert_not_detects(line, "wt", "mention in trailing comment"); + assert!(!is_shell_integration_line_for_uninstall_any_cmd(line)); + + // A real integration line keeps matching with a comment after it, + // and `$#` does not open a comment. + for line in [ + r#"eval "$(wt config shell init bash)" # worktrunk"#, + r#"if [ $# -eq 0 ]; then :; fi; eval "$(wt config shell init bash)""#, + ] { + assert_detects(line, "wt", "code before trailing comment"); + assert!(is_shell_integration_line_for_uninstall_any_cmd(line)); + } } #[test] @@ -841,6 +821,10 @@ mod tests { r#"eval "$(/opt/homebrew/bin/my.tool_1 config shell init bash)""#, "if type -q wt; command wt config shell init fish | source; end", "iex (wt config shell init powershell | Out-String)", + // Old PowerShell forms without `| Out-String`, which uninstall + // must still remove (permissive execution-context check). + "iex (wt config shell init powershell)", + "if (Get-Command wt -ErrorAction SilentlyContinue) { Invoke-Expression (& wt config shell init powershell) }", ] { assert!( is_shell_integration_line_for_uninstall_any_cmd(line), diff --git a/src/shell/mod.rs b/src/shell/mod.rs index 7660fe3b6e..330639263c 100644 --- a/src/shell/mod.rs +++ b/src/shell/mod.rs @@ -16,12 +16,11 @@ use askama::Template; // Re-export public types and functions pub use detection::{ BypassAlias, DetectedLine, FileDetectionResult, is_shell_integration_line, - is_shell_integration_line_for_uninstall, is_shell_integration_line_for_uninstall_any_cmd, - scan_for_detection_details, + is_shell_integration_line_for_uninstall_any_cmd, scan_for_detection_details, }; pub use paths::{ completion_path, config_paths, home_dir_required, legacy_fish_conf_d_path, - nushell_autoload_candidates, powershell_profile_paths, + line_based_config_paths, nushell_autoload_candidates, }; pub use utils::{ AncestorShell, ancestor_shell, current_shell, current_shell_name, detect_zsh_compinit, diff --git a/src/shell/paths.rs b/src/shell/paths.rs index 53a8dd0b54..2e6cb22dce 100644 --- a/src/shell/paths.rs +++ b/src/shell/paths.rs @@ -209,14 +209,14 @@ pub fn powershell_profile_paths(home: &std::path::Path) -> Vec { } } -/// Returns the config file paths for a shell. +/// Rc/profile files scanned line-by-line for integration lines. /// -/// The `cmd` parameter affects the Fish functions filename (e.g., `wt.fish` or `git-wt.fish`). -/// Returns paths in order of preference. The first existing file should be used. -pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std::io::Error> { - let home = home_dir_required()?; - - Ok(match shell { +/// Bash/Zsh/PowerShell integration is one line in an rc file, so these paths +/// are independent of the binary name; Fish and Nushell use per-name wrapper +/// files instead and have no line-based config. `config_paths` builds on this +/// for the line-based shells, so install and uninstall cannot drift apart. +pub fn line_based_config_paths(shell: super::Shell, home: &std::path::Path) -> Vec { + match shell { super::Shell::Bash => { // Use .bashrc - sourced by interactive shells (login shells should source .bashrc) vec![home.join(".bashrc")] @@ -224,9 +224,25 @@ pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std: super::Shell::Zsh => { let zdotdir = std::env::var("ZDOTDIR") .map(PathBuf::from) - .unwrap_or_else(|_| home.clone()); + .unwrap_or_else(|_| home.to_path_buf()); vec![zdotdir.join(".zshrc")] } + super::Shell::PowerShell => powershell_profile_paths(home), + super::Shell::Fish | super::Shell::Nushell => Vec::new(), + } +} + +/// Returns the config file paths for a shell. +/// +/// The `cmd` parameter affects the Fish functions filename (e.g., `wt.fish` or `git-wt.fish`). +/// Returns paths in order of preference. The first existing file should be used. +pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std::io::Error> { + let home = home_dir_required()?; + + Ok(match shell { + super::Shell::Bash | super::Shell::Zsh | super::Shell::PowerShell => { + line_based_config_paths(shell, &home) + } super::Shell::Fish => { // For fish, we write to functions/ which is autoloaded on first use. // This ensures PATH is fully configured before our function loads, @@ -250,7 +266,6 @@ pub fn config_paths(shell: super::Shell, cmd: &str) -> Result, std: .map(|autoload_dir| autoload_dir.join(format!("{}.nu", cmd))) .collect() } - super::Shell::PowerShell => powershell_profile_paths(&home), }) } diff --git a/tests/integration_tests/configure_shell.rs b/tests/integration_tests/configure_shell.rs index 56ddec6dff..7ab6f3b074 100644 --- a/tests/integration_tests/configure_shell.rs +++ b/tests/integration_tests/configure_shell.rs @@ -433,7 +433,11 @@ fn test_configure_shell_fish_legacy_cleanup_even_when_already_exists( // Also create completions (so it reports "all already configured") let completions_d = temp_home.path().join(".config/fish/completions"); fs::create_dir_all(&completions_d).unwrap(); - fs::write(completions_d.join("wt.fish"), "# completions").unwrap(); + fs::write( + completions_d.join("wt.fish"), + "# worktrunk completions for fish\ncomplete --command wt\n", + ) + .unwrap(); // Create legacy conf.d file (old location that should be cleaned up) let conf_d = temp_home.path().join(".config/fish/conf.d"); @@ -499,7 +503,11 @@ fn test_uninstall_shell_fish_legacy_conf_d_cleanup(repo: TestRepo, temp_home: Te let completions_d = temp_home.path().join(".config/fish/completions"); fs::create_dir_all(&completions_d).unwrap(); let completions_file = completions_d.join("wt.fish"); - fs::write(&completions_file, "# completions").unwrap(); + fs::write( + &completions_file, + "# worktrunk completions for fish\ncomplete --command wt\n", + ) + .unwrap(); let settings = setup_home_snapshot_settings(&temp_home); settings.bind(|| { @@ -609,7 +617,11 @@ fn test_configure_shell_fish_dry_run_does_not_delete_legacy(repo: TestRepo, temp // Create completions (so it reports "all already configured") let completions_d = temp_home.path().join(".config/fish/completions"); fs::create_dir_all(&completions_d).unwrap(); - fs::write(completions_d.join("wt.fish"), "# completions").unwrap(); + fs::write( + completions_d.join("wt.fish"), + "# worktrunk completions for fish\ncomplete --command wt\n", + ) + .unwrap(); // Create legacy conf.d file that should NOT be deleted in dry-run mode let conf_d = temp_home.path().join(".config/fish/conf.d"); @@ -1260,6 +1272,22 @@ fn test_uninstall_scan_removes_custom_cmd_fish_files(repo: TestRepo, temp_home: fs::write(&default_function, "function wt\nend\n").unwrap(); fs::write(&default_completion, "complete --command wt\n").unwrap(); + // A second worktrunk-managed wrapper left over from an earlier install + // under another name, and an orphaned completion whose wrapper is already + // gone: both carry worktrunk's markers, so scan-all removes them too. + let stale_wrapper = fish_functions.join("old-wt.fish"); + fs::write( + &stale_wrapper, + "# worktrunk shell integration for fish\nfunction old-wt\nend\n", + ) + .unwrap(); + let orphan_completion = fish_completions.join("stale.fish"); + fs::write( + &orphan_completion, + "# worktrunk completions for fish\ncomplete --command stale\n", + ) + .unwrap(); + let mut install_cmd = wt_command(); repo.configure_wt_cmd(&mut install_cmd); set_temp_home_env(&mut install_cmd, temp_home.path()); @@ -1296,15 +1324,54 @@ fn test_uninstall_scan_removes_custom_cmd_fish_files(repo: TestRepo, temp_home: String::from_utf8_lossy(&uninstall_output.stderr) ); - // Scan-all removes git-wt.fish (worktrunk-managed by content) but leaves the - // hand-written wt.fish stub alone — it lacks the `config shell init … | source` - // marker so it doesn't look like a worktrunk wrapper. + // Scan-all removes every worktrunk-managed file (git-wt install, the stale + // second wrapper, the orphaned completion) but leaves the hand-written + // wt.fish stubs alone — they carry no worktrunk marker. assert!(!custom_function.exists()); assert!(!custom_completion.exists()); + assert!(!stale_wrapper.exists()); + assert!(!orphan_completion.exists()); assert!(default_function.exists()); assert!(default_completion.exists()); } +#[cfg(unix)] +#[rstest] +fn test_uninstall_shell_powershell_removes_integration_line(repo: TestRepo, temp_home: TempDir) { + // Unix-only: the Windows profile path comes from the real Documents + // folder, which a temp HOME does not redirect. + let profile_dir = temp_home.path().join(".config/powershell"); + fs::create_dir_all(&profile_dir).unwrap(); + let profile = profile_dir.join("Microsoft.PowerShell_profile.ps1"); + fs::write( + &profile, + "# my profile\nInvoke-Expression (& wt config shell init powershell)\nSet-Alias ll Get-ChildItem\n", + ) + .unwrap(); + + let mut cmd = wt_command(); + repo.configure_wt_cmd(&mut cmd); + set_temp_home_env(&mut cmd, temp_home.path()); + cmd.args(["config", "shell", "uninstall", "powershell", "--yes"]) + .current_dir(repo.root_path()); + + let output = cmd.output().expect("Failed to execute uninstall"); + assert!( + output.status.success(), + "Uninstall should succeed:\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // The old pre-Out-String integration line is removed; everything else stays. + let content = fs::read_to_string(&profile).unwrap(); + assert!( + !content.contains("config shell init"), + "Integration line should be removed:\n{content}" + ); + assert!(content.contains("# my profile")); + assert!(content.contains("Set-Alias ll Get-ChildItem")); +} + #[rstest] fn test_uninstall_scan_removes_custom_cmd_nushell_file(repo: TestRepo, temp_home: TempDir) { let home = canonical_temp_home(&temp_home); @@ -1802,7 +1869,11 @@ fn test_uninstall_shell_dry_run_fish(repo: TestRepo, temp_home: TempDir) { let completions_d = temp_home.path().join(".config/fish/completions"); fs::create_dir_all(&completions_d).unwrap(); let completions_file = completions_d.join("wt.fish"); - fs::write(&completions_file, "# fish completions").unwrap(); + fs::write( + &completions_file, + "# worktrunk completions for fish\ncomplete --command wt\n", + ) + .unwrap(); let settings = setup_home_snapshot_settings(&temp_home); settings.bind(|| { @@ -1845,7 +1916,11 @@ fn test_uninstall_shell_dry_run_fish_canonical(repo: TestRepo, temp_home: TempDi let completions_d = temp_home.path().join(".config/fish/completions"); fs::create_dir_all(&completions_d).unwrap(); let completions_file = completions_d.join("wt.fish"); - fs::write(&completions_file, "# fish completions").unwrap(); + fs::write( + &completions_file, + "# worktrunk completions for fish\ncomplete --command wt\n", + ) + .unwrap(); let settings = setup_home_snapshot_settings(&temp_home); settings.bind(|| {