`.gitignore` vs `.git/info/exclude` vs Global Excludes
A file your build script quietly depends on is invisible in git status, works fine on every laptop, and fails the moment it hits a clean CI runner — because it was never tracked and never really ignored either.

A build starts failing on CI with Cannot find module './config.local.js'. Nobody touched that file. It works on every laptop on the team. git status is clean, .gitignore doesn't mention it, and yet a fresh clone doesn't have it. This is what happens when a file gets ignored in a way that only exists on one machine.
Git actually has three separate places a file can be excluded from tracking, and they don't all travel with the repository.
Three exclude files, three different reaches
| Mechanism | Location | Committed? | Applies to |
|---|---|---|---|
.gitignore | tracked in the repo | Yes | Everyone who clones the repo |
.git/info/exclude | inside .git, never cloned | No | Only this local clone |
| Global excludes file | core.excludesFile, usually ~/.gitignore_global | No | Only this machine, every repo on it |
.gitignore is the one everyone knows: commit it, and every clone — including CI — inherits the same ignore rules. The other two are personal. They're genuinely useful for editor swap files or a local .env you never want to see in git status, but a file ignored only by .git/info/exclude is invisible to you and completely absent everywhere else — because that exclude file isn't part of the repository at all.
Watching it happen
A build script that depends on a small local config module:
// build.js
const { API_URL } = require("./config.local.js");
console.log(`Building against ${API_URL}`);The config file itself, created once and never committed:
// config.local.js
module.exports = { API_URL: "https://staging.internal.example.com" };Right now it shows up as untracked, same as any new file would:
git status --short
# ?? config.local.js
node build.js
# Building against https://staging.internal.example.comAdd it to the local, per-clone exclude file instead of .gitignore:
echo "config.local.js" >> .git/info/exclude
git status --short
# (nothing)git status now reports a clean working tree. The build still works, because the file is still sitting right there on disk — it's just invisible to git. This is the exact moment the trap gets set: nothing here looks wrong.
Where it breaks
Clone the repo somewhere else — a teammate's laptop, or a CI runner:
git clone /path/to/repo ci-runner
cd ci-runner
node build.jsnode:internal/modules/cjs/loader:1408
throw err;
Error: Cannot find module './config.local.js'
Require stack:
- /ci-runner/build.js.git/info/exclude lives inside the .git directory of the original clone. It was never committed, so it never traveled anywhere. The clone has no idea config.local.js was ever supposed to exist, because as far as git's history is concerned, it never did.
The actual fix
.git/info/exclude isn't the bug here — using it for something the build genuinely needs is. Once you notice a locally-excluded file is load-bearing, there are two honest fixes:
- Commit a template, ignore the real file. Track
config.local.example.jswith placeholder values, add the realconfig.local.jsto the tracked.gitignore, and have a setup script orpostinstallstep copy the template on first run. CI either provisions the same file in its own setup step or, better, reads the value from an environment variable instead. - If it should be the same everywhere, just commit it. Not every "local" file is actually sensitive. If
config.local.jshas no secrets in it, the simplest fix is dropping it into version control and deleting the confusion entirely.
Either way, the fix is never "add it to .git/info/exclude on the CI runner too" — that treats a shared-history problem as a per-machine one, and you'll be back here the next time someone re-provisions a runner or a teammate does a fresh clone.
.git/info/exclude and the global excludes file both have real, narrow uses — keep your personal editor cruft (.DS_Store, *.swp) out of every repo's git status without polluting a shared .gitignore that other contributors didn't ask for. Just make sure nothing your build actually needs ever ends up only there.