SeriesPart 5 of 8 // Git Quick Hits
gitWriting
Sep 7, 2026
3 min read
git-quick-hits

`.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 file sitting quietly on a laptop desktop while the same spot on a server rack sits empty, with a red X where the file should be.

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

MechanismLocationCommitted?Applies to
.gitignoretracked in the repoYesEveryone who clones the repo
.git/info/excludeinside .git, never clonedNoOnly this local clone
Global excludes filecore.excludesFile, usually ~/.gitignore_globalNoOnly 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.com

Add 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.js
node: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:

  1. Commit a template, ignore the real file. Track config.local.example.js with placeholder values, add the real config.local.js to the tracked .gitignore, and have a setup script or postinstall step 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.
  2. If it should be the same everywhere, just commit it. Not every "local" file is actually sensitive. If config.local.js has 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.

Series contents

01
Automating Regression Hunts with `git bisect run`
Read
02
Parallel Branches Without Stashing: `git worktree`
Read
03
The Undo Button Nobody Uses: `git reflog`
Read
04
`git rerere`: Stop Resolving the Same Conflict Twice
Read
05
`.gitignore` vs `.git/info/exclude` vs Global Excludes
Current
06
Shipping a Repo as One File with `git bundle`
Coming soon // Sep 14, 2026
07
Attaching Build Metadata with `git notes`
Coming soon // Sep 21, 2026
08
Why Shallow Clones Break Your Changelog
Coming soon // Sep 28, 2026