Repository navigation
Conversation
…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
left a comment
There was a problem hiding this comment.
.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.
…allower than its directory
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
left a comment
There was a problem hiding this comment.
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).
…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
left a comment
There was a problem hiding this comment.
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
.worktreeincludeof.envalone copies only the root.env. - Adding
packages/*/.cache/types/also copiespackages/foo/node_modules/pkg/.env, becausepackages/is a prefix ofpackages/foo/node_modules. - Adding
**/.cache/types/instead copies only the root.envand the cache dir. But a line like**/foo/barwould openpackages/foo/node_modulesthe same way, becausefoomatches one of its components.
So the monorepo line this PR targets brings back the node_modules leak that the "searched" gate exists to stop.
…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
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
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.
… 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.
.worktreeincludenow 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 --directorylists such a directory as one entry, so.cache/claude-code-types/under an ignored.cache/matched nothing, andcopy-ignoredandpromoteskipped it. Claude Code's own.worktreeincludecopy 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 --directoryonce with the standard exclude sources and, when.worktreeincludeexists, 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 forpackages/*/.cache/types/plus.envit also copiespackages/foo/node_modules/pkg/.env. The.worktreeincludeparse error goes away, since git takes any line as a pattern. So:.cache/types/) selects it, and its siblings stay behind..env) selects the root.envand any.envgit lists on its own, but never a package's.envfromnode_modules/.build/with onlybuild/*.oignored) 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:
promoteno longer moves.jj/repo.promoterefuses a destination that resolves outside its worktree through a symlinked directory, andcopy-ignoredchecks 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.