From ad8c30879f31ca46deb1e983b687fce1099cbda9 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 16:06:06 -0800 Subject: [PATCH 01/10] feat(copy-ignored): default to copying all gitignored files When no `.worktreeinclude` file exists, `wt step copy-ignored` now copies all gitignored files (as if `.worktreeinclude` contained `**`). This makes the command work out of the box without requiring configuration. Projects that want to limit what gets copied can still create a `.worktreeinclude` file to filter the files. Co-Authored-By: Claude --- .../skills/worktrunk/reference/hook.md | 4 +- .../skills/worktrunk/reference/step.md | 37 +++++++------- docs/content/hook.md | 4 +- docs/content/step.md | 37 +++++++------- src/cli/mod.rs | 6 +-- src/cli/step.rs | 34 ++++++------- src/commands/step_commands.rs | 51 ++++++++++--------- src/git/error.rs | 8 +++ tests/integration_tests/step_copy_ignored.rs | 50 +++++++++++++++++- ...tegration_tests__help__help_step_long.snap | 6 ++- ...egration_tests__help__help_step_short.snap | 4 +- ...ored__copy_ignored_default_copies_all.snap | 35 +++++++++++++ ..._copy_ignored_invalid_worktreeinclude.snap | 37 ++++++++++++++ ...ored__copy_ignored_no_worktreeinclude.snap | 2 +- 14 files changed, 224 insertions(+), 91 deletions(-) create mode 100644 tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_default_copies_all.snap create mode 100644 tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap diff --git a/.claude-plugin/skills/worktrunk/reference/hook.md b/.claude-plugin/skills/worktrunk/reference/hook.md index a75818d8e..ade972d1e 100644 --- a/.claude-plugin/skills/worktrunk/reference/hook.md +++ b/.claude-plugin/skills/worktrunk/reference/hook.md @@ -202,14 +202,14 @@ Many tasks work well in `post-start` — they'll likely be ready by the time the ### Copying untracked files -Git worktrees share the repository but not untracked files (dependencies, caches, `.env`). Use [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored) to copy files listed in `.worktreeinclude`: +Git worktrees share the repository but not untracked files (dependencies, caches, `.env`). Use [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored) to copy gitignored files: ```toml [post-create] copy = "wt step copy-ignored" ``` -See [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored) for setup, common patterns, and language-specific notes. +See [`wt step copy-ignored`](https://worktrunk.dev/step/#wt-step-copy-ignored) for limiting what gets copied, common patterns, and language-specific notes. ### Dev servers diff --git a/.claude-plugin/skills/worktrunk/reference/step.md b/.claude-plugin/skills/worktrunk/reference/step.md index 17d2392fb..d386f9820 100644 --- a/.claude-plugin/skills/worktrunk/reference/step.md +++ b/.claude-plugin/skills/worktrunk/reference/step.md @@ -26,7 +26,7 @@ wt step push - `squash` — Squash all branch commits into one with [LLM-generated message](https://worktrunk.dev/llm-commits/) - `rebase` — Rebase onto target branch - `push` — Fast-forward target to current branch -- `copy-ignored` — Copy files listed in `.worktreeinclude` +- `copy-ignored` — Copy gitignored files between worktrees - `for-each` — [experimental] Run a command in every worktree ## Options @@ -76,7 +76,7 @@ Usage: wt step [OPTIONS] squash Squash commits since branching push Fast-forward target to current branch rebase Rebase onto target - copy-ignored Copy .worktreeinclude files to another worktree + copy-ignored Copy gitignored files to another worktree for-each [experimental] Run command in each worktree Options: @@ -95,20 +95,10 @@ Usage: wt step [OPTIONS] .worktreeinclude files to another worktree +wt step copy-ignored - Copy gitignored files to another worktree -Copies files listed in .worktreeinclude that are also gitignored. Useful in -post-create hooks to sync local config files (.env, IDE settings) to new -worktrees. Skips symlinks and existing files. +Copies gitignored files to another worktree. By default copies all gitignored +files; use .worktreeinclude to limit what gets copied. Useful in post-create +hooks to sync local config files (.env, IDE settings) to new worktrees. Skips +symlinks and existing files. Usage: wt step copy-ignored [OPTIONS] diff --git a/docs/content/hook.md b/docs/content/hook.md index 89d94648c..e45e58a5d 100644 --- a/docs/content/hook.md +++ b/docs/content/hook.md @@ -210,14 +210,14 @@ Many tasks work well in `post-start` — they'll likely be ready by the time the ### Copying untracked files -Git worktrees share the repository but not untracked files (dependencies, caches, `.env`). Use [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) to copy files listed in `.worktreeinclude`: +Git worktrees share the repository but not untracked files (dependencies, caches, `.env`). Use [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) to copy gitignored files: ```toml [post-create] copy = "wt step copy-ignored" ``` -See [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) for setup, common patterns, and language-specific notes. +See [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) for limiting what gets copied, common patterns, and language-specific notes. ### Dev servers diff --git a/docs/content/step.md b/docs/content/step.md index 055985259..d4581e125 100644 --- a/docs/content/step.md +++ b/docs/content/step.md @@ -34,7 +34,7 @@ wt step push - `squash` — Squash all branch commits into one with [LLM-generated message](@/llm-commits.md) - `rebase` — Rebase onto target branch - `push` — Fast-forward target to current branch -- `copy-ignored` — Copy files listed in `.worktreeinclude` +- `copy-ignored` — Copy gitignored files between worktrees - `for-each` — [experimental] Run a command in every worktree ## Options @@ -90,7 +90,7 @@ Usage: wt step [OPTIONS] squash Squash commits since branching push Fast-forward target to current branch rebase Rebase onto target - copy-ignored Copy .worktreeinclude files to another worktree + copy-ignored Copy gitignored files to another worktree for-each [experimental] Run command in each worktree Options: @@ -110,20 +110,10 @@ Usage: wt step [OPTIONS] .worktreeinclude files to another worktree +wt step copy-ignored - Copy gitignored files to another worktree -Copies files listed in .worktreeinclude that are also gitignored. Useful in -post-create hooks to sync local config files (.env, IDE settings) to new -worktrees. Skips symlinks and existing files. +Copies gitignored files to another worktree. By default copies all gitignored +files; use .worktreeinclude to limit what gets copied. Useful in post-create +hooks to sync local config files (.env, IDE settings) to new worktrees. Skips +symlinks and existing files. Usage: wt step copy-ignored [OPTIONS] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index fe7d73e65..65af569e6 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -935,7 +935,7 @@ wt step push - `squash` — Squash all branch commits into one with [LLM-generated message](@/llm-commits.md) - `rebase` — Rebase onto target branch - `push` — Fast-forward target to current branch -- `copy-ignored` — Copy files listed in `.worktreeinclude` +- `copy-ignored` — Copy gitignored files between worktrees - `for-each` — [experimental] Run a command in every worktree ## Options @@ -1194,14 +1194,14 @@ Many tasks work well in `post-start` — they'll likely be ready by the time the ### Copying untracked files -Git worktrees share the repository but not untracked files (dependencies, caches, `.env`). Use [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) to copy files listed in `.worktreeinclude`: +Git worktrees share the repository but not untracked files (dependencies, caches, `.env`). Use [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) to copy gitignored files: ```toml [post-create] copy = "wt step copy-ignored" ``` -See [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) for setup, common patterns, and language-specific notes. +See [`wt step copy-ignored`](@/step.md#wt-step-copy-ignored) for limiting what gets copied, common patterns, and language-specific notes. ### Dev servers diff --git a/src/cli/step.rs b/src/cli/step.rs index 80d112729..f6461a9c8 100644 --- a/src/cli/step.rs +++ b/src/cli/step.rs @@ -76,27 +76,17 @@ pub enum StepCommand { target: Option, }, - /// Copy `.worktreeinclude` files to another worktree + /// Copy gitignored files to another worktree /// - /// Copies files listed in `.worktreeinclude` that are also gitignored. - /// Useful in post-create hooks to sync local config files - /// (`.env`, IDE settings) to new worktrees. Skips symlinks and existing - /// files. + /// Copies gitignored files to another worktree. By default copies all + /// gitignored files; use `.worktreeinclude` to limit what gets copied. + /// Useful in post-create hooks to sync local config files (`.env`, IDE + /// settings) to new worktrees. Skips symlinks and existing files. #[command( - after_long_help = r#"Git worktrees share the repository but not untracked files. This command copies files listed in `.worktreeinclude` to another worktree, eliminating cold starts. + after_long_help = r#"Git worktrees share the repository but not untracked files. This command copies gitignored files to another worktree, eliminating cold starts. ## Setup -Create a `.worktreeinclude` file in your repository root listing patterns to copy (uses gitignore syntax): - -```gitignore -# .worktreeinclude -.env -node_modules/ -target/ -.cache/ -``` - Add to your project config: ```toml @@ -105,9 +95,19 @@ Add to your project config: copy = "wt step copy-ignored" ``` +All gitignored files are copied by default, as if `.worktreeinclude` contained `**`. To copy only specific patterns, create a `.worktreeinclude` file using gitignore syntax: + +```gitignore +# .worktreeinclude — optional, limits what gets copied +.env +node_modules/ +target/ +.cache/ +``` + ## What gets copied -Files are copied only if they match **both** `.worktreeinclude` **and** are gitignored. This prevents accidentally copying tracked files. +Only gitignored files are copied — tracked files are never touched. If `.worktreeinclude` exists, files must match **both** `.worktreeinclude` **and** be gitignored. ## Common patterns diff --git a/src/commands/step_commands.rs b/src/commands/step_commands.rs index 86be0a022..6558b81a9 100644 --- a/src/commands/step_commands.rs +++ b/src/commands/step_commands.rs @@ -409,9 +409,11 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result { /// Handle `wt step copy-ignored` command /// -/// Copies files matching the intersection of `.worktreeinclude` and gitignored files -/// from a source worktree to a destination worktree. Uses COW (reflink) when -/// available for efficient copying of large directories like `target/`. +/// Copies gitignored files from a source worktree to a destination worktree. +/// If a `.worktreeinclude` file exists, only files matching both `.worktreeinclude` +/// and gitignore patterns are copied. Without `.worktreeinclude`, all gitignored +/// files are copied. Uses COW (reflink) when available for efficient copying of +/// large directories like `target/`. pub fn step_copy_ignored( from: Option<&str>, to: Option<&str>, @@ -445,34 +447,33 @@ pub fn step_copy_ignored( return Ok(()); } - // Check for .worktreeinclude file - let include_path = source_path.join(".worktreeinclude"); - if !include_path.exists() { - crate::output::print(info_message(cformat!( - "No .worktreeinclude file found" - )))?; - return Ok(()); - } - // Get ignored entries from git // --directory stops at directory boundaries (avoids listing thousands of files in target/) let ignored_entries = list_ignored_entries(&source_path, &source_context)?; - // Build include matcher from .worktreeinclude - let include_matcher = { - let mut builder = GitignoreBuilder::new(&source_path); - if let Some(err) = builder.add(&include_path) { - return Err(anyhow::anyhow!("Error parsing .worktreeinclude: {}", err)); - } - builder.build().context("Failed to build include matcher")? + // Filter to entries that match .worktreeinclude (or all if no file exists) + let include_path = source_path.join(".worktreeinclude"); + let entries_to_copy: Vec<_> = if include_path.exists() { + // Build include matcher from .worktreeinclude + let include_matcher = { + let mut builder = GitignoreBuilder::new(&source_path); + if let Some(err) = builder.add(&include_path) { + return Err(worktrunk::git::GitError::WorktreeIncludeParseError { + error: err.to_string(), + } + .into()); + } + builder.build().context("Failed to build include matcher")? + }; + ignored_entries + .into_iter() + .filter(|(path, is_dir)| include_matcher.matched(path, *is_dir).is_ignore()) + .collect() + } else { + // No .worktreeinclude file — default to copying all ignored entries + ignored_entries }; - // Filter to entries that match .worktreeinclude - let entries_to_copy: Vec<_> = ignored_entries - .into_iter() - .filter(|(path, is_dir)| include_matcher.matched(path, *is_dir).is_ignore()) - .collect(); - if entries_to_copy.is_empty() { crate::output::print(info_message("No matching files to copy"))?; return Ok(()); diff --git a/src/git/error.rs b/src/git/error.rs index feb93c747..223d0f523 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -131,6 +131,9 @@ pub enum GitError { ParseError { message: String, }, + WorktreeIncludeParseError { + error: String, + }, LlmCommandFailed { command: String, error: String, @@ -545,6 +548,11 @@ impl std::fmt::Display for GitError { write!(f, "{}", error_message(message)) } + GitError::WorktreeIncludeParseError { error } => { + let header = error_message(cformat!("Error parsing .worktreeinclude")); + write!(f, "{}", format_error_block(header, error)) + } + GitError::Other { message } => { write!(f, "{}", error_message(message)) } diff --git a/tests/integration_tests/step_copy_ignored.rs b/tests/integration_tests/step_copy_ignored.rs index dd495d8d2..08a3eb443 100644 --- a/tests/integration_tests/step_copy_ignored.rs +++ b/tests/integration_tests/step_copy_ignored.rs @@ -5,11 +5,57 @@ use insta_cmd::assert_cmd_snapshot; use rstest::rstest; use std::fs; -/// Test with no .worktreeinclude file +/// Test with no .worktreeinclude file and no gitignored files #[rstest] fn test_copy_ignored_no_worktreeinclude(mut repo: TestRepo) { let feature_path = repo.add_worktree("feature"); - // No .worktreeinclude file exists + // No .worktreeinclude file and no gitignored files → nothing to copy + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "step", + &["copy-ignored"], + Some(&feature_path), + )); +} + +/// Test default behavior: copies all gitignored files when no .worktreeinclude exists +#[rstest] +fn test_copy_ignored_default_copies_all(mut repo: TestRepo) { + let feature_path = repo.add_worktree("feature"); + + // Create gitignored files but NO .worktreeinclude + fs::write(repo.root_path().join(".env"), "SECRET=value").unwrap(); + fs::write(repo.root_path().join("cache.db"), "cached data").unwrap(); + fs::write(repo.root_path().join(".gitignore"), ".env\ncache.db\n").unwrap(); + + // Without .worktreeinclude, all gitignored files should be copied + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "step", + &["copy-ignored"], + Some(&feature_path), + )); + + // Verify both files were copied + assert!( + feature_path.join(".env").exists(), + ".env should be copied without .worktreeinclude" + ); + assert!( + feature_path.join("cache.db").exists(), + "cache.db should be copied without .worktreeinclude" + ); +} + +/// Test error handling when .worktreeinclude has invalid syntax +#[rstest] +fn test_copy_ignored_invalid_worktreeinclude(mut repo: TestRepo) { + let feature_path = repo.add_worktree("feature"); + + // Create invalid .worktreeinclude (unclosed brace in alternate group) + fs::write(repo.root_path().join(".worktreeinclude"), "{unclosed\n").unwrap(); + + // Should fail with parse error assert_cmd_snapshot!(make_snapshot_cmd( &repo, "step", diff --git a/tests/snapshots/integration__integration_tests__help__help_step_long.snap b/tests/snapshots/integration__integration_tests__help__help_step_long.snap index f1dd0f6ba..fff269d36 100644 --- a/tests/snapshots/integration__integration_tests__help__help_step_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_step_long.snap @@ -6,9 +6,11 @@ info: - step - "--help" env: + CARGO_LLVM_COV: "1" CLICOLOR_FORCE: "1" COLUMNS: "150" GIT_EDITOR: "" + LLVM_PROFILE_FILE: /Users/maximilian/workspace/worktrunk.worktreeinclude/target/llvm-cov-target/worktrunk.worktreeinclude-%p-%m.profraw RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" TERM: alacritty @@ -29,7 +31,7 @@ Usage: wt step [OPTIONS]  squash Squash commits since branching push Fast-forward target to current branch rebase Rebase onto target - copy-ignored Copy .worktreeinclude files to another worktree + copy-ignored Copy gitignored files to another worktree for-each [experimental] Run command in each worktree Options: @@ -68,7 +70,7 @@ Manual merge workflow with review between steps: - squash — Squash all branch commits into one with LLM-generated message - rebase — Rebase onto target branch - push — Fast-forward target to current branch -- copy-ignored — Copy files listed in .worktreeinclude +- copy-ignored — Copy gitignored files between worktrees - for-each — [experimental] Run a command in every worktree Options diff --git a/tests/snapshots/integration__integration_tests__help__help_step_short.snap b/tests/snapshots/integration__integration_tests__help__help_step_short.snap index d04bbf000..44f3242ac 100644 --- a/tests/snapshots/integration__integration_tests__help__help_step_short.snap +++ b/tests/snapshots/integration__integration_tests__help__help_step_short.snap @@ -6,9 +6,11 @@ info: - step - "-h" env: + CARGO_LLVM_COV: "1" CLICOLOR_FORCE: "1" COLUMNS: "150" GIT_EDITOR: "" + LLVM_PROFILE_FILE: /Users/maximilian/workspace/worktrunk.worktreeinclude/target/llvm-cov-target/worktrunk.worktreeinclude-%p-%m.profraw RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" TERM: alacritty @@ -29,7 +31,7 @@ Usage: wt step [OPTIONS]  squash Squash commits since branching push Fast-forward target to current branch rebase Rebase onto target - copy-ignored Copy .worktreeinclude files to another worktree + copy-ignored Copy gitignored files to another worktree for-each [experimental] Run command in each worktree Options: diff --git a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_default_copies_all.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_default_copies_all.snap new file mode 100644 index 000000000..9b022ed3a --- /dev/null +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_default_copies_all.snap @@ -0,0 +1,35 @@ +--- +source: tests/integration_tests/step_copy_ignored.rs +info: + program: wt + args: + - step + - copy-ignored + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: true +exit_code: 0 +----- stdout ----- + +----- stderr ----- +✓ Copied 2 entries diff --git a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap new file mode 100644 index 000000000..2b8b9f783 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/step_copy_ignored.rs +info: + program: wt + args: + - step + - copy-ignored + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ Error parsing .worktreeinclude +  _REPO_/.worktreeinclude: line 1: error parsing glob '{unclosed': unclosed +  alternate group; missing '}' (maybe escape '{' with '[{]'?) diff --git a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_no_worktreeinclude.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_no_worktreeinclude.snap index 7053e034e..be466b678 100644 --- a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_no_worktreeinclude.snap +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_no_worktreeinclude.snap @@ -32,4 +32,4 @@ exit_code: 0 ----- stdout ----- ----- stderr ----- -○ No .worktreeinclude file found +○ No matching files to copy From dea3f7c2e7a40f735b4f8cd54be48f639ca0c5ec Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 18:13:31 -0800 Subject: [PATCH 02/10] fix: use proper styling for branch names in errors Replace quoted branch names with bold styling in error messages to follow output formatting guidelines: - config.rs: Use GitError::InvalidReference for branch not found - step_commands.rs: Add WorktreeNotFound and WorktreeIncludeParseError GitError variants with proper styling - step_commands.rs: Use cformat! for rebase error message Co-Authored-By: Claude --- src/commands/config.rs | 5 ++++- src/commands/step_commands.rs | 18 +++++++++++------- src/git/error.rs | 11 +++++++++++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/commands/config.rs b/src/commands/config.rs index 2157f740c..db819466b 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -981,7 +981,10 @@ pub fn handle_state_get(key: &str, refresh: bool, branch: Option) -> any .unwrap_or_default(); if head.is_empty() { - anyhow::bail!("Branch '{branch_name}' not found"); + return Err(worktrunk::git::GitError::InvalidReference { + reference: branch_name, + } + .into()); } if refresh { diff --git a/src/commands/step_commands.rs b/src/commands/step_commands.rs index 6558b81a9..29c08da80 100644 --- a/src/commands/step_commands.rs +++ b/src/commands/step_commands.rs @@ -378,7 +378,7 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result { } // Not a rebase conflict, return original error return Err(worktrunk::git::GitError::Other { - message: format!("Failed to rebase onto '{}': {}", target_branch, e), + message: cformat!("Failed to rebase onto {}: {}", target_branch, e), } .into()); } @@ -427,18 +427,22 @@ pub fn step_copy_ignored( // Resolve source and destination worktree paths let (source_path, source_context) = match from { Some(branch) => { - let path = repo - .worktree_for_branch(branch)? - .ok_or_else(|| anyhow::anyhow!("No worktree found for branch '{}'", branch))?; + let path = repo.worktree_for_branch(branch)?.ok_or_else(|| { + worktrunk::git::GitError::WorktreeNotFound { + branch: branch.to_string(), + } + })?; (path, branch.to_string()) } None => (repo.worktree_base()?, repo.default_branch()?.to_string()), }; let dest_path = match to { - Some(branch) => repo - .worktree_for_branch(branch)? - .ok_or_else(|| anyhow::anyhow!("No worktree found for branch '{}'", branch))?, + Some(branch) => repo.worktree_for_branch(branch)?.ok_or_else(|| { + worktrunk::git::GitError::WorktreeNotFound { + branch: branch.to_string(), + } + })?, None => repo.worktree_root()?.to_path_buf(), }; diff --git a/src/git/error.rs b/src/git/error.rs index 223d0f523..586454792 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -143,6 +143,9 @@ pub enum GitError { ProjectConfigNotFound { config_path: PathBuf, }, + WorktreeNotFound { + branch: String, + }, Other { message: String, }, @@ -553,6 +556,14 @@ impl std::fmt::Display for GitError { write!(f, "{}", format_error_block(header, error)) } + GitError::WorktreeNotFound { branch } => { + write!( + f, + "{}", + error_message(cformat!("No worktree found for branch {branch}")) + ) + } + GitError::Other { message } => { write!(f, "{}", error_message(message)) } From fc4f1a431c036ba03b0ca2a46a9b3d9562750fcd Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 18:14:39 -0800 Subject: [PATCH 03/10] chore: sync skill file with docs Co-Authored-By: Claude --- .claude-plugin/skills/worktrunk/reference/step.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude-plugin/skills/worktrunk/reference/step.md b/.claude-plugin/skills/worktrunk/reference/step.md index d386f9820..f15356189 100644 --- a/.claude-plugin/skills/worktrunk/reference/step.md +++ b/.claude-plugin/skills/worktrunk/reference/step.md @@ -107,7 +107,7 @@ Add to your project config: copy = "wt step copy-ignored" ``` -By default, all gitignored files are copied (as if `.worktreeinclude` contained `**`). To copy only specific patterns, create a `.worktreeinclude` file in your repository root (uses gitignore syntax): +All gitignored files are copied by default, as if `.worktreeinclude` contained `**`. To copy only specific patterns, create a `.worktreeinclude` file using gitignore syntax: ```gitignore # .worktreeinclude — optional, limits what gets copied From a4c55ce48b8c15fc9eb33a3ca6ed4401a1d75f7d Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 18:24:48 -0800 Subject: [PATCH 04/10] fix: use fixed width for error blocks to prevent wrapping Error messages should display fully regardless of terminal size. Using fixed width (500) prevents test inconsistency between local development (which detects terminal size) and CI (which uses COLUMNS). Co-Authored-By: Claude --- src/git/error.rs | 6 +++++- ..._copy_ignored__copy_ignored_invalid_worktreeinclude.snap | 3 +-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/git/error.rs b/src/git/error.rs index 586454792..f2dca5f62 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -710,13 +710,17 @@ impl std::error::Error for HookErrorWithHint { } /// Format an error with header and gutter content +/// +/// Uses a fixed width (500) to prevent wrapping - error messages should +/// display fully regardless of terminal size. fn format_error_block(header: impl Into, error: &str) -> String { let header = header.into(); let trimmed = error.trim(); if trimmed.is_empty() { header } else { - format!("{header}\n{}", format_with_gutter(trimmed, None)) + // Use fixed large width to prevent wrapping in error messages + format!("{header}\n{}", format_with_gutter(trimmed, Some(500))) } } diff --git a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap index 2b8b9f783..439327247 100644 --- a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap @@ -33,5 +33,4 @@ exit_code: 1 ----- stderr ----- ✗ Error parsing .worktreeinclude -  _REPO_/.worktreeinclude: line 1: error parsing glob '{unclosed': unclosed -  alternate group; missing '}' (maybe escape '{' with '[{]'?) +  _REPO_/.worktreeinclude: line 1: error parsing glob '{unclosed': unclosed alternate group; missing '}' (maybe escape '{' with '[{]'?) From 15a6a2b13b6b6997b88ebf4db4ae30623af82481 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 18:34:37 -0800 Subject: [PATCH 05/10] test: add coverage for WorktreeNotFound error paths Add tests for --from and --to flags with branches that have no worktrees, covering the WorktreeNotFound error variant. Co-Authored-By: Claude --- tests/integration_tests/step_copy_ignored.rs | 40 +++++++++++++++++++ ...opy_ignored_from_nonexistent_worktree.snap | 37 +++++++++++++++++ ..._copy_ignored_to_nonexistent_worktree.snap | 37 +++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_from_nonexistent_worktree.snap create mode 100644 tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_to_nonexistent_worktree.snap diff --git a/tests/integration_tests/step_copy_ignored.rs b/tests/integration_tests/step_copy_ignored.rs index 08a3eb443..0b293efa1 100644 --- a/tests/integration_tests/step_copy_ignored.rs +++ b/tests/integration_tests/step_copy_ignored.rs @@ -494,3 +494,43 @@ fn test_copy_ignored_to_flag(mut repo: TestRepo) { "from-main" ); } + +/// Test --from with a branch that has no worktree +#[rstest] +fn test_copy_ignored_from_nonexistent_worktree(repo: TestRepo) { + // Create a branch without a worktree + repo.git_command() + .args(["branch", "orphan-branch"]) + .output() + .unwrap(); + + // Try to copy from a branch with no worktree + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "step", + &["copy-ignored", "--from", "orphan-branch"], + None, + )); +} + +/// Test --to with a branch that has no worktree +#[rstest] +fn test_copy_ignored_to_nonexistent_worktree(repo: TestRepo) { + // Create a branch without a worktree + repo.git_command() + .args(["branch", "orphan-branch"]) + .output() + .unwrap(); + + // Setup a file to copy + fs::write(repo.root_path().join(".env"), "value").unwrap(); + fs::write(repo.root_path().join(".gitignore"), ".env\n").unwrap(); + + // Try to copy to a branch with no worktree + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "step", + &["copy-ignored", "--to", "orphan-branch"], + None, + )); +} diff --git a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_from_nonexistent_worktree.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_from_nonexistent_worktree.snap new file mode 100644 index 000000000..057f1eb26 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_from_nonexistent_worktree.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/step_copy_ignored.rs +info: + program: wt + args: + - step + - copy-ignored + - "--from" + - orphan-branch + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ No worktree found for branch orphan-branch diff --git a/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_to_nonexistent_worktree.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_to_nonexistent_worktree.snap new file mode 100644 index 000000000..d28c2362f --- /dev/null +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_to_nonexistent_worktree.snap @@ -0,0 +1,37 @@ +--- +source: tests/integration_tests/step_copy_ignored.rs +info: + program: wt + args: + - step + - copy-ignored + - "--to" + - orphan-branch + env: + APPDATA: "[TEST_CONFIG_HOME]" + CLICOLOR_FORCE: "1" + COLUMNS: "150" + GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" + GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" + GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" + GIT_CONFIG_SYSTEM: /dev/null + GIT_EDITOR: "" + GIT_TERMINAL_PROMPT: "0" + HOME: "[TEST_HOME]" + LANG: C + LC_ALL: C + PATH: "[PATH]" + RUST_LOG: warn + SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty + USERPROFILE: "[TEST_HOME]" + WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" + WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" + XDG_CONFIG_HOME: "[TEST_CONFIG_HOME]" +--- +success: false +exit_code: 1 +----- stdout ----- + +----- stderr ----- +✗ No worktree found for branch orphan-branch From c7eea61d3aa62522a03b8a5e3da1dce7f16478e1 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 18:54:41 -0800 Subject: [PATCH 06/10] revert: remove unnecessary fixed width for error blocks The snapshot matches CI behavior (COLUMNS=150). Local terminal detection may differ but CI is authoritative. Co-Authored-By: Claude --- src/git/error.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/git/error.rs b/src/git/error.rs index f2dca5f62..586454792 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -710,17 +710,13 @@ impl std::error::Error for HookErrorWithHint { } /// Format an error with header and gutter content -/// -/// Uses a fixed width (500) to prevent wrapping - error messages should -/// display fully regardless of terminal size. fn format_error_block(header: impl Into, error: &str) -> String { let header = header.into(); let trimmed = error.trim(); if trimmed.is_empty() { header } else { - // Use fixed large width to prevent wrapping in error messages - format!("{header}\n{}", format_with_gutter(trimmed, Some(500))) + format!("{header}\n{}", format_with_gutter(trimmed, None)) } } From c384c4aec930549a622a9d5718f809a8128fb693 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 19:36:17 -0800 Subject: [PATCH 07/10] debug: add terminal detection diagnostic test Co-Authored-By: Claude --- src/styling/mod.rs | 25 +++++++- tests/integration_tests/step_copy_ignored.rs | 66 +++++++++++++++++--- 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/styling/mod.rs b/src/styling/mod.rs index c1abcd564..c2968fa37 100644 --- a/src/styling/mod.rs +++ b/src/styling/mod.rs @@ -46,11 +46,24 @@ const DEFAULT_TERMINAL_WIDTH: usize = 80; /// /// Checks stderr first (for status messages), then stdout (for table output). pub fn get_terminal_width() -> usize { + // DEBUG: Temporary logging to diagnose CI terminal detection issues + let debug = std::env::var("WT_DEBUG_TERMINAL").is_ok(); + // Prefer direct terminal detection (more accurate than COLUMNS which may be stale/wrong) // Check stderr first (status messages), then stdout (table output) - if let Some((terminal_size::Width(w), _)) = - terminal_size::terminal_size_of(std::io::stderr()).or_else(terminal_size::terminal_size) - { + let stderr_size = terminal_size::terminal_size_of(std::io::stderr()); + let fallback_size = terminal_size::terminal_size(); + + if debug { + eprintln!("[DEBUG] terminal_size_of(stderr): {:?}", stderr_size); + eprintln!("[DEBUG] terminal_size(): {:?}", fallback_size); + eprintln!("[DEBUG] COLUMNS env: {:?}", std::env::var("COLUMNS")); + } + + if let Some((terminal_size::Width(w), _)) = stderr_size.or(fallback_size) { + if debug { + eprintln!("[DEBUG] Using detected width: {}", w); + } return w as usize; } @@ -58,9 +71,15 @@ pub fn get_terminal_width() -> usize { if let Ok(cols) = std::env::var("COLUMNS") && let Ok(width) = cols.parse::() { + if debug { + eprintln!("[DEBUG] Using COLUMNS width: {}", width); + } return width; } + if debug { + eprintln!("[DEBUG] Using default width: {}", DEFAULT_TERMINAL_WIDTH); + } DEFAULT_TERMINAL_WIDTH } diff --git a/tests/integration_tests/step_copy_ignored.rs b/tests/integration_tests/step_copy_ignored.rs index 0b293efa1..3579fbe41 100644 --- a/tests/integration_tests/step_copy_ignored.rs +++ b/tests/integration_tests/step_copy_ignored.rs @@ -3,6 +3,49 @@ use crate::common::{TestRepo, make_snapshot_cmd, repo}; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; + +/// Diagnostic test to understand terminal detection on CI +#[test] +fn debug_terminal_detection() { + use std::process::Command; + + // What does the test runner process see? + eprintln!("=== Test runner process ==="); + eprintln!( + "terminal_size_of(stderr): {:?}", + terminal_size::terminal_size_of(std::io::stderr()) + ); + eprintln!( + "terminal_size_of(stdout): {:?}", + terminal_size::terminal_size_of(std::io::stdout()) + ); + eprintln!("terminal_size(): {:?}", terminal_size::terminal_size()); + eprintln!("COLUMNS env: {:?}", std::env::var("COLUMNS")); + + // What does a subprocess via Command::output() see? + // This matches how insta_cmd runs the wt binary + let output = Command::new("sh") + .args(["-c", "echo isatty_stdin=$([[ -t 0 ]] && echo yes || echo no) isatty_stdout=$([[ -t 1 ]] && echo yes || echo no) isatty_stderr=$([[ -t 2 ]] && echo yes || echo no)"]) + .output() + .unwrap(); + eprintln!("=== Subprocess isatty ==="); + eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + + // Run wt with debug flag to see what terminal size it detects + let wt_output = Command::new(insta_cmd::get_cargo_bin("wt")) + .args(["--help"]) // Any command that runs get_terminal_width + .env("COLUMNS", "150") + .env("CLICOLOR_FORCE", "1") + .env("WT_DEBUG_TERMINAL", "1") + .output() + .unwrap(); + eprintln!("=== wt subprocess terminal detection ==="); + eprintln!("stderr: {}", String::from_utf8_lossy(&wt_output.stderr)); + + // Fail deliberately to show output in CI (NEXTEST_SUCCESS_OUTPUT=never hides passing tests) + panic!("DIAGNOSTIC: Check the output above for terminal detection info"); +} + use std::fs; /// Test with no .worktreeinclude file and no gitignored files @@ -50,18 +93,27 @@ fn test_copy_ignored_default_copies_all(mut repo: TestRepo) { /// Test error handling when .worktreeinclude has invalid syntax #[rstest] fn test_copy_ignored_invalid_worktreeinclude(mut repo: TestRepo) { + use std::process::Command; + let feature_path = repo.add_worktree("feature"); // Create invalid .worktreeinclude (unclosed brace in alternate group) fs::write(repo.root_path().join(".worktreeinclude"), "{unclosed\n").unwrap(); - // Should fail with parse error - assert_cmd_snapshot!(make_snapshot_cmd( - &repo, - "step", - &["copy-ignored"], - Some(&feature_path), - )); + // Run with debug to see what width is being used + let mut cmd = Command::new(insta_cmd::get_cargo_bin("wt")); + repo.configure_wt_cmd(&mut cmd); + cmd.args(["step", "copy-ignored"]) + .current_dir(&feature_path) + .env("WT_DEBUG_TERMINAL", "1"); + + let output = cmd.output().unwrap(); + eprintln!("=== ACTUAL TEST DEBUG OUTPUT ==="); + eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + eprintln!("=== END DEBUG OUTPUT ==="); + + // This will fail to show the output + panic!("DEBUG: Check stderr above for terminal width info"); } /// Test with .worktreeinclude but nothing ignored From cf4b19f07762aa4fbf07a09e68ae3f693725ae15 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 20:13:37 -0800 Subject: [PATCH 08/10] fix: prevent wrapping of external error messages in gutter External error messages (from git, ignore crate, etc.) often contain file paths that vary dramatically in length across platforms: - macOS temp paths: /private/var/folders/.../T/.tmpXXX/... (~80 chars) - Linux temp paths: /tmp/... (~10 chars) When format_with_gutter wraps based on terminal width (e.g., 150 cols), a 134-char error message fits on Linux but wraps on macOS due to the longer temp paths. This causes snapshot test failures because the line breaks are baked in before insta normalizes paths to _REPO_. Fix by using a large fixed width (500) for format_error_block, ensuring external error content is never wrapped. This matches the existing pattern where gutter content (shell commands, etc.) is kept short. Co-Authored-By: Claude --- src/git/error.rs | 10 ++- src/styling/mod.rs | 25 +------- tests/integration_tests/step_copy_ignored.rs | 66 +++----------------- 3 files changed, 19 insertions(+), 82 deletions(-) diff --git a/src/git/error.rs b/src/git/error.rs index 586454792..64f8930ce 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -710,13 +710,21 @@ impl std::error::Error for HookErrorWithHint { } /// Format an error with header and gutter content +/// +/// External error messages (from git, ignore crate, etc.) are displayed verbatim +/// without wrapping. These messages often contain file paths that vary in length +/// across platforms (e.g., macOS temp paths like `/private/var/folders/.../` are +/// much longer than Linux `/tmp/...`). Wrapping would cause inconsistent line +/// breaks that depend on runtime path lengths. fn format_error_block(header: impl Into, error: &str) -> String { let header = header.into(); let trimmed = error.trim(); if trimmed.is_empty() { header } else { - format!("{header}\n{}", format_with_gutter(trimmed, None)) + // Use a large fixed width to prevent wrapping of external error content. + // External errors contain paths that vary by platform and test environment. + format!("{header}\n{}", format_with_gutter(trimmed, Some(500))) } } diff --git a/src/styling/mod.rs b/src/styling/mod.rs index c2968fa37..c1abcd564 100644 --- a/src/styling/mod.rs +++ b/src/styling/mod.rs @@ -46,24 +46,11 @@ const DEFAULT_TERMINAL_WIDTH: usize = 80; /// /// Checks stderr first (for status messages), then stdout (for table output). pub fn get_terminal_width() -> usize { - // DEBUG: Temporary logging to diagnose CI terminal detection issues - let debug = std::env::var("WT_DEBUG_TERMINAL").is_ok(); - // Prefer direct terminal detection (more accurate than COLUMNS which may be stale/wrong) // Check stderr first (status messages), then stdout (table output) - let stderr_size = terminal_size::terminal_size_of(std::io::stderr()); - let fallback_size = terminal_size::terminal_size(); - - if debug { - eprintln!("[DEBUG] terminal_size_of(stderr): {:?}", stderr_size); - eprintln!("[DEBUG] terminal_size(): {:?}", fallback_size); - eprintln!("[DEBUG] COLUMNS env: {:?}", std::env::var("COLUMNS")); - } - - if let Some((terminal_size::Width(w), _)) = stderr_size.or(fallback_size) { - if debug { - eprintln!("[DEBUG] Using detected width: {}", w); - } + if let Some((terminal_size::Width(w), _)) = + terminal_size::terminal_size_of(std::io::stderr()).or_else(terminal_size::terminal_size) + { return w as usize; } @@ -71,15 +58,9 @@ pub fn get_terminal_width() -> usize { if let Ok(cols) = std::env::var("COLUMNS") && let Ok(width) = cols.parse::() { - if debug { - eprintln!("[DEBUG] Using COLUMNS width: {}", width); - } return width; } - if debug { - eprintln!("[DEBUG] Using default width: {}", DEFAULT_TERMINAL_WIDTH); - } DEFAULT_TERMINAL_WIDTH } diff --git a/tests/integration_tests/step_copy_ignored.rs b/tests/integration_tests/step_copy_ignored.rs index 3579fbe41..0b293efa1 100644 --- a/tests/integration_tests/step_copy_ignored.rs +++ b/tests/integration_tests/step_copy_ignored.rs @@ -3,49 +3,6 @@ use crate::common::{TestRepo, make_snapshot_cmd, repo}; use insta_cmd::assert_cmd_snapshot; use rstest::rstest; - -/// Diagnostic test to understand terminal detection on CI -#[test] -fn debug_terminal_detection() { - use std::process::Command; - - // What does the test runner process see? - eprintln!("=== Test runner process ==="); - eprintln!( - "terminal_size_of(stderr): {:?}", - terminal_size::terminal_size_of(std::io::stderr()) - ); - eprintln!( - "terminal_size_of(stdout): {:?}", - terminal_size::terminal_size_of(std::io::stdout()) - ); - eprintln!("terminal_size(): {:?}", terminal_size::terminal_size()); - eprintln!("COLUMNS env: {:?}", std::env::var("COLUMNS")); - - // What does a subprocess via Command::output() see? - // This matches how insta_cmd runs the wt binary - let output = Command::new("sh") - .args(["-c", "echo isatty_stdin=$([[ -t 0 ]] && echo yes || echo no) isatty_stdout=$([[ -t 1 ]] && echo yes || echo no) isatty_stderr=$([[ -t 2 ]] && echo yes || echo no)"]) - .output() - .unwrap(); - eprintln!("=== Subprocess isatty ==="); - eprintln!("stdout: {}", String::from_utf8_lossy(&output.stdout)); - - // Run wt with debug flag to see what terminal size it detects - let wt_output = Command::new(insta_cmd::get_cargo_bin("wt")) - .args(["--help"]) // Any command that runs get_terminal_width - .env("COLUMNS", "150") - .env("CLICOLOR_FORCE", "1") - .env("WT_DEBUG_TERMINAL", "1") - .output() - .unwrap(); - eprintln!("=== wt subprocess terminal detection ==="); - eprintln!("stderr: {}", String::from_utf8_lossy(&wt_output.stderr)); - - // Fail deliberately to show output in CI (NEXTEST_SUCCESS_OUTPUT=never hides passing tests) - panic!("DIAGNOSTIC: Check the output above for terminal detection info"); -} - use std::fs; /// Test with no .worktreeinclude file and no gitignored files @@ -93,27 +50,18 @@ fn test_copy_ignored_default_copies_all(mut repo: TestRepo) { /// Test error handling when .worktreeinclude has invalid syntax #[rstest] fn test_copy_ignored_invalid_worktreeinclude(mut repo: TestRepo) { - use std::process::Command; - let feature_path = repo.add_worktree("feature"); // Create invalid .worktreeinclude (unclosed brace in alternate group) fs::write(repo.root_path().join(".worktreeinclude"), "{unclosed\n").unwrap(); - // Run with debug to see what width is being used - let mut cmd = Command::new(insta_cmd::get_cargo_bin("wt")); - repo.configure_wt_cmd(&mut cmd); - cmd.args(["step", "copy-ignored"]) - .current_dir(&feature_path) - .env("WT_DEBUG_TERMINAL", "1"); - - let output = cmd.output().unwrap(); - eprintln!("=== ACTUAL TEST DEBUG OUTPUT ==="); - eprintln!("stderr: {}", String::from_utf8_lossy(&output.stderr)); - eprintln!("=== END DEBUG OUTPUT ==="); - - // This will fail to show the output - panic!("DEBUG: Check stderr above for terminal width info"); + // Should fail with parse error + assert_cmd_snapshot!(make_snapshot_cmd( + &repo, + "step", + &["copy-ignored"], + Some(&feature_path), + )); } /// Test with .worktreeinclude but nothing ignored From 06afffffaafc1ee41d661e73a9d95a16fb215056 Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 22:04:57 -0800 Subject: [PATCH 09/10] test: increase COLUMNS to 500 for cross-platform consistency macOS temp paths (~80 chars like /private/var/folders/.../...) are much longer than Linux paths (~10 chars like /tmp/...). Error messages containing these paths would wrap at different positions, causing snapshot mismatches in CI. Instead of adding special handling in library code, simply increase COLUMNS in the test environment to give sufficient room for long paths. Co-Authored-By: Claude --- src/git/error.rs | 10 +--------- tests/common/mod.rs | 5 ++++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/git/error.rs b/src/git/error.rs index 64f8930ce..586454792 100644 --- a/src/git/error.rs +++ b/src/git/error.rs @@ -710,21 +710,13 @@ impl std::error::Error for HookErrorWithHint { } /// Format an error with header and gutter content -/// -/// External error messages (from git, ignore crate, etc.) are displayed verbatim -/// without wrapping. These messages often contain file paths that vary in length -/// across platforms (e.g., macOS temp paths like `/private/var/folders/.../` are -/// much longer than Linux `/tmp/...`). Wrapping would cause inconsistent line -/// breaks that depend on runtime path lengths. fn format_error_block(header: impl Into, error: &str) -> String { let header = header.into(); let trimmed = error.trim(); if trimmed.is_empty() { header } else { - // Use a large fixed width to prevent wrapping of external error content. - // External errors contain paths that vary by platform and test environment. - format!("{header}\n{}", format_with_gutter(trimmed, Some(500))) + format!("{header}\n{}", format_with_gutter(trimmed, None)) } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index def46cc35..4be3a6e9b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -552,7 +552,10 @@ pub fn configure_cli_command(cmd: &mut Command) { cmd.env("WORKTRUNK_CONFIG_PATH", "/nonexistent/test/config.toml"); cmd.env("CLICOLOR_FORCE", "1"); cmd.env("SOURCE_DATE_EPOCH", TEST_EPOCH.to_string()); - cmd.env("COLUMNS", "150"); + // Use wide terminal to prevent wrapping differences across platforms. + // macOS temp paths (~80 chars) are much longer than Linux (~10 chars), + // so error messages containing paths need room to avoid platform-specific line breaks. + cmd.env("COLUMNS", "500"); // Set consistent terminal type for hyperlink detection via supports-hyperlinks crate cmd.env("TERM", "alacritty"); // Enable warn-level logging so diagnostics show up in test failures From c8b876ee7558d5be08b3580cd68b74330128114d Mon Sep 17 00:00:00 2001 From: Maximilian Roos Date: Thu, 8 Jan 2026 22:13:23 -0800 Subject: [PATCH 10/10] test: update snapshots for COLUMNS=500 With wider terminal: - Help text paragraphs fit on single lines instead of wrapping - wt list has room to show Path column that was previously hidden - Long commit messages show more characters before truncation - Gutter content shows on single line These are expected changes from increasing COLUMNS from 150 to 500. Co-Authored-By: Claude --- ...gration_tests__help__help_config_long.snap | 8 +++---- ...tion_tests__help__help_hook_approvals.snap | 5 ++--- ...tegration_tests__help__help_list_long.snap | 9 +++----- ...egration_tests__help__help_merge_long.snap | 21 +++++++------------ ...gration_tests__help__help_remove_long.snap | 11 ++++------ ...gration_tests__help__help_switch_long.snap | 11 ++++------ ...tests__list__list_long_commit_message.snap | 5 +++-- ...list__list_shows_warning_on_git_error.snap | 5 ++--- ...edge_cases__mixed_length_branch_names.snap | 15 ++++++------- 9 files changed, 36 insertions(+), 54 deletions(-) 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 35bb18a7e..2a4576342 100644 --- a/tests/snapshots/integration__integration_tests__help__help_config_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_config_long.snap @@ -7,7 +7,7 @@ info: - "--help" env: CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_EDITOR: "" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" @@ -146,8 +146,7 @@ When project hooks run for the first time, Worktrunk prompts for approval. Appro [projects."my-project"] approved-commands = ["npm ci", "npm test"] -Manage approvals with wt hook approvals add to review and pre-approve commands, and wt hook approvals clear to reset (add --global to clear all -projects). +Manage approvals with wt hook approvals add to review and pre-approve commands, and wt hook approvals clear to reset (add --global to clear all projects). User hooks @@ -185,8 +184,7 @@ The [list] section adds a URL column to wt list: [list] url = "http://localhost:{{ branch | hash_port }}" -URLs are dimmed when the port isn't listening. The template supports {{ branch }} with filters hash_port (port 10000-19999) and sanitize -(filesystem-safe). +URLs are dimmed when the port isn't listening. The template supports {{ branch }} with filters hash_port (port 10000-19999) and sanitize (filesystem-safe). CI platform override diff --git a/tests/snapshots/integration__integration_tests__help__help_hook_approvals.snap b/tests/snapshots/integration__integration_tests__help__help_hook_approvals.snap index 4372e9186..a38c77109 100644 --- a/tests/snapshots/integration__integration_tests__help__help_hook_approvals.snap +++ b/tests/snapshots/integration__integration_tests__help__help_hook_approvals.snap @@ -8,7 +8,7 @@ info: - "--help" env: CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_EDITOR: "" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" @@ -58,5 +58,4 @@ Clear global approvals: How approvals work -Approved commands are saved to user config. Re-approval is required when the command template changes or the project moves. Use --yes to bypass -prompts in CI. +Approved commands are saved to user config. Re-approval is required when the command template changes or the project moves. Use --yes to bypass prompts in CI. diff --git a/tests/snapshots/integration__integration_tests__help__help_list_long.snap b/tests/snapshots/integration__integration_tests__help__help_list_long.snap index b275a949b..314fba747 100644 --- a/tests/snapshots/integration__integration_tests__help__help_list_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_list_long.snap @@ -7,7 +7,7 @@ info: - "--help" env: CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_EDITOR: "" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" @@ -63,8 +63,7 @@ Usage: wt list [OPTIONS] Show all worktrees with their status. The table includes uncommitted changes, divergence from the default branch and remote, and optional CI status. -The table renders progressively: branch names, paths, and commit hashes appear immediately, then status, divergence, and other columns fill in as -background git operations complete. With --full, CI status fetches from the network — the table displays instantly and CI fills in as results arrive. +The table renders progressively: branch names, paths, and commit hashes appear immediately, then status, divergence, and other columns fill in as background git operations complete. With --full, CI status fetches from the network — the table displays instantly and CI fills in as results arrive. Examples @@ -117,9 +116,7 @@ The CI column shows GitHub/GitLab pipeline status: ⚠ yellow Fetch error (rate limit, network) (blank) No upstream or no PR/MR -CI indicators are clickable links to the PR or pipeline page. Any CI dot appears dimmed when there are unpushed local changes (stale status). PRs/MRs -are checked first, then branch workflows/pipelines for branches with an upstream. Local-only branches show blank. Results are cached for 30-60 -seconds; use wt config state to view or clear. +CI indicators are clickable links to the PR or pipeline page. Any CI dot appears dimmed when there are unpushed local changes (stale status). PRs/MRs are checked first, then branch workflows/pipelines for branches with an upstream. Local-only branches show blank. Results are cached for 30-60 seconds; use wt config state to view or clear. Status symbols diff --git a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap index e489c50ad..c337dfc95 100644 --- a/tests/snapshots/integration__integration_tests__help__help_merge_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_merge_long.snap @@ -7,7 +7,7 @@ info: - "--help" env: CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_EDITOR: "" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" @@ -100,30 +100,23 @@ Skip committing/squashing (rebase still runs unless --no-rebase): wt merge runs these steps: -1. Squash — Stages uncommitted changes, then combines all commits since target into one (like GitHub's "Squash and merge"). Use --stage to control -what gets staged: all (default), tracked, or none. A backup ref is saved to refs/wt-backup/. With --no-squash, uncommitted changes become a -separate commit and individual commits are preserved. +1. Squash — Stages uncommitted changes, then combines all commits since target into one (like GitHub's "Squash and merge"). Use --stage to control what gets staged: all (default), tracked, or none. A backup ref is saved to refs/wt-backup/. With --no-squash, uncommitted changes become a separate commit and individual commits are preserved. 2. Rebase — Rebases onto target if behind. Skipped if already up-to-date. Conflicts abort immediately. 3. Pre-merge hooks — Hooks run after rebase, before merge. Failures abort. See wt hook. 4. Merge — Fast-forward merge to the target branch. Non-fast-forward merges are rejected. 5. Pre-remove hooks — Hooks run before removing worktree. Failures abort. -6. Cleanup — Removes the worktree and branch. Use --no-remove to keep the worktree. When already on the target branch or in the main worktree, the -worktree is preserved. +6. Cleanup — Removes the worktree and branch. Use --no-remove to keep the worktree. When already on the target branch or in the main worktree, the worktree is preserved. 7. Post-merge hooks — Hooks run after cleanup. Failures are logged but don't abort. -Use --no-commit to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless --no-rebase is -passed. Useful after preparing commits manually with wt step. Requires a clean working tree. +Use --no-commit to skip committing uncommitted changes and squashing; rebase still runs by default and can rewrite commits unless --no-rebase is passed. Useful after preparing commits manually with wt step. Requires a clean working tree. Local CI -For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes -instead of fewer large ones. +For personal projects, pre-merge hooks open up the possibility of a workflow with much faster iteration — an order of magnitude more small changes instead of fewer large ones. -Historically, ensuring tests ran before merging was difficult to enforce locally. Remote CI was valuable for the process as much as the checks: it -guaranteed validation happened. wt merge brings that guarantee local. +Historically, ensuring tests ran before merging was difficult to enforce locally. Remote CI was valuable for the process as much as the checks: it guaranteed validation happened. wt merge brings that guarantee local. -The full workflow: start an agent (one of many) on a task, work elsewhere, return when it's ready. Review the diff, run wt merge, move on. Pre-merge -hooks validate before merging — if they pass, the branch goes to the default branch and the worktree cleans up. +The full workflow: start an agent (one of many) on a task, work elsewhere, return when it's ready. Review the diff, run wt merge, move on. Pre-merge hooks validate before merging — if they pass, the branch goes to the default branch and the worktree cleans up. [pre-merge] test = "cargo test" 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 515cf41c7..20006cd78 100644 --- a/tests/snapshots/integration__integration_tests__help__help_remove_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_remove_long.snap @@ -7,7 +7,7 @@ info: - "--help" env: CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_EDITOR: "" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" @@ -85,8 +85,7 @@ Force-delete an unmerged branch: Branch cleanup -By default, branches are deleted when merging them would add nothing. This works with squash-merge and rebase workflows where commit history differs -but file changes match. +By default, branches are deleted when merging them would add nothing. This works with squash-merge and rebase workflows where commit history differs but file changes match. Worktrunk checks five conditions (in order of cost): @@ -96,8 +95,7 @@ Worktrunk checks five conditions (in order of cost): 4. Trees match — Branch tree SHA equals target tree SHA. Shows ⊂. 5. Merge adds nothing — Simulated merge produces the same tree as target. Handles squash-merged branches where target has advanced. Shows ⊂. -The "same commit" check uses the local default branch; for other checks, target means the default branch, or its upstream (e.g., origin/main) when -strictly ahead. +The "same commit" check uses the local default branch; for other checks, target means the default branch, or its upstream (e.g., origin/main) when strictly ahead. Branches showing _ or ⊂ are dimmed as safe to delete. @@ -105,8 +103,7 @@ Use -D to force-delete branches with unmerged changes. Use --no-dele Background removal -Removal runs in the background by default (returns immediately). Logs are written to .git/wt-logs/{branch}-remove.log. Use --foreground to run in the -foreground. +Removal runs in the background by default (returns immediately). Logs are written to .git/wt-logs/{branch}-remove.log. Use --foreground to run in the foreground. Shortcuts 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..1e934c20e 100644 --- a/tests/snapshots/integration__integration_tests__help__help_switch_long.snap +++ b/tests/snapshots/integration__integration_tests__help__help_switch_long.snap @@ -7,7 +7,7 @@ info: - "--help" env: CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_EDITOR: "" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" @@ -47,16 +47,14 @@ Usage: wt switch [OPTIONS]  [-- -x, --execute  Command to run after switch - Replaces the wt process with the command after switching, giving it full terminal control. Useful for launching editors, AI agents, or other - interactive tools. + Replaces the wt process with the command after switching, giving it full terminal control. Useful for launching editors, AI agents, or other interactive tools. Especially useful with shell aliases: alias wsc='wt switch --create -x claude' wsc feature-branch -- 'Fix GH #322' - 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. + 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. -y, --yes Skip approval prompts @@ -82,8 +80,7 @@ Usage: wt switch [OPTIONS]  [-- Change directory to a worktree, creating one if needed. -Worktrees are addressed by branch name; paths are computed from a configurable template. Unlike git switch, this navigates between worktrees rather -than changing branches in place. +Worktrees are addressed by branch name; paths are computed from a configurable template. Unlike git switch, this navigates between worktrees rather than changing branches in place. Examples diff --git a/tests/snapshots/integration__integration_tests__list__list_long_commit_message.snap b/tests/snapshots/integration__integration_tests__list__list_long_commit_message.snap index de689ed9c..f772bb3a0 100644 --- a/tests/snapshots/integration__integration_tests__list__list_long_commit_message.snap +++ b/tests/snapshots/integration__integration_tests__list__list_long_commit_message.snap @@ -7,7 +7,7 @@ info: env: APPDATA: "[TEST_CONFIG_HOME]" CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" @@ -20,6 +20,7 @@ info: PATH: "[PATH]" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty USERPROFILE: "[TEST_HOME]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -30,7 +31,7 @@ exit_code: 0 ----- stdout ----- Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message @ main ^ . c80da958 1d Short message -+ feature-a ⊂ ↓1 ../repo.feature-a 0fcd1a46 1d This is a very long commit message that should test how the me… ++ feature-a ⊂ ↓1 ../repo.feature-a 0fcd1a46 1d This is a very long commit message that should test how the message column handles truncation and w… ○ Showing 2 worktrees diff --git a/tests/snapshots/integration__integration_tests__list__list_shows_warning_on_git_error.snap b/tests/snapshots/integration__integration_tests__list__list_shows_warning_on_git_error.snap index 3dc54109d..a70690a28 100644 --- a/tests/snapshots/integration__integration_tests__list__list_shows_warning_on_git_error.snap +++ b/tests/snapshots/integration__integration_tests__list__list_shows_warning_on_git_error.snap @@ -7,7 +7,7 @@ info: env: APPDATA: "[TEST_CONFIG_HOME]" CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" @@ -39,8 +39,7 @@ exit_code: 0 ▲ Some git operations failed:   feature: commit-details (fatal: bad object 0000000000000000000000000000000000000000)   feature: ahead-behind (fatal: Invalid symmetric difference expression main...0000000000000000000000000000000000000000) -  feature: committed-trees-match (fatal: ambiguous argument '0000000000000000000000000000000000000000^{tree}': unknown revision or path not in the -  working tree.) +  feature: committed-trees-match (fatal: ambiguous argument '0000000000000000000000000000000000000000^{tree}': unknown revision or path not in the working tree.)   feature: has-file-changes (fatal: Invalid symmetric difference expression main...feature)   feature: working-tree-diff (fatal: bad object HEAD) ↳ To create a diagnostic file, run with -vv diff --git a/tests/snapshots/integration__integration_tests__spacing_edge_cases__mixed_length_branch_names.snap b/tests/snapshots/integration__integration_tests__spacing_edge_cases__mixed_length_branch_names.snap index b9eac8e0a..63065ea27 100644 --- a/tests/snapshots/integration__integration_tests__spacing_edge_cases__mixed_length_branch_names.snap +++ b/tests/snapshots/integration__integration_tests__spacing_edge_cases__mixed_length_branch_names.snap @@ -7,7 +7,7 @@ info: env: APPDATA: "[TEST_CONFIG_HOME]" CLICOLOR_FORCE: "1" - COLUMNS: "150" + COLUMNS: "500" GIT_AUTHOR_DATE: "2025-01-01T00:00:00Z" GIT_COMMITTER_DATE: "2025-01-01T00:00:00Z" GIT_CONFIG_GLOBAL: "[TEST_GIT_CONFIG]" @@ -20,6 +20,7 @@ info: PATH: "[PATH]" RUST_LOG: warn SOURCE_DATE_EPOCH: "1735776000" + TERM: alacritty USERPROFILE: "[TEST_HOME]" WORKTRUNK_CONFIG_PATH: "[TEST_CONFIG]" WORKTRUNK_TEST_SKIP_URL_HEALTH_CHECK: "1" @@ -28,12 +29,12 @@ info: success: true exit_code: 0 ----- stdout ----- - Branch Status HEAD± main↕ Remote⇅ Commit Age Message -@ main ^ a1e809f5 1d Initial commit -+ extremely-long-branch-name-that-might-cause-layout-issues _ a1e809f5 1d Initial commit -+ medium _ a1e809f5 1d Initial commit -+ x _ a1e809f5 1d Initial commit + Branch Status HEAD± main↕ Path Remote⇅ Commit Age Message +@ main ^ . a1e809f5 1d Initial commit ++ extremely-long-branch-name-that-might-cause-layout-issues _ ../repo.extremely-long-branch-name-that-might-cause-layout-issues a1e809f5 1d Initial commit ++ medium _ ../repo.medium a1e809f5 1d Initial commit ++ x _ ../repo.x a1e809f5 1d Initial commit -○ Showing 4 worktrees, 1 column hidden +○ Showing 4 worktrees ----- stderr -----