How to gitignore per worktree
You want a file ignored in one worktree only: not committed to
.gitignore, not ignored everywhere on your machine, just invisible in
this one checkout. Here is the version that actually works:
git config extensions.worktreeConfig true # once per repo
git -C <worktree> config --worktree \
core.excludesFile /abs/path/to/exclude-file # once per worktree
echo "debug.log" >> /abs/path/to/exclude-file
The rest of this post is why the popular answers fail, measured, and the two gotchas inside the working one. Everything below was run today on git 2.39.5.
The file everyone cites is never read
Search for this and the top answers, including a dedicated guide site and
well-starred gists, tell you a linked worktree has its own exclude file at
.git/worktrees/<name>/info/exclude. It sounds right: that directory is
the worktree’s private git dir, and info/exclude is where repo-local
ignores live. Measured:
$ echo "debug.log" > .git/worktrees/wt-a/info/exclude
$ touch wt-a/debug.log
$ git -C wt-a status --porcelain
?? debug.log
Git does not read that path. Ignore patterns come from
$GIT_COMMON_DIR/info/exclude, the directory all worktrees share, and git
never consults a per-worktree info/ for excludes. The failure mode is
the worst kind: nothing errors, the file just sits there, and you believe
the pattern is active until an agent runs git add -A and commits the
thing you “ignored”.
Two more non-answers, briefly. A different .gitignore per worktree does
not exist as a concept: .gitignore is tracked content, so it belongs to
the branch, follows every checkout of that branch, and any edit is one
git add away from being committed for the whole team. And the global
core.excludesFile is the opposite scope, every repo on the machine,
which is its own trap.
The almost-right answer: one file for all worktrees
.git/info/exclude in the common directory works and is never committed.
Its scope surprises people in the other direction:
$ echo "debug.log" >> .git/info/exclude
$ git status --porcelain; git -C wt-a status --porcelain; git -C wt-b status --porcelain
(empty) (empty) (empty)
One line, ignored in the main checkout and in every worktree, current and future. Before reaching for true per-worktree isolation, check whether this is actually what you need: an ignore pattern for a file that does not exist costs nothing, so the union of every worktree’s patterns in one shared file behaves identically to per-worktree files in almost every real case. You need real per-worktree scope only when the same filename must be ignored in one worktree and visible in another.
True per-worktree: worktreeConfig
When you do need it, git has it, behind an extension:
$ git config extensions.worktreeConfig true
$ git -C wt-a config --worktree core.excludesFile \
"$PWD/.git/worktrees/wt-a/info/exclude"
$ git -C wt-a status --porcelain # debug.log ignored here
(empty)
$ git -C wt-b status --porcelain # and only here
?? debug.log
A nice touch: pointing core.excludesFile at
.git/worktrees/<name>/info/exclude makes the internet’s mythical file
real. Git still does not read it on its own; the --worktree config is
what wires it in, and the path is simply a sensible place to keep the
patterns, since it dies with the worktree.
Two gotchas cost me a retake each:
The path must be absolute. My first attempt used a relative path and
nothing was ignored, silently: a relative core.excludesFile resolves
against wherever the command runs, not against the git dir.
Per-worktree core.excludesFile replaces the outer one. The key is
single-valued, so the most specific scope wins alone; it does not stack:
-- repo-level excludesFile ignores *.tmp
-- wt-a sets a worktree-level excludesFile for debug.log
$ git -C wt-b status --porcelain | grep tmp # still ignored
(empty)
$ git -C wt-a status --porcelain | grep tmp # resurfaced
?? x.tmp
The worktree that customized its excludes lost every pattern from the level above. If you use this, copy the outer patterns into the per-worktree file, and remember the config is machine-local state: a new clone, a teammate, a recreated worktree all start from zero.
Declaring it once, in the repo
For the common case, the shared info/exclude, pwt can own the writing.
The project’s Pwtfile (which is committed) declares the patterns, and
every pwt create applies them idempotently:
# Pwtfile
setup() {
pwtfile_git_exclude "pnpm-lock.yaml" "pnpm-workspace.yaml" ".claude/"
}
pwtfile_git_exclude (pwt ≥ 0.2.2) appends each pattern to the common
.git/info/exclude only if it is not already there, so the declaration is
safe to run on every worktree creation, and a fresh machine converges the
first time it creates a worktree. The knowledge travels in the commit; the
ignore state stays out of it.
What this looks like with an agent
The honest summary of this post is “git has four ignore layers, one famous path is fake, one config key silently replaces another, and paths must be absolute”. Nobody should keep that in their head. Stated as an outcome instead:
you> ignore the generated pnpm files in every worktree,
without committing anything
agent> runs pwt skill, reads the guide, adds
pwtfile_git_exclude "pnpm-lock.yaml" "pnpm-workspace.yaml"
to setup(), creates a throwaway worktree, checks git status
pwt is a git worktree manager for
parallel development: brew install jonasporto/pwt/pwt. The exclude
helper landed in v0.2.2, alongside the
port allocation
and setup hooks
from earlier posts.
You do not have to memorize any of this.
pwt skillprints the agent-facing guide to pwt; tell your agent the outcome you want and point it there, and it works out the mechanism and verifies it in a throwaway worktree.
pwtfile_git_excludeis part of that guide: ask for “ignore this file without committing anything” and the agent lands on the right layer instead of the mythical per-worktree file above. Reference: Pwtfile helpers.