In Git, why can a ! negation line in .gitignore fail to re-include a file?
answer
- Exclusion works at directory granularity
- Git optimises by not walking excluded trees
- The negation is never even evaluated
- Exclude the contents, not the directory
basics
~10 sGit never descends into an excluded directory, so no pattern inside it can re-include anything. Exclude the directory's contents with a pattern like assets/* instead of assets/, then negate the paths you want back.
solid answer
~50 sA `!` line un-ignores a path, but there is one hard limitation: it cannot re-include a file whose **parent directory** is excluded. When a directory matches an exclude pattern Git stops walking it entirely, for performance, so it never even sees the file your negation names. The fix is to exclude the directory's *contents* rather than the directory itself: write `assets/*` instead of `assets/`, then `!assets/keep.svg` works, because `assets` is still traversed. When the exception lives deeper you must re-include each intermediate directory as you go. Order matters too, since the last matching line wins: a negation placed above the broad rule it is meant to undo does nothing. And a literal filename starting with an exclamation mark has to be escaped as `\!name`. `git check-ignore -v <path>` will tell you which line actually decided.
code
text · 2 linesassets/
!assets/logo.svggo deeper
Know that ! un-ignores a path and that it must be written after the rule it overrides. If a negation seems inert, that is expected when a whole directory was ignored.
Explain the mechanism: an excluded directory is never traversed, so nothing inside it can be re-included. Show the fix, excluding contents with a pattern ending in slash-star, and confirm with git check-ignore -v.
Demonstrate judgment about layered rules on a real repository: verify before committing, keep exception chains shallow, and prefer restructuring so that hand-written files do not live inside a generated tree at all.
Own the convention across repositories: ignore files that stay small and reviewable, no elaborate allowlist gymnastics, and generated output segregated from source so exceptions are unnecessary rather than cleverly encoded.
## What negation does A line beginning with `!` reverses a previous match: the path it matches is *not* ignored, even though an earlier pattern excluded it. Combined with the rule that the last matching line wins, it is the tool for "ignore this whole class of files except these". ## The limitation everyone hits Git's exclude walk is directory-based. When it evaluates a directory and finds it excluded, it does not enumerate the contents at all. That is a deliberate performance decision: it is what makes a `node_modules/` line cheap instead of a walk over a hundred thousand files. The consequence is stated plainly in Git's documentation: it is not possible to re-include a file if a parent directory of that file is excluded. So an `assets/` line followed by `!assets/logo.svg` fails. The directory was excluded, Git never descends, the negation never gets a chance. ## The fix Exclude the *contents* rather than the directory. `assets/*` matches each entry inside `assets` but leaves `assets` itself un-excluded, so Git walks in, and `!assets/logo.svg` now takes effect. Note that `*` does not cross a slash, so `assets/*` does not match `assets/sub/file.png`; once you re-include `assets/sub/`, everything under it is visible again unless another rule excludes it. When the exception is several levels deep, you re-include each level: exclude `data/*`, then negate `data/fixtures/`, and if another rule excludes `data/fixtures/*`, negate the specific files inside. Think of it as opening one door at a time. ## The allowlist idiom The same mechanism supports "ignore everything, then opt in", useful in repositories that hold generated output next to a couple of hand-written files. Start with `/*` to exclude every top-level entry, then negate the ones you want, including `!/.gitignore` itself so the file does not exclude itself from the repository. Each opted-in directory then needs its own descent, so this style is powerful but easy to get wrong; verify it before committing. ## Ordering Because the last matching line decides, the negation must appear **after** the pattern it undoes, both within a file and in terms of layering. A later, broader rule silently cancels an earlier exception. Reviewers should treat appending a catch-all near the bottom of a long ignore file as a change that can affect everything above it. ## Escaping `!` is only special at the start of a pattern. To ignore a file literally named `!important.txt`, escape the marker: `\!important.txt`. The same escaping rule applies to a leading `#`, which would otherwise start a comment. ## Verifying Do not reason about layered negations in your head. `git check-ignore -v path/to/file` prints the source file, line number and pattern that decided the outcome, and exits with status 1 when the path is not ignored at all. If the reported line is a directory pattern rather than the negation you wrote, you have just confirmed the parent-directory limitation. Adding `--no-index` is useful when the path was already added to the index and you want to see the pattern verdict regardless. ## When to stop fighting it Deeply nested negation chains are a smell. Often the better answer is to keep the exception outside the ignored tree entirely, for example storing a template or fixture in a source directory and copying it into the generated tree at build time, which removes the need for any negation at all.
- How would you write a Git ignore file that hides everything at the repository root except a handful of paths?Start with `/*` so every top-level entry is excluded, then negate what you want back, for example `!/.gitignore`, `!/src/` and `!/README.md`. Because `*` does not cross a slash, re-including a directory makes its contents visible again. Verify the result with `git check-ignore -v` or `git status --ignored` before committing, since this style is easy to get subtly wrong.
- How do you ignore a file whose name literally begins with an exclamation mark?Escape the marker: write `\!important.txt`. The exclamation mark is only special as the first character of a pattern, so escaping it there makes Git treat the rest as a literal name. The same applies to a leading `#`, which would otherwise begin a comment and has to be written `\#`.
If you seal a whole room, painting a note on one box inside it changes nothing. You have to leave the room open and seal the boxes individually.
saying these in an interview costs you the question
- Believes any ! line always wins over exclusions
- Puts the negation above the rule it undoes
- Thinks node_modules/ then !node_modules/pkg/ works
- Says re-inclusion needs a leading slash to work
- Ignores that the last matching pattern decides