Skip to content

fix(copy-ignored): reach .worktreeinclude paths inside a wholly ignored directory - #4751

Open
max-sixty wants to merge 16 commits into
mainfrom
online-feedback
Open

max-sixty wants to merge 16 commits into
mainfrom
online-feedback

Conversation

@max-sixty

@max-sixty max-sixty commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

.worktreeinclude now selects by git's own gitignore rules, including paths inside a wholly ignored directory that an include line names, as Claude Code does. git ls-files --directory lists such a directory as one entry, so .cache/claude-code-types/ under an ignored .cache/ matched nothing, and copy-ignored and promote skipped it. Claude Code's own .worktreeinclude copy reaches it, and a team trialling the Claude Code plugin lost that cache in every new worktree (Rikmorn/sidekick#287).

Git now does all the matching. Discovery runs git ls-files -o -i --directory once with the standard exclude sources and, when .worktreeinclude exists, again with that file as the only source. An entry is selected where the two listings overlap, taking the deeper path of each pair where one holds the other. Inside a directory git lists whole, only lines that name a path (a slash before the end, past any leading / or **/) select, as found by a third listing of those lines plus the negations. A name matched at any depth (.env, types/) selects only outside such directories. Claude Code, which defines the file, agrees on path-naming lines; it differs in opening a directory from a line's text and then applying every line inside, so for packages/*/.cache/types/ plus .env it also copies packages/foo/node_modules/pkg/.env. The .worktreeinclude parse error goes away, since git takes any line as a pattern. So:

  • A pattern naming a path inside an ignored directory (.cache/types/) selects it, and its siblings stay behind.
  • A pattern that matches at any depth (.env) selects the root .env and any .env git lists on its own, but never a package's .env from node_modules/.
  • A negation can't re-include a path inside a selected directory.
  • An include line naming a partly ignored directory (build/ with only build/*.o ignored) selects only its ignored files.

The cost is that each include listing walks every untracked directory: 0.33 s over 124k ignored files with a warm cache, against 0.03 s for the standard listing. Without .worktreeinclude, discovery runs one listing as before.

Reaching inside ignored directories needed boundaries that whole-directory entries had made unnecessary:

  • Built-in VCS and tool-state directories now exclude everything inside them too, so promote no longer moves .jj/repo.
  • promote refuses a destination that resolves outside its worktree through a symlinked directory, and copy-ignored checks before creating parent directories. The containment error now says "write" rather than "copy".

The Claude Code page now says that hook-made worktrees ignore worktree.baseRef, since the hook input carries the worktree's name and no base.

Left alone, and older than this change: copying or promoting into a destination where another worktree is checked out inside an ignored directory writes into that worktree, with or without .worktreeinclude.

This was written by Claude Code on behalf of max-sixty

…ed directory

`git ls-files --directory` lists a wholly ignored directory as one entry, so
a `.worktreeinclude` pattern naming a path inside it, such as
`.cache/claude-code-types/` under an ignored `.cache/`, matched nothing and
copy-ignored and promote skipped it. Claude Code's own `.worktreeinclude`
copy does reach it, so a team trialling worktrunk behind Claude Code's
worktree hooks lost that cache in every new worktree (Rikmorn/sidekick#287).

An anchored include pattern (a slash before its end, a literal first
component) now leads into such a directory, and the include matcher picks
the deepest entries it names. Patterns that match at any depth (`.env`,
`**/.env`) still apply only to the entries git lists, so an include file
never walks a whole ignored `node_modules/` or `target/`.
Claude Code's WorktreeCreate hook input carries the worktree's name and no
base, so the plugin's `wt switch --create` starts every new branch from the
default branch, whatever `worktree.baseRef` says. A team dispatching agents
from a feature branch with `baseRef: "head"` found their workers on the
default branch instead (Rikmorn/sidekick#287). The OpenCode paragraph
already states its base; this states Claude Code's.
…ries

Reaching inside a wholly ignored directory exposed cases that only
whole-directory entries had kept out of reach:

- promote moved an entry through a symlinked directory in the destination
  worktree, writing outside it; distribution now refuses such a
  destination, and copy-ignored checks before creating parent directories
  rather than after;
- the walk entered another worktree checked out inside an ignored
  directory, and promote moved that worktree's tracked files;
- an entry inside a built-in excluded directory (`.jj/repo`) passed the
  basename-only check, and promote moved it.

Configured excludes, built-in directories and other worktrees now form one
boundary that prunes the walk and filters git's entries alike.

Anchored patterns are now matched one glob per component, so a glob prefix
that can't match a directory no longer walks it, and escapes and glob first
components lead where the full matcher would. Selection follows gitignore's
parent rule on both routes: a negation can't re-include a path inside a
selected directory, whether git listed the path itself or its directory.
…aces

The walk took a socket or FIFO inside an ignored directory as a file to
copy, which failed the whole copy; it now keeps to what `git ls-files`
lists: directories, files and symlinks. The include parser trimmed any
trailing Unicode whitespace, then sliced by bytes and panicked on a
multi-byte one; it now trims spaces, as gitignore does.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.worktreeinclude negations no longer keep files out when git lists those files individually. This happens whenever the parent directory holds tracked files, so it is the common layout, not just the wholly ignored directories this PR targets. Take a tracked config/tracked, a .gitignore of config/*.local, and a .worktreeinclude of config/ followed by !config/secret.local. Before this PR, wt step copy-ignored --dry-run would copy only config/a.local. With this PR it also copies config/secret.local, and promote would move it. I reproduced both against builds of the base and the head.

Strict gitignore parity is defensible, but in an include file a negation is how someone says "don't copy this secret". After this change that line is silently ignored with no warning. The choice here is between parity and keeping that intent. Some options are inline.

Because this changes what promote moves into its staging directory, which holds the user's only copy partway through a swap, it falls in the data-loss surface that .claude/skills/running-tend/references/review-pr.md holds for a human decision. That is why this is a comment rather than an approval.

Comment thread src/commands/step/shared.rs Resolved
Git now owns the gitignore semantics of `.worktreeinclude`. Discovery runs
`git ls-files -o -i --directory` with the standard exclude sources and, when
the include file exists, again with that file as the only source, and keeps
the deeper path of each pair where one listing's entry holds the other's.
Before overlapping, each listing drops the directories git adds beside their
excluded contents (`d/` next to `d/f`), which no pattern selected.

This replaces the branch's own anchored-pattern parser, its walk into
ignored directories, and its parent-rule helper, along with the globset
dependency and the `.worktreeinclude` parse error: git takes any line as a
pattern. Patterns that match at any depth now reach inside ignored
directories too, so `.env` selects `node_modules/pkg/.env`, as gitignore
rules say.

The second listing walks every untracked directory: 0.33 s over 124k ignored
files with a warm cache, against 0.03 s for the standard listing. Without
`.worktreeinclude`, discovery runs one listing as before.
…ine names a path in it

Claude Code, which defines `.worktreeinclude`, looks inside a directory git
lists whole only when a pattern names a path there. Following gitignore rules
in full instead let any-depth lines reach into dependency directories: `.env`
copied a package's `node_modules/.pnpm/.../natural/.env` into the new
worktree. Of 98 public `.worktreeinclude` files sampled, 76 have such a line,
mostly `.env`-style.

Git still does all the matching; an entry inside a directory git lists whole
now counts only when an include line's text, after a leading `/`, starts with
that directory's path. Checked against Claude Code 2.1.322's built-in worktree
creation on a fixture: both copy the same files.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An include line that names a path inside an ignored directory only opens that directory when it spells the path literally. Lines that use a glob or **/ before that directory match nothing. Git picks the entry up from the include listing. But searched compares the line's characters with starts_with, so the entry is then dropped with no warning. For example, take a .gitignore of .cache/ and the file packages/foo/.cache/types/a. A .worktreeinclude of packages/foo/.cache/types/ gives Would copy 1 entry: packages/foo/.cache/types (dir) from wt step copy-ignored --dry-run. Both packages/*/.cache/types/ and **/.cache/types/ give No matching files to copy. In a monorepo, a glob is the natural way to write the line this PR targets (.cache/claude-code-types/ in each package).

Comment thread src/commands/step/shared.rs Resolved
…lines too

The literal prefix check dropped monorepo lines: with `.cache/` ignored,
`packages/*/.cache/types/` and `**/.cache/types/` matched
`packages/foo/.cache/types/` in git's include listing, but the entry was
discarded because neither line starts with that directory's path. Claude
Code 2.1.322 copies it for both lines.

The opening check now follows Claude Code's rule in full: a line opens a
directory git lists whole when it starts inside it, when its text before the
first wildcard is a prefix of it, or when, after leading `**/`, its first
component is one of the directory's components (ignoring case). `**/.env`
still opens nothing in `node_modules/`, as in Claude Code. Checked all four
lines against Claude Code's own worktree creation on the same fixture.

Found by the worktrunk-bot review of f977486.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new opens rules open more than the directory a line points into. A glob line opens every directory under its literal prefix, and a **/ line opens any directory whose path contains its first component at any depth. Either one lets a depth-matching line such as .env reach inside node_modules/, which the PR description and the list_and_filter_ignored_entries docstring both say never happens. Take a .gitignore of .cache/, node_modules/ and .env, with files packages/foo/.cache/types/a and packages/foo/node_modules/pkg/.env. Against a build of this head, wt step copy-ignored --dry-run behaves like this:

  • A .worktreeinclude of .env alone copies only the root .env.
  • Adding packages/*/.cache/types/ also copies packages/foo/node_modules/pkg/.env, because packages/ is a prefix of packages/foo/node_modules.
  • Adding **/.cache/types/ instead copies only the root .env and the cache dir. But a line like **/foo/bar would open packages/foo/node_modules the same way, because foo matches one of its components.

So the monorepo line this PR targets brings back the node_modules leak that the "searched" gate exists to stop.

Comment thread src/commands/step/shared.rs Resolved
…g it exists first

Reading the file decides whether it exists, so an unreadable include file (a directory, say) now fails with our own context before any git call, and a test pins that it fails rather than copying as if the file were absent.
…g line reaches

Claude Code's textual rule for opening an ignored directory over-opens: with
`packages/*/.cache/types/` and `.env` in `.worktreeinclude`, the `packages/`
prefix also opened `packages/foo/node_modules/`, so `.env` copied a package's
`.env` from it. Claude Code 2.1.322 does copy that file; Worktrunk now doesn't.

Instead of reading line text, a third `git ls-files` runs with only the lines
that name a path (a slash before the end, past any leading `/` or `**/`), and a
directory git lists whole is searched exactly where those lines match inside
it. Git does all the matching; `opens` and its unit test go. Glob and `**/`
lines still open the directory they reach, matching Claude Code on every other
fixture checked.

Found by the worktrunk-bot review of 660b03d.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A **/ line that matches inside node_modules/ opens all of node_modules/ to depth-matching lines. searched holds every ancestor of every named leaf, and overlap checks only the nearest directory git lists whole. So a single match under node_modules/x/ lets .env select every .env anywhere under node_modules/. Take a .gitignore of .cache/, node_modules/ and .env, with files packages/foo/.cache/types/a, node_modules/x/.cache/types/b and node_modules/y/.env. Against a build of this head, a .worktreeinclude of **/.cache/types/ and .env gives this from wt step copy-ignored --dry-run:

○ Would copy 4 entries:
  .env (file)
  node_modules/x/.cache/types (dir)
  node_modules/y/.env (file)
  packages/foo/.cache/types (dir)

Copying node_modules/x/.cache/types is what the line asks for. node_modules/y/.env is not, and the list_and_filter_ignored_entries docstring says it never happens. Claude Code wouldn't open node_modules here either, because .cache isn't one of its components. One option is to check the entry's parent against searched instead of its nearest whole-listed ancestor. That also stops .env from reaching .cache/other/.env beside a .cache/types/ line, which the PR description currently says it does. The alternative is to keep the rule and narrow the docstring. Each of the last three rounds has found a leak or a miss in how directories get opened, so the opening rule itself may be the decision to settle here, rather than another patch to it.

This is a comment rather than an approval for the same reason as before: the change alters what promote moves into its staging directory, which is in the data-loss surface held for a human decision.

Comment thread src/commands/step/shared.rs Resolved
…path-naming lines

Three review rounds each found a leak or a miss in deciding which ignored
directories to "open" for every include line. The latest: one
`**/.cache/types/` match under `node_modules/x/` opened all of
`node_modules/`, so `.env` picked up `node_modules/y/.env`.

Drop the opening step. Inside a directory git lists whole, only lines that
name a path select, as found by the listing of those lines plus the
negations; a name matched at any depth (`.env`, `types/`) selects only outside
such directories. Claude Code agrees on every path-naming line checked. It
still differs where it opens a directory from a line's text and then applies
every line inside: for `packages/*/.cache/types/` plus `.env` it copies
`packages/foo/node_modules/pkg/.env`, which Worktrunk doesn't.

Found by the worktrunk-bot review of bc1a2b8.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The named chain can emit an entry nested inside another selected entry. This happens when a depth-matching line selects a whole untracked directory that contains the wholly ignored directory. The second chain picks up the ignored directory because it sits inside an included one. The named chain only checks whether that ignored directory itself is included, so it adds the path-named entry beneath it as well. To reproduce: take a .gitignore of .cache/ and an untracked packages/foo/ holding src.ts, .cache/types/a and .cache/other/b. With a .worktreeinclude of foo/ and packages/foo/.cache/types/, a build of this head shows this for wt step copy-ignored --dry-run:

○ Would copy 2 entries:
  packages/foo/.cache (dir)
  packages/foo/.cache/types (dir)

The copy itself still succeeds, because it merges into the existing directory. promote can't hit this, since it refuses the untracked src.ts. So the visible effects are the dry-run listing and redundant copy work. Checking ancestors of the ignored directory too gives Would copy 1 entry: packages/foo/.cache (dir), and the rest of step_copy_ignored still passes with that change.

This is a comment rather than an approval for the same reason as earlier rounds: the change alters what promote moves into its staging directory, which is in the data-loss surface held for a human decision.

Comment thread src/commands/step/shared.rs Resolved
…ady covers

With `foo/` selecting an untracked `packages/foo/` that holds the ignored `packages/foo/.cache/`, a `packages/foo/.cache/types/` line also listed `packages/foo/.cache/types` beneath it, so dry-run showed nested entries and the copy did redundant work. A path-named entry now counts only when no directory around it is already selected. Found by the worktrunk-bot review of 33ad634.

@worktrunk-bot worktrunk-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new nesting check drops path-named entries that nothing else selects. !inside(path, &included_paths) treats any included ancestor as cover. But an included directory that sits inside the wholly ignored directory is not emitted by either of the other chains, because ignored lists only the outer directory. Take a .gitignore of .cache/ with files .cache/types/sub/a and .cache/types/other/b, and a .worktreeinclude of types/ followed by .cache/types/sub/. The include listing holds .cache/types, the ignored listing holds .cache, and the named listing holds .cache/types/sub. Running wt step copy-ignored --dry-run:

  • at 33ad634: Would copy 1 entry: .cache/types/sub (dir)
  • at this head: No matching files to copy

So adding a depth-matching line silently cancels a path line that works by itself. Only an included ancestor-or-self of the ignored directory actually covers the entry, which is the check the earlier thread suggested. The inline change restores the 33ad634 output for this case and keeps the new overlap_selects_no_nested_entries test passing. A unit case with included = [".cache/types"], ignored = [".cache"] and named = [".cache/types/sub"] would pin it.

Comment thread src/commands/step/shared.rs Resolved
… beneath it

With `foo/` and `node_modules/foo/types/` in `.worktreeinclude`, the include
listing's `node_modules/foo/` (a bare-name match inside the ignored
`node_modules/`, which selects nothing) hid the path-named
`node_modules/foo/types/`, so copy-ignored copied nothing and promote left the
data behind. Only include entries outside a directory git lists whole now hide
anything.

The path-naming lines also go to git through a temporary file rather than
`--exclude=` arguments, so git reads them exactly as it reads
`.worktreeinclude` (trailing spaces, a byte-order mark), and classifying a line
ignores trailing whitespace.

Found by an independent Codex review of 15aee4a.
…r mark

Git skips a UTF-8 BOM at the start of an exclude file, but the classification saw it, so a first line of BOM + `**/foo/` counted as naming a path and selected `node_modules/foo/` inside the ignored `node_modules/`. Found by a Codex follow-up review of 2ef37ea.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants