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..f15356189 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/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 86be0a022..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()); } @@ -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>, @@ -425,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(), }; @@ -445,34 +451,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..586454792 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, @@ -140,6 +143,9 @@ pub enum GitError { ProjectConfigNotFound { config_path: PathBuf, }, + WorktreeNotFound { + branch: String, + }, Other { message: String, }, @@ -545,6 +551,19 @@ 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::WorktreeNotFound { branch } => { + write!( + f, + "{}", + error_message(cformat!("No worktree found for branch {branch}")) + ) + } + GitError::Other { message } => { write!(f, "{}", error_message(message)) } 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 diff --git a/tests/integration_tests/step_copy_ignored.rs b/tests/integration_tests/step_copy_ignored.rs index dd495d8d2..0b293efa1 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", @@ -448,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__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_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__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 ----- 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_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_invalid_worktreeinclude.snap b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap new file mode 100644 index 000000000..439327247 --- /dev/null +++ b/tests/snapshots/integration__integration_tests__step_copy_ignored__copy_ignored_invalid_worktreeinclude.snap @@ -0,0 +1,36 @@ +--- +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 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