Skip to content
Merged
4 changes: 2 additions & 2 deletions .claude-plugin/skills/worktrunk/reference/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 19 additions & 18 deletions .claude-plugin/skills/worktrunk/reference/step.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -76,7 +76,7 @@ Usage: <b><span class=c>wt step</span></b> <span class=c>[OPTIONS]</span> <span
<b><span class=c>squash</span></b> Squash commits since branching
<b><span class=c>push</span></b> Fast-forward target to current branch
<b><span class=c>rebase</span></b> Rebase onto target
<b><span class=c>copy-ignored</span></b> Copy <b>.worktreeinclude</b> files to another worktree
<b><span class=c>copy-ignored</span></b> Copy gitignored files to another worktree
<b><span class=c>for-each</span></b> [experimental] Run command in each worktree

<b><span class=g>Options:</span></b>
Expand All @@ -95,20 +95,10 @@ Usage: <b><span class=c>wt step</span></b> <span class=c>[OPTIONS]</span> <span

## wt step copy-ignored

Git worktrees share the repository but not untracked files. This command copies files listed in `.worktreeinclude` to another worktree, eliminating cold starts.
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
Expand All @@ -117,9 +107,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

Expand Down Expand Up @@ -158,11 +158,12 @@ Virtual environments contain absolute paths and can't be copied. Use `uv sync` i

### Command reference

wt step copy-ignored - Copy <b>.worktreeinclude</b> files to another worktree
wt step copy-ignored - Copy gitignored files to another worktree

Copies files listed in <b>.worktreeinclude</b> that are also gitignored. Useful in
post-create hooks to sync local config files (<b>.env</b>, IDE settings) to new
worktrees. Skips symlinks and existing files.
Copies gitignored files to another worktree. By default copies all gitignored
files; use <b>.worktreeinclude</b> to limit what gets copied. Useful in post-create
hooks to sync local config files (<b>.env</b>, IDE settings) to new worktrees. Skips
symlinks and existing files.

Usage: <b><span class=c>wt step copy-ignored</span></b> <span class=c>[OPTIONS]</span>

Expand Down
4 changes: 2 additions & 2 deletions docs/content/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 19 additions & 18 deletions docs/content/step.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -90,7 +90,7 @@ Usage: <b><span class=c>wt step</span></b> <span class=c>[OPTIONS]</span> <span
<b><span class=c>squash</span></b> Squash commits since branching
<b><span class=c>push</span></b> Fast-forward target to current branch
<b><span class=c>rebase</span></b> Rebase onto target
<b><span class=c>copy-ignored</span></b> Copy <b>.worktreeinclude</b> files to another worktree
<b><span class=c>copy-ignored</span></b> Copy gitignored files to another worktree
<b><span class=c>for-each</span></b> [experimental] Run command in each worktree

<b><span class=g>Options:</span></b>
Expand All @@ -110,20 +110,10 @@ Usage: <b><span class=c>wt step</span></b> <span class=c>[OPTIONS]</span> <span

## wt step copy-ignored

Git worktrees share the repository but not untracked files. This command copies files listed in `.worktreeinclude` to another worktree, eliminating cold starts.
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
Expand All @@ -132,9 +122,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

Expand Down Expand Up @@ -174,11 +174,12 @@ Virtual environments contain absolute paths and can't be copied. Use `uv sync` i
### Command reference

{% terminal() %}
wt step copy-ignored - Copy <b>.worktreeinclude</b> files to another worktree
wt step copy-ignored - Copy gitignored files to another worktree

Copies files listed in <b>.worktreeinclude</b> that are also gitignored. Useful in
post-create hooks to sync local config files (<b>.env</b>, IDE settings) to new
worktrees. Skips symlinks and existing files.
Copies gitignored files to another worktree. By default copies all gitignored
files; use <b>.worktreeinclude</b> to limit what gets copied. Useful in post-create
hooks to sync local config files (<b>.env</b>, IDE settings) to new worktrees. Skips
symlinks and existing files.

Usage: <b><span class=c>wt step copy-ignored</span></b> <span class=c>[OPTIONS]</span>

Expand Down
6 changes: 3 additions & 3 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
34 changes: 17 additions & 17 deletions src/cli/step.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,27 +76,17 @@ pub enum StepCommand {
target: Option<String>,
},

/// 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
Expand All @@ -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

Expand Down
5 changes: 4 additions & 1 deletion src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,10 @@ pub fn handle_state_get(key: &str, refresh: bool, branch: Option<String>) -> 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 {
Expand Down
69 changes: 37 additions & 32 deletions src/commands/step_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result<RebaseResult> {
}
// 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 <bold>{}</>: {}", target_branch, e),
}
.into());
}
Expand Down Expand Up @@ -409,9 +409,11 @@ pub fn handle_rebase(target: Option<&str>) -> anyhow::Result<RebaseResult> {

/// 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>,
Expand All @@ -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(),
};

Expand All @@ -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 <bold>.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(());
Expand Down
Loading