.gitignore only ever applied to untracked files·the fix is two commands·git check-ignore -v names the rule·three ignore layers, in precedence order

The reason, before anything else: the file is already tracked

A .gitignore pattern only affects untracked files. That is the whole rule. If a path is in the index, because someone ran git add . once upon a time, Git keeps tracking it forever and reports every change, and no pattern you write afterwards changes that. The ignore machinery is consulted when Git decides which unknown files to mention in git status and which ones git add . should pick up. Files it already knows never go through it.

So the sequence that produces the bug is boring and extremely common. You start a project, commit everything including .env or dist/ or that 400 MB node_modules folder, then add a proper .gitignore, then wonder why nothing changed. Nothing changed because nothing was supposed to.

Quick check before you go pattern hunting: git ls-files --error-unmatch path/to/file. Exit code 0 means tracked, and your ignore rule was never in play. That single command settles the argument faster than any amount of staring at patterns.

Untracking without deleting anything

The fix is git rm --cached. The --cached flag removes the path from the index and leaves the file on disk untouched, which is exactly what you want. For a directory add -r:

  • git rm --cached .env for one file, then commit.
  • git rm -r --cached node_modules for a directory, then commit.
  • The nuclear version when the ignore file has drifted a lot: git rm -r --cached . followed by git add . and one commit. That re-stages the whole tree through the current ignore rules, and the resulting diff is a pile of deletions for everything that should have been ignored all along.

Two things to know before you push that. First, the commit records a deletion, so everyone who pulls loses their local copy of that file. For build output nobody cares. For a config.local.php that every developer has filled in differently, announce it first, or you will spend the afternoon on Slack. The pattern the Git documentation itself recommends is committing a config.sample and having everyone copy it to the ignored name.

Second, and this is the part people get wrong with security consequences: untracking does not touch history. The old commits still contain your API key, and so does every clone, fork and CI cache of the repository. Rotate the credential. Rewriting history with git filter-repo (the tool Git’s own docs now point to instead of the retired filter-branch) is cleanup, not remediation. And while you are in there, check what else got committed: a base64 blob in a config file is not protection either, as we picked apart in base64 is not encryption, and the file permissions on the copy you just left on disk deserve a look too, see why chmod 777 is never the answer.

git check-ignore -v, the command nobody uses

If the file genuinely is untracked and still shows up, or vanishes when it shouldn’t, stop guessing. Git ships a debugger for exactly this:

git check-ignore -v <path>

It prints the source file, the line number and the exact pattern responsible, in the form source:linenum:pattern pathname. So instead of “something is ignoring my file”, you get .gitignore:14:*.log logs/app.log, and the argument is over. It also handles the reverse case: if the command prints nothing at all, no pattern matched, and whatever is hiding your file is not the ignore system.

Three details worth knowing:

  • Add --no-index for tracked paths. By default check-ignore consults the index and stays quiet about paths Git already tracks. With --no-index it answers the pure pattern question, which is how you find out why git add . picked up a file the rules should have excluded.
  • The exit codes are scriptable. 0 means at least one path is ignored, 1 means none is, 128 means something broke. Handy in a pre-commit hook.
  • To see the whole picture, git status --ignored lists ignored entries alongside the usual output, and git ls-files --others --ignored --exclude-standard prints every ignored file in the tree. Good for auditing a .gitignore you inherited.

The pattern rules people actually get wrong

Git’s ignore patterns are glob patterns with three additions that carry all the confusion: the leading slash, the trailing slash and **. Everything else behaves like a shell glob, except that * does not cross a directory separator.

PatternMatchesNote
buildany file or directory named build, at any depthno slash anywhere means “match at any level below this .gitignore”
/buildonly build in the directory holding this .gitignorethe leading slash anchors, it does not mean “root of the repo”
build/directories named build onlya file called build stays tracked
doc/*.txtdoc/a.txt but not doc/sub/b.txta slash in the middle also anchors the pattern
**/logslogs anywhereidentical to plain logs
logs/**everything inside logs, at infinite depththe directory entry itself is not matched
a/**/ba/b, a/x/b, a/x/y/bslash-star-star-slash matches zero or more directories

The anchoring rule is the one worth memorising, because it is stated backwards in most tutorials: a pattern containing a slash anywhere except at the very end is relative to the directory of the .gitignore file itself. A pattern with no slash floats and matches at any depth. That is why /node_modules in a monorepo root ignores exactly one folder while node_modules ignores all of them, and why people copying rules between a root and a package-level ignore file get different results with the same text.

Two smaller ones that cost real time. An empty directory is invisible to Git regardless of ignore rules, so a folder “disappearing” from a clone is usually not your .gitignore. And a line ending in a backslash-escaped space is the only way to keep a trailing space in a pattern; otherwise trailing whitespace is stripped, which quietly breaks patterns copied out of a chat window.

Why your ! rule does nothing

A pattern starting with ! re-includes a previously excluded path, and within one file the last matching pattern wins. So far so good. Then you write this and it does not work:

secrets/ followed by !secrets/README.md

The gitignore documentation explains why in one sentence: it is not possible to re-include a file if a parent directory of that file is excluded, because Git does not list excluded directories for performance reasons, so any patterns on contained files have no effect no matter where they are defined. Git sees secrets/ matched, prunes the whole subtree, and never looks inside. Your negation is dead code. Worse, a .gitignore sitting inside an ignored directory is never read either, for the same reason.

The fix is one character:

secrets/* followed by !secrets/README.md

With secrets/* the directory itself is never excluded, only its entries are, so Git still descends into it and the negation gets its chance. For nested keeps you have to repeat the trick at every level, for example assets/*, then !assets/icons/, then assets/icons/*, then !assets/icons/logo.svg. Verbose, but it works, and git check-ignore -v shows you exactly which line is still winning if it doesn’t.

One more escape hatch: git add -f path forces a single ignored file into the index. It is the right answer for a one-off (a checked-in binary that lives in an ignored folder) and the wrong answer as a habit, because the file is then tracked and, as established at the top, the ignore rules stop applying to it entirely.

Three ignore layers, and which one wins

Patterns come from more than one place, and mixing them up produces the classic “it works on my machine, it doesn’t work in CI” report.

LayerLocationScope
Repository ignore.gitignore, any directorycommitted, applies to everyone
Personal repo ignore.git/info/excludethis clone only, never pushed
Global ignorecore.excludesFileevery repository on your machine

Precedence runs from most specific to least: patterns given on the command line beat per-directory .gitignore files (with a deeper file overriding a shallower one), which beat .git/info/exclude, which beats core.excludesFile. Within a single file, later lines win over earlier ones.

The global file trips people up twice. Its default path is $XDG_CONFIG_HOME/git/ignore, falling back to ~/.config/git/ignore when that variable is unset, which is not the ~/.gitignore_global that every blog post from 2013 tells you to create; that name only works if you also point core.excludesFile at it. And it is the right home for .DS_Store and editor droppings, not the shared repository file. Your operating system’s clutter is not the project’s problem, so keep it out of the project’s ignore file.

The output of git check-ignore -v tells you which layer answered, incidentally. The source is printed as an absolute path when the rule came from core.excludesFile, and relative to the repository root when it came from .git/info/exclude or a per-directory file.

macOS, case, and files that come back from the dead

Ignore patterns are matched as text, but macOS (APFS by default) and Windows filesystems are case-insensitive while preserving the case you typed. So Logs/ and logs/ are one directory to the OS and two different strings to a naive reader of your ignore file. Git detects this at init and clone time and sets core.ignorecase = true, which papers over a lot of it locally, but the setting is per-clone. A colleague on Linux, or your CI container, gets the case-sensitive behaviour and a different result from the same repository.

The related trap is renaming a file’s case. git mv Readme.md README.md fails or silently does nothing on a case-insensitive filesystem; you need git mv -f, or two commits with an intermediate name. Until then the file keeps its old name in the index while showing the new one in Finder, which looks exactly like an ignore bug and is not one.

Practical rule: write patterns in the exact case the files use, and don’t rely on core.ignorecase being set the same way for everyone. If you want case-insensitive matching in a shared ignore file, write it out, for example [Tt]humbs.db.

assume-unchanged is not an ignore, and neither is skip-worktree

Sooner or later someone finds git update-index --assume-unchanged on Stack Overflow and presents it as the way to ignore local changes to a tracked file. It isn’t, and the Git documentation says so bluntly: users often try to use the assume-unchanged and skip-worktree bits to tell Git to ignore changes to tracked files, and this does not work as expected, since Git may still check working tree files against the index when performing certain operations. The manual page ends that paragraph with the flat statement that Git does not provide a way to ignore changes to tracked files.

What the two bits actually are:

  • assume-unchanged is a performance hint. It was added for large trees on filesystems with a slow lstat() call, such as CIFS shares, and it tells Git it may skip the stat check. Git is free to clear the bit whenever it feels like it, and a git pull that touches the file will happily overwrite your local edits or abort with a confusing message.
  • skip-worktree is stronger and stickier. It tells Git to avoid writing the file to the working tree and to treat it as unchanged when it isn’t there. It takes precedence over assume-unchanged when both are set, and it is the mechanism behind sparse checkout. But it turns rebases, stashes and merges into a minefield the moment upstream modifies that file, and the error messages point nowhere near the actual cause.
  • Finding them again is the part nobody documents: git ls-files -v prints a status letter per path, where a lowercase letter means assume-unchanged and S means skip-worktree. Every team that uses these flags eventually needs this command to explain why one person’s changes never reach the repository.

The supported solution for “this file is tracked but everyone needs their own version” is the boring one: track a sample, ignore the real one, copy on setup. Same shape as pinning dependencies with a committed lockfile while the loose ranges live in the manifest, which we go through in caret vs tilde in semver. Commit the template, ignore the instance.

More .gitignore problems

Why is my .gitignore not working?

In the overwhelming majority of cases the file is already tracked. A .gitignore only affects untracked paths, so once a file has been added to the index, Git keeps reporting its changes no matter what patterns you write afterwards. Run git rm --cached on the path and commit that removal, then the ignore rule takes effect. If the file was never committed, the cause is the pattern itself, and git check-ignore -v will tell you which rule matched or that none did.

How do I untrack a file in Git without deleting it?

Use git rm --cached <path> for a single file, or git rm -r --cached <dir> for a directory. The --cached flag removes the path from the index only, so the file stays on disk exactly as it is. The next commit records the deletion, which means anyone who pulls will lose their copy of that file, which is what you want for build output and exactly what you must warn people about for a config file everyone has locally.

How do I ignore a file only for myself without changing .gitignore?

Put the pattern in .git/info/exclude inside the repository. It uses the same syntax as .gitignore, applies only to your clone, and is never committed or pushed, which makes it the right place for editor scratch files, personal notes and local debug scripts that have no business in a shared ignore file. For patterns you want in every repository on your machine, set core.excludesFile, whose default location is $XDG_CONFIG_HOME/git/ignore, falling back to ~/.config/git/ignore.

How do I ignore everything in a folder except one file?

Ignore the folder contents with dir/* rather than the folder itself with dir/, then re-include with !dir/keep.txt. The distinction matters because Git does not descend into an excluded directory, so a negation pattern for something inside it is never even evaluated. With dir/* the directory itself stays visible to Git and the negation works. The gitignore documentation states it directly: it is not possible to re-include a file if a parent directory of that file is excluded.

Does adding a file to .gitignore remove it from Git history?

No. Ignoring a path affects future scanning of the working tree, nothing else, and git rm --cached only removes it from the current index. Every commit that already contains the file still contains it, and anyone with a clone or a fork can read it. For a leaked credential the only correct response is to rotate the secret; rewriting history with git filter-repo or the BFG is optional cleanup afterwards, not the fix.

What is the difference between .gitignore and .git/info/exclude?

They use identical syntax and differ only in scope and precedence. A .gitignore file lives in the working tree, gets committed, and applies to everyone who clones the repository. The .git/info/exclude file lives inside the .git directory, never leaves your machine, and is the correct place for personal ignores. When both match a path, the .gitignore wins, because per-directory .gitignore files rank above info/exclude, which in turn ranks above the global core.excludesFile.

Why does git status still show a folder I ignored?

Either the folder contains tracked files, in which case the ignore rule is irrelevant for them, or the pattern does not match. A trailing slash such as build/ matches directories only, and a leading slash such as /build anchors the pattern to the directory containing that .gitignore rather than matching a build folder at any depth. Run git check-ignore -v --no-index on a concrete file inside the folder; that reports the matching pattern even for paths already in the index.

Should .gitignore be committed to the repository?

Yes, that is the entire point of it. A committed .gitignore keeps build artefacts, dependency folders and local config out of everyone’s status output and out of accidental git add . commits. GitHub maintains a public collection of language-specific starter files at github/gitignore, which is what the template dropdown in the repository creation form pulls from. Personal, machine-specific patterns belong in .git/info/exclude instead, so the shared file stays readable.