diff --git a/.claude-plugin/skills/worktrunk/reference/switch.md b/.claude-plugin/skills/worktrunk/reference/switch.md
index c9305e262..ac07891ee 100644
--- a/.claude-plugin/skills/worktrunk/reference/switch.md
+++ b/.claude-plugin/skills/worktrunk/reference/switch.md
@@ -109,6 +109,20 @@ Usage: wt switch [OPTIONS] --no-verify
Skip hooks
+ -t, --tmux
+ Run in new tmux window/session
+
+ Creates the worktree in a new tmux window (if already in tmux) or
+ session (if not). All hooks run there instead of the current terminal.
+ By default, the session/window is attached. Use --detach for
+ background.
+
+ -d, --detach
+ Run tmux session in background
+
+ Only valid with --tmux. Creates a detached session instead of
+ attaching to it.
+
-h, --help
Print help (see a summary with '-h')
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1794fb34b..e5358089f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -71,8 +71,9 @@ repos:
entry: '\.output\(\)'
# shell_exec.rs: defines run() which legitimately calls .output()
# select.rs: skim API's selected.output() is not Command::output()
+ # tmux.rs: quick tmux status checks that don't need logging
# tests/benches: test utilities run commands directly
- exclude: '^(src/shell_exec\.rs|src/commands/select\.rs|tests/|benches/)'
+ exclude: '^(src/shell_exec\.rs|src/commands/select\.rs|src/commands/tmux\.rs|tests/|benches/)'
ci:
# pre-commit.ci doesn't have Rust toolchain, so skip Rust-specific hooks.
diff --git a/docs/content/switch.md b/docs/content/switch.md
index ca707a9b3..f94dd9796 100644
--- a/docs/content/switch.md
+++ b/docs/content/switch.md
@@ -132,6 +132,20 @@ Usage: wt switch [OPTIONS] --no-verify
Skip hooks
+ -t, --tmux
+ Run in new tmux window/session
+
+ Creates the worktree in a new tmux window (if already in tmux) or
+ session (if not). All hooks run there instead of the current terminal.
+ By default, the session/window is attached. Use --detach for
+ background.
+
+ -d, --detach
+ Run tmux session in background
+
+ Only valid with --tmux. Creates a detached session instead of
+ attaching to it.
+
-h, --help
Print help (see a summary with '-h')
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index fe7d73e65..7b37a1322 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -346,6 +346,21 @@ To change which branch a worktree is on, use `git switch` inside that worktree.
/// Skip hooks
#[arg(long = "no-verify", action = clap::ArgAction::SetFalse, default_value_t = true)]
verify: bool,
+
+ /// Run in new tmux window/session
+ ///
+ /// Creates the worktree in a new tmux window (if already in tmux) or
+ /// session (if not). All hooks run there instead of the current terminal.
+ /// By default, the session/window is attached. Use --detach for background.
+ #[arg(short = 't', long)]
+ tmux: bool,
+
+ /// Run tmux session in background
+ ///
+ /// Only valid with --tmux. Creates a detached session instead of
+ /// attaching to it.
+ #[arg(short = 'd', long, requires = "tmux")]
+ detach: bool,
},
/// List worktrees and their status
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index bdb3489ee..4a355d70f 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -19,6 +19,7 @@ pub mod repository_ext;
pub mod select;
pub mod statusline;
pub mod step_commands;
+pub mod tmux;
pub mod worktree;
pub use command_approval::approve_hooks;
diff --git a/src/commands/tmux.rs b/src/commands/tmux.rs
new file mode 100644
index 000000000..ecaffda03
--- /dev/null
+++ b/src/commands/tmux.rs
@@ -0,0 +1,283 @@
+//! Tmux integration for launching worktree operations in new windows/sessions.
+
+use anyhow::{Result, bail};
+use std::env;
+use std::process::Command;
+
+use worktrunk::shell_exec;
+
+/// Check if tmux is available in PATH.
+pub fn is_available() -> bool {
+ which::which("tmux").is_ok()
+}
+
+/// Check if we're inside a tmux session.
+pub fn is_inside_tmux() -> bool {
+ env::var("TMUX").is_ok()
+}
+
+/// Sanitize branch name for tmux session/window name.
+///
+/// Tmux doesn't allow certain characters in session/window names.
+fn sanitize_name(branch: &str) -> String {
+ branch
+ .chars()
+ .map(|c| if matches!(c, '/' | '.' | ':') { '-' } else { c })
+ .collect()
+}
+
+/// Options for spawning a worktree switch in tmux.
+pub struct TmuxSwitchOptions<'a> {
+ pub branch: &'a str,
+ pub create: bool,
+ pub base: Option<&'a str>,
+ pub execute: Option<&'a str>,
+ pub execute_args: &'a [String],
+ pub yes: bool,
+ pub clobber: bool,
+ pub verify: bool,
+ pub detach: bool,
+}
+
+/// Check if a tmux session with the given name exists.
+fn session_exists(name: &str) -> bool {
+ Command::new("tmux")
+ .args(["has-session", "-t", name])
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+/// Check if a window with the given name exists in the current tmux session.
+fn window_exists(name: &str) -> bool {
+ Command::new("tmux")
+ .args(["list-windows", "-F", "#{window_name}"])
+ .output()
+ .map(|o| {
+ String::from_utf8_lossy(&o.stdout)
+ .lines()
+ .any(|line| line == name)
+ })
+ .unwrap_or(false)
+}
+
+/// Environment variables to skip when passing to tmux session.
+const SKIP_ENV_VARS: &[&str] = &[
+ "PWD",
+ "OLDPWD",
+ "_",
+ "SHLVL",
+ "TMUX",
+ "TMUX_PANE",
+ "TERM_SESSION_ID",
+ "SHELL_SESSION_ID",
+ "WORKTRUNK_DIRECTIVE_FILE",
+ "COMP_LINE",
+ "COMP_POINT",
+];
+
+/// Write environment variables to a temp file and return the path.
+/// The file contains export statements that can be sourced.
+fn write_env_file() -> Option {
+ use std::io::Write;
+
+ let path = std::env::temp_dir().join(format!("wt-env-{}", std::process::id()));
+ let mut file = std::fs::File::create(&path).ok()?;
+
+ for (key, value) in env::vars() {
+ if SKIP_ENV_VARS.contains(&key.as_str()) || key.starts_with("__") {
+ continue;
+ }
+ if let Ok(escaped) = shlex::try_quote(&value) {
+ writeln!(file, "export {}={}", key, escaped).ok()?;
+ }
+ }
+
+ Some(path)
+}
+
+/// Capture the visible output from a tmux pane, cleaning up whitespace.
+fn capture_pane(session: &str) -> String {
+ Command::new("tmux")
+ .args(["capture-pane", "-t", session, "-p"])
+ .output()
+ .map(|o| {
+ let raw = String::from_utf8_lossy(&o.stdout);
+ // Filter out blank lines and tmux's "Pane is dead" message
+ raw.lines()
+ .filter(|line| {
+ let trimmed = line.trim();
+ !trimmed.is_empty() && !trimmed.starts_with("Pane is dead")
+ })
+ .collect::>()
+ .join("\n")
+ })
+ .unwrap_or_default()
+}
+
+/// Result of spawning a tmux session/window.
+pub enum TmuxSpawnResult {
+ /// Created detached session with this name
+ Detached(String),
+ /// Detached session failed quickly - includes captured output
+ DetachedFailed { name: String, output: String },
+ /// Created new window (already in tmux)
+ Window(String),
+ /// Switched to existing window (was inside tmux)
+ SwitchedWindow(String),
+}
+
+/// Spawn worktree switch in a new tmux window/session.
+///
+/// # Returns
+/// * `TmuxSpawnResult::Window` - If already in tmux, created new window
+/// * `TmuxSpawnResult::Detached` - If not in tmux and detach=true
+/// * Never returns if not in tmux and detach=false (exec replaces process)
+#[cfg(unix)]
+pub fn spawn_switch_in_tmux(opts: &TmuxSwitchOptions<'_>) -> Result {
+ use std::os::unix::process::CommandExt;
+
+ if !is_available() {
+ bail!("tmux not found. Install tmux or run without --tmux");
+ }
+
+ let name = sanitize_name(opts.branch);
+
+ // Build the wt command to run inside tmux
+ let mut wt_args = vec!["switch".to_string()];
+ if opts.create {
+ wt_args.push("--create".to_string());
+ }
+ wt_args.push(opts.branch.to_string());
+ if let Some(b) = opts.base {
+ wt_args.push("--base".to_string());
+ wt_args.push(b.to_string());
+ }
+ if let Some(cmd) = opts.execute {
+ wt_args.push("--execute".to_string());
+ wt_args.push(cmd.to_string());
+ }
+ if opts.yes {
+ wt_args.push("--yes".to_string());
+ }
+ if opts.clobber {
+ wt_args.push("--clobber".to_string());
+ }
+ if !opts.verify {
+ wt_args.push("--no-verify".to_string());
+ }
+ // Add execute_args after -- separator
+ if !opts.execute_args.is_empty() {
+ wt_args.push("--".to_string());
+ wt_args.extend(opts.execute_args.iter().cloned());
+ }
+
+ // Shell-escape each argument
+ let escaped_args: Vec = wt_args
+ .iter()
+ .map(|arg| shlex::try_quote(arg).unwrap_or(arg.into()).into_owned())
+ .collect();
+
+ // Write env vars to temp file for sourcing in tmux
+ let env_file = write_env_file();
+
+ // Command to run: source env file, then wt switch, then keep shell open
+ let wt_command = match &env_file {
+ Some(path) => format!(
+ "source {} && rm -f {} && wt {} && exec $SHELL",
+ path.display(),
+ path.display(),
+ escaped_args.join(" ")
+ ),
+ None => format!("wt {} && exec $SHELL", escaped_args.join(" ")),
+ };
+
+ if is_inside_tmux() {
+ // Already in tmux: check if window exists
+ if window_exists(&name) {
+ // Switch to existing window
+ let mut cmd = Command::new("tmux");
+ cmd.args(["select-window", "-t", &name]);
+ shell_exec::run(&mut cmd, None)?;
+ Ok(TmuxSpawnResult::SwitchedWindow(name))
+ } else {
+ // Create new window and switch to it
+ let mut cmd = Command::new("tmux");
+ cmd.args(["new-window", "-n", &name, &wt_command]);
+ shell_exec::run(&mut cmd, None)?;
+ Ok(TmuxSpawnResult::Window(name))
+ }
+ } else if session_exists(&name) {
+ // Session exists: attach to it (exec replaces process)
+ let mut cmd = Command::new("tmux");
+ cmd.args(["attach-session", "-t", &name]);
+ let err = cmd.exec();
+ Err(err.into())
+ } else if opts.detach {
+ // Not in tmux, detach mode: create detached session
+ // Use remain-on-exit so we can capture output if command fails quickly
+ let mut cmd = Command::new("tmux");
+ cmd.args([
+ "new-session",
+ "-d",
+ "-s",
+ &name,
+ "-x",
+ "200", // Wide enough to not wrap output
+ "-y",
+ "50",
+ &wt_command,
+ ]);
+ shell_exec::run(&mut cmd, None)?;
+
+ // Set remain-on-exit so pane stays if command fails
+ let mut cmd = Command::new("tmux");
+ cmd.args(["set-option", "-t", &name, "remain-on-exit", "on"]);
+ let _ = cmd.output(); // Ignore errors
+
+ // Wait briefly and check if the command is still running
+ std::thread::sleep(std::time::Duration::from_secs(2));
+
+ // Check if the pane is dead (command exited)
+ let pane_dead = Command::new("tmux")
+ .args(["list-panes", "-t", &name, "-F", "#{pane_dead}"])
+ .output()
+ .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "1")
+ .unwrap_or(false);
+
+ if pane_dead {
+ // Command exited quickly - likely an error
+ let output = capture_pane(&name);
+
+ // Kill the dead session
+ let mut cmd = Command::new("tmux");
+ cmd.args(["kill-session", "-t", &name]);
+ let _ = cmd.output();
+
+ Ok(TmuxSpawnResult::DetachedFailed {
+ name: name.clone(),
+ output,
+ })
+ } else {
+ // Command still running - turn off remain-on-exit for normal behavior
+ let mut cmd = Command::new("tmux");
+ cmd.args(["set-option", "-t", &name, "remain-on-exit", "off"]);
+ let _ = cmd.output();
+
+ Ok(TmuxSpawnResult::Detached(name))
+ }
+ } else {
+ // Not in tmux, attach mode: exec into tmux (replaces process)
+ let mut cmd = Command::new("tmux");
+ cmd.args(["new-session", "-s", &name, &wt_command]);
+ // exec() replaces the current process with tmux, so this only returns on error
+ let err = cmd.exec();
+ Err(err.into())
+ }
+}
+
+/// Windows stub - tmux is not available on Windows.
+#[cfg(not(unix))]
+pub fn spawn_switch_in_tmux(_opts: &TmuxSwitchOptions<'_>) -> Result {
+ bail!("tmux is not available on Windows")
+}
diff --git a/src/main.rs b/src/main.rs
index 54b1bd653..16b317966 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1265,144 +1265,213 @@ fn main() {
yes,
clobber,
verify,
- } => WorktrunkConfig::load()
- .context("Failed to load config")
- .and_then(|mut config| {
- // "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 approved = if verify {
- let repo = Repository::current();
- let repo_root = repo.worktree_base().context("Failed to switch worktree")?;
- // Compute worktree path for template expansion in approval prompt
- let worktree_path = compute_worktree_path(&repo, &branch, &config)?;
- let ctx = CommandContext::new(
- &repo,
- &config,
- Some(&branch),
- &worktree_path,
- &repo_root,
- yes,
- );
- // Approve different hooks based on whether we're creating or switching
- if create {
- approve_hooks(
- &ctx,
- &[
- HookType::PostCreate,
- HookType::PostStart,
- HookType::PostSwitch,
- ],
- )?
- } else {
- // When switching to existing, only post-switch needs approval
- approve_hooks(&ctx, &[HookType::PostSwitch])?
- }
- } else {
- true // --no-verify: skip all hooks
- };
-
- // Skip hooks if --no-verify or user declined approval
- let skip_hooks = !verify || !approved;
-
- // Show message if user declined approval
- if !approved {
- crate::output::print(info_message(if create {
- "Commands declined, continuing worktree creation"
- } else {
- "Commands declined"
- }))?;
- }
-
- // Execute switch operation (creates worktree, runs post-create hooks if approved)
- let (result, branch_info) = handle_switch(
- &branch,
+ tmux,
+ detach,
+ } => {
+ // Handle --tmux early: spawn in tmux and skip normal processing
+ if tmux {
+ use crate::commands::tmux::{self, TmuxSpawnResult, TmuxSwitchOptions};
+ use worktrunk::styling::success_message;
+
+ let opts = TmuxSwitchOptions {
+ branch: &branch,
create,
- base.as_deref(),
+ base: base.as_deref(),
+ execute: execute.as_deref(),
+ execute_args: &execute_args,
yes,
clobber,
- skip_hooks,
- &config,
- )?;
-
- // 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)
- let hooks_display_path =
- handle_switch_output(&result, &branch_info, execute.as_deref())?;
-
- // 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)
- // Best-effort: don't fail switch if offer fails
- if !output::is_shell_integration_active() {
- let skip_prompt = execute.is_some();
- let _ =
- output::prompt_shell_integration(&mut config, &binary_name(), skip_prompt);
- }
-
- // 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
- if !skip_hooks {
- let repo = Repository::current();
- let repo_root = repo.worktree_base().context("Failed to switch worktree")?;
- let ctx = CommandContext::new(
- &repo,
- &config,
- Some(&branch_info.branch),
- result.path(),
- &repo_root,
- yes,
- );
+ verify,
+ detach,
+ };
+ tmux::spawn_switch_in_tmux(&opts).and_then(|result| {
+ use worktrunk::styling::{error_message, format_with_gutter};
- // Build extra vars for base branch context
- // "base" is the branch we branched from when creating a new worktree.
- // For existing worktrees, there's no base concept.
- let (base_branch, base_worktree_path): (Option<&str>, Option<&str>) =
- match &result {
- SwitchResult::Created {
- base_branch,
- base_worktree_path,
- ..
- } => (base_branch.as_deref(), base_worktree_path.as_deref()),
- SwitchResult::Existing(_) | SwitchResult::AlreadyAt(_) => (None, None),
- };
- let extra_vars: Vec<(&str, &str)> = [
- base_branch.map(|b| ("base", b)),
- base_worktree_path.map(|p| ("base_worktree_path", p)),
- ]
- .into_iter()
- .flatten()
- .collect();
-
- // Post-switch runs first (immediate "I'm here" signal)
- ctx.spawn_post_switch_commands(&extra_vars, hooks_display_path.as_deref())?;
-
- // Post-start runs only on creation (setup tasks)
- if matches!(&result, SwitchResult::Created { .. }) {
- ctx.spawn_post_start_commands(&extra_vars, hooks_display_path.as_deref())?;
+ match result {
+ TmuxSpawnResult::Window(_name) => {
+ // Already in tmux, created new window - nothing to print,
+ // tmux new-window switches to the new window automatically
+ }
+ TmuxSpawnResult::SwitchedWindow(_name) => {
+ // Switched to existing window - nothing to print,
+ // tmux select-window switches automatically
+ }
+ TmuxSpawnResult::Detached(name) => {
+ output::print(success_message(cformat!(
+ "Worktree creation started in tmux session {name}>"
+ )))?;
+ output::print(hint_message(cformat!(
+ "Run tmux attach -t {name}> to view progress"
+ )))?;
+ }
+ TmuxSpawnResult::DetachedFailed { name, output } => {
+ output::print(error_message(cformat!(
+ "Worktree creation failed in tmux session {name}>"
+ )))?;
+ if !output.is_empty() {
+ output::print(format_with_gutter(&output, None))?;
+ }
+ return Err(anyhow::anyhow!("tmux session exited with error"));
+ }
}
- }
+ Ok(())
+ })
+ } else {
+ WorktrunkConfig::load()
+ .context("Failed to load config")
+ .and_then(|mut config| {
+ // "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 approved = if verify {
+ let repo = Repository::current();
+ let repo_root =
+ repo.worktree_base().context("Failed to switch worktree")?;
+ // Compute worktree path for template expansion in approval prompt
+ let worktree_path = compute_worktree_path(&repo, &branch, &config)?;
+ let ctx = CommandContext::new(
+ &repo,
+ &config,
+ Some(&branch),
+ &worktree_path,
+ &repo_root,
+ yes,
+ );
+ // Approve different hooks based on whether we're creating or switching
+ if create {
+ approve_hooks(
+ &ctx,
+ &[
+ HookType::PostCreate,
+ HookType::PostStart,
+ HookType::PostSwitch,
+ ],
+ )?
+ } else {
+ // When switching to existing, only post-switch needs approval
+ approve_hooks(&ctx, &[HookType::PostSwitch])?
+ }
+ } else {
+ true // --no-verify: skip all hooks
+ };
- // 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 {
- // Append any trailing args (after --) to the execute command
- let full_cmd = if execute_args.is_empty() {
- cmd
- } else {
- let escaped_args: Vec<_> = execute_args
- .iter()
- .map(|arg| shlex::try_quote(arg).unwrap_or(arg.into()).into_owned())
+ // Skip hooks if --no-verify or user declined approval
+ let skip_hooks = !verify || !approved;
+
+ // Show message if user declined approval
+ if !approved {
+ crate::output::print(info_message(if create {
+ "Commands declined, continuing worktree creation"
+ } else {
+ "Commands declined"
+ }))?;
+ }
+
+ // Execute switch operation (creates worktree, runs post-create hooks if approved)
+ let (result, branch_info) = handle_switch(
+ &branch,
+ create,
+ base.as_deref(),
+ yes,
+ clobber,
+ skip_hooks,
+ &config,
+ )?;
+
+ // 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)
+ let hooks_display_path =
+ handle_switch_output(&result, &branch_info, execute.as_deref())?;
+
+ // 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)
+ // Best-effort: don't fail switch if offer fails
+ if !output::is_shell_integration_active() {
+ let skip_prompt = execute.is_some();
+ let _ = output::prompt_shell_integration(
+ &mut config,
+ &binary_name(),
+ skip_prompt,
+ );
+ }
+
+ // 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
+ if !skip_hooks {
+ let repo = Repository::current();
+ let repo_root =
+ repo.worktree_base().context("Failed to switch worktree")?;
+ let ctx = CommandContext::new(
+ &repo,
+ &config,
+ Some(&branch_info.branch),
+ result.path(),
+ &repo_root,
+ yes,
+ );
+
+ // Build extra vars for base branch context
+ // "base" is the branch we branched from when creating a new worktree.
+ // For existing worktrees, there's no base concept.
+ let (base_branch, base_worktree_path): (Option<&str>, Option<&str>) =
+ match &result {
+ SwitchResult::Created {
+ base_branch,
+ base_worktree_path,
+ ..
+ } => (base_branch.as_deref(), base_worktree_path.as_deref()),
+ SwitchResult::Existing(_) | SwitchResult::AlreadyAt(_) => {
+ (None, None)
+ }
+ };
+ let extra_vars: Vec<(&str, &str)> = [
+ base_branch.map(|b| ("base", b)),
+ base_worktree_path.map(|p| ("base_worktree_path", p)),
+ ]
+ .into_iter()
+ .flatten()
.collect();
- format!("{} {}", cmd, escaped_args.join(" "))
- };
- execute_user_command(&full_cmd)?;
- }
- Ok(())
- }),
+ // Post-switch runs first (immediate "I'm here" signal)
+ ctx.spawn_post_switch_commands(
+ &extra_vars,
+ hooks_display_path.as_deref(),
+ )?;
+
+ // Post-start runs only on creation (setup tasks)
+ if matches!(&result, SwitchResult::Created { .. }) {
+ ctx.spawn_post_start_commands(
+ &extra_vars,
+ hooks_display_path.as_deref(),
+ )?;
+ }
+ }
+
+ // 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 {
+ // Append any trailing args (after --) to the execute command
+ let full_cmd = if execute_args.is_empty() {
+ cmd
+ } else {
+ let escaped_args: Vec<_> = execute_args
+ .iter()
+ .map(|arg| {
+ shlex::try_quote(arg).unwrap_or(arg.into()).into_owned()
+ })
+ .collect();
+ format!("{} {}", cmd, escaped_args.join(" "))
+ };
+ execute_user_command(&full_cmd)?;
+ }
+
+ Ok(())
+ })
+ }
+ }
Commands::Remove {
branches,
delete_branch,
diff --git a/tests/integration_tests/switch.rs b/tests/integration_tests/switch.rs
index 7d7b65361..298795f72 100644
--- a/tests/integration_tests/switch.rs
+++ b/tests/integration_tests/switch.rs
@@ -1046,3 +1046,23 @@ fn test_switch_create_no_hint_with_custom_worktree_path(repo: TestRepo) {
"Hint should be suppressed when user has custom worktree-path config"
);
}
+
+// --tmux flag tests
+
+#[rstest]
+fn test_switch_detach_requires_tmux(repo: TestRepo) {
+ // --detach requires --tmux
+ let output = repo
+ .wt_command()
+ .args(["switch", "--create", "feature", "--detach"])
+ .output()
+ .unwrap();
+
+ assert!(!output.status.success());
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ assert!(
+ stderr.contains("--tmux") || stderr.contains("required"),
+ "Should indicate --tmux is required, got: {}",
+ stderr
+ );
+}
diff --git a/tests/snapshots/integration__integration_tests__help__help_page_switch.snap b/tests/snapshots/integration__integration_tests__help__help_page_switch.snap
index 594e3a17d..b79f50cf0 100644
--- a/tests/snapshots/integration__integration_tests__help__help_page_switch.snap
+++ b/tests/snapshots/integration__integration_tests__help__help_page_switch.snap
@@ -144,6 +144,20 @@ Usage: [1m[36mwt switch[0m [36m[OPTIONS][0m [36m[0m [1m[36m[--
[1m[36m--no-verify[0m
Skip hooks
+ [1m[36m-t[0m, [1m[36m--tmux[0m
+ Run in new tmux window/session[0m
+ [0m
+ Creates the worktree in a new tmux window (if already in tmux) or
+ session (if not). All hooks run there instead of the current terminal.
+ By default, the session/window is attached. Use --detach for
+ background.[0m
+
+ [1m[36m-d[0m, [1m[36m--detach[0m
+ Run tmux session in background[0m
+ [0m
+ Only valid with --tmux. Creates a detached session instead of
+ attaching to it.[0m
+
[1m[36m-h[0m, [1m[36m--help[0m
Print help (see a summary with '-h')
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 5d82241c4..e70fe0333 100644
--- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap
+++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap
@@ -67,6 +67,17 @@ Usage: [1m[36mwt switch[0m [36m[OPTIONS][0m [36m[0m [1m[36m[--
[1m[36m--no-verify
Skip hooks
+ [1m[36m-t[0m, [1m[36m--tmux
+ Run in new tmux window/session
+
+ Creates the worktree in a new tmux window (if already in tmux) or session (if not). All hooks run there instead of the current terminal. By
+ default, the session/window is attached. Use --detach for background.
+
+ [1m[36m-d[0m, [1m[36m--detach
+ Run tmux session in background
+
+ Only valid with --tmux. Creates a detached session instead of attaching to it.
+
[1m[36m-h[0m, [1m[36m--help
Print help (see a summary with '-h')
diff --git a/tests/snapshots/integration__integration_tests__help__help_switch_short.snap b/tests/snapshots/integration__integration_tests__help__help_switch_short.snap
index 56611ed94..156801c7b 100644
--- a/tests/snapshots/integration__integration_tests__help__help_switch_short.snap
+++ b/tests/snapshots/integration__integration_tests__help__help_switch_short.snap
@@ -35,6 +35,8 @@ Usage: [1m[36mwt switch[0m [36m[OPTIONS][0m [36m[0m [1m[36m[--
[1m[36m-y[0m, [1m[36m--yes[0m Skip approval prompts
[1m[36m--clobber[0m Remove stale paths at target
[1m[36m--no-verify[0m Skip hooks
+ [1m[36m-t[0m, [1m[36m--tmux[0m Run in new tmux window/session
+ [1m[36m-d[0m, [1m[36m--detach[0m Run tmux session in background
[1m[36m-h[0m, [1m[36m--help[0m Print help (see more with '--help')
[1m[32mGlobal Options: