I found two global gitignores on my machine. Both were the wrong layer.
Modern worktrees fill up with files that must exist and must never be
committed: agent configuration (.claude/, AGENTS.md, .mcp.json), a
derived pnpm-lock.yaml in a yarn project,
generated Procfiles, review-tool state. The standard answer is a global
gitignore in core.excludesFile, and that is what I had. This week I
audited it before retiring it, and the audit is the argument.
What I found on one machine: two global ignore files with identical
content, because a conditional include (includeIf "gitdir:...") pointed
work repos at a second copy. core.excludesFile is single-valued, so the
second file did not add to the first; it silently replaced it. The two
files matched today only because one started as a copy of the other, and
nothing would have kept them matching. Together they were hiding 36
files across 10 repos, and in one repo they were hiding a directory the
repo deliberately tracks ten files inside.
That last one is the real cost. Ignore rules never affect tracked files,
so nothing broke. But the next intentional file of that name, in the one
repo where it belongs in a commit, is born invisible: git status will
not mention it, and you will not commit it, and nothing will tell you.
The four layers, and what each is for
| Layer | Committed? | Scope | Travels with |
|---|---|---|---|
.gitignore |
yes | this repo, everyone | the repo |
.git/info/exclude |
never | this repo, this machine | nothing (but see below) |
core.excludesFile (global) |
never | every repo on the machine | nothing |
per-worktree, via extensions.worktreeConfig |
never | one worktree | nothing |
Two facts decide everything:
Layers are additive, but core.excludesFile is one value. All four
sources combine when git decides what to ignore. But the global layer is a
single config key: set it again in an included config and the old file
stops applying entirely. That is how machines end up with two registries
and nobody noticing.
.git/info/exclude lives in the repo’s common directory. A worktree’s
.git is a pointer file; the real directory, including info/exclude, is
shared by every worktree of the repo. One line in that file ignores the
pattern in the main checkout and in all worktrees, current and future, and
can never reach a commit because git does not track its own metadata.
That second fact makes info/exclude the right home for exactly the
files this post is about: repo-specific, machine-local, worktree-borne.
The global file is the right home for almost nothing: the classic
.DS_Store case, and even that is arguable.
The fourth layer exists but read the fine print before wanting it:
per-worktree config requires extensions.worktreeConfig, and a
per-worktree core.excludesFile replaces the global one for that
worktree instead of adding to it. Since ignore patterns for files that do
not exist are free, I have yet to find a case where the union of patterns
in info/exclude is not simpler.
Migrating off the global, mechanically
The dangerous move is deleting the global first: the moment it stops
applying, every file it was hiding shows up as ?? in every repo at once,
and an agent running git add -A will happily commit your agent config
into a work repo. So the order is: write the per-repo entries first, prove
they cover everything, delete the global last.
No guessing is needed at any step. Git will tell you exactly what the global is hiding, per repo:
# what status looks like today vs. with no global ignore
diff <(git status --porcelain) \
<(git -c core.excludesFile=/dev/null status --porcelain)
# for each path that appeared: which pattern (and which file) hid it
git check-ignore -v -- <path>
Every line the diff produces is a fact: this repo depends on that global
pattern. check-ignore -v names the pattern and the file it came from,
which is also how I discovered registry number two. The migration is then:
append exactly those patterns to that repo’s .git/info/exclude, re-run
the diff everywhere until it is empty everywhere, and only then empty the
global file. I ran this across 90+ checkouts; the whole thing is an hour,
most of it waiting on git status.
The repos that needed nothing got nothing, which is the point. A global
pattern is a claim about every repo you will ever clone. A line in
info/exclude is a claim about one repo, and it can be wrong without
poisoning the others.
Making it reproducible
info/exclude has one genuine weakness: it travels with nothing. A new
machine, a teammate, a re-clone all start from an empty file, and the
knowledge of what to exclude lives nowhere.
So give it a source of truth that is committed, without committing the excludes themselves. As of this week, a pwt Pwtfile can declare them:
# Pwtfile - setup() runs on every `pwt create`
setup() {
pwtfile_git_exclude "pnpm-lock.yaml" "pnpm-workspace.yaml" ".claude/"
}
pwtfile_git_exclude appends each pattern to the repo’s common
.git/info/exclude, idempotently, so declaring it on every create costs
nothing and a fresh machine converges on the first worktree it creates.
The Pwtfile is committed; the exclude file never is. The declaration
travels, the state stays local.
Running agents in your worktrees? This failure is agent-shaped on both ends: agents generate exactly the files that need excluding, and agents run
git add -Awithout reading the status first. A pattern declared in the Pwtfile closes the loop before either happens.
pwtfile_git_exclude ships in pwt as of this week, alongside the port
allocation and setup hooks from the
earlier
posts:
brew install jonasporto/pwt/pwt.
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.