Folders behave differently from files in Git, and one decision drives everything else: whether your rule excludes the directory itself or only what is inside it. Get that wrong and the rule can look correct while quietly refusing to let you keep anything, or refusing to apply at all.
The one thing to internalise. Git does not walk into a folder it has excluded. Every folder-specific surprise —.gitkeep vanishing, a ! line that does nothing, a nested .gitignore that is never read — follows from that. Ignore the folder when you want it gone entirely; ignore itscontents when you need to keep anything inside.
Two rules that look alike and are not
build/ and build/* both stop the folder's files from showing up. They differ in whether the directory itself is excluded, and that difference decides everything downstream:
rule build/ itself files inside, any depth
build/ excluded excluded
build/* not excluded excluded
build/** not excluded excludedAll three hide the contents, so a quick git status makes them look interchangeable. The distinction only surfaces later, when you try to re-include something: with build/ the directory is gone and nothing inside can be reconsidered, while withbuild/* the directory is still walked and exceptions are possible.
Between build/* and build/** there is no practical difference. * cannot cross a slash on its own, but it does match the subdirectories, and once those are excluded their contents go with them — so both end up covering the whole subtree.
Where the rule matches: name, depth, anchor
A folder rule with no slash other than the trailing one matches by name at any depth. Add a slash elsewhere and it becomes anchored relative to the.gitignore holding it:
rule build/ a/build/ a/b/build/
build/ yes yes yes ← name, any depth
**/build/ yes yes yes ← identical to the above
/build/ yes — — ← top level only
a/build/ — yes — ← exactly this path
*/build/ — yes — ← exactly one level down
a/**/build/ — yes yes ← under a/, any depthTwo takeaways. **/build/ buys you nothing overbuild/ — the unanchored form is already recursive, which is the opposite of what most people assume. And */build/ meansexactly one directory in between, whereasa/**/build/ means zero or more, so it also matchesa/build/ itself.
If your folder rule is matching more than you intended, a leading slash is usually the fix. If it is matching less, you have probably anchored it without meaning to by including a slash mid-pattern.
Git never looks inside an excluded folder
This is the rule that explains the most confusing folder failures. Once a directory is excluded Git stops there — it does not read the files inside, and it does not read any .gitignore inside either:
# .gitignore
build/
# build/.gitignore
!keep.txt ← never read; build/keep.txt stays ignoredThe negation is not wrong, it is unreachable. Move it to an ignore file Git still visits and it works, because the folder above is being walked normally:
# .gitignore
logs/
# a/.gitignore
!logs/ ← this DOES re-include a/logs/ and its contentsSo the question to ask about any ! line is not whether the pattern is right, but whether Git gets far enough to read it.
A nested .gitignore governs only its own folder
Rules in a nested ignore file apply to that folder and its descendants, and nowhere else. A logs/ rule in a/.gitignorehides a/logs/ and leaves a logs/ folder at the repository root fully visible.
That is a feature when a subproject needs its own rules, and a trap when you half-remember writing a rule and cannot find why it does not apply. Repository-wide folder rules belong in the root.gitignore.
Keeping something inside an ignored folder
Because negation cannot reach into an excluded directory, whitelisting starts with ignoring the contents rather than the folder:
build/*
!build/keep.txt → keep.txt is visible, everything else stays ignoredThe same reasoning applies again one level down, which catches people who already know the trick. build/* also excludes the subdirectory build/sub, so a rule pointing inside it is unreachable until that directory is re-included too:
build/*
!build/sub/ → re-includes the directory, and keep.txt with itAnd for completeness, the two shapes that do not work — both blocked by the same traversal rule, whether the exception names a file or a pattern:
build/
!build/keep.txt → still ignored
build/
!build/*.txt → still ignoredEmpty folders cannot be committed at all
Git records files, not directories. A folder with nothing in it does not appear in git status and cannot be committed, no matter what your .gitignore says — so if you are trying to preserve a directory's existence, that is a separate problem from ignoring it.
The convention is a placeholder file, typically.gitkeep. Combining the two goals means ignoring the contents and re-including the placeholder:
build/*
!build/.gitkeep → the folder exists in the repo, its output does notNote that build/* does match .gitkeep — unlike your shell, gitignore wildcards do not skip dotfiles — which is exactly why the second line is needed. Swap the first line forbuild/ and the placeholder disappears along with everything else.
What git status is actually telling you
Git collapses a directory whose contents are entirely untracked into a single entry, which is why an unignored folder shows up as one line rather than a list:
$ git status --porcelain
?? mixed/
$ git status --porcelain --untracked-files=all
?? mixed/a.txt
?? mixed/b.logThat matters for diagnosis. A folder appearing as one collapsed?? folder/ line means Git considers the whole thing untracked, so your rule is not matching the folder at all — check the anchoring first. Expand with --untracked-files=all to see whether some files are covered and others are not.
git add gives you a second signal. When a directory rule is what blocks a file, the error names the directory rather than the file you asked for:
$ git add build/x.txt
The following paths are ignored by one of your .gitignore files:
build
hint: Use -f if you really want to add them.Seeing build there rather than build/x.txt tells you the decision was made at the folder level. git add -foverrides it for that one file if you genuinely need the exception.
A folder you already committed
Ignore rules only ever apply to untracked paths, so addingbuild/ does nothing to files inside it that are already in the repository. They keep being tracked, and the rule appears broken. The folder version of the fix needs -r to recurse:
$ git rm -r --cached build
$ git commit -m "Stop tracking build/"--cached removes the files from Git's index while leaving them on disk, so nothing is deleted from your working tree — the commit records the removal, and from then on the ignore rule takes over. Anyone pulling that commit will lose the files locally, which is worth mentioning in the commit message.
Confirm which rule decided
Rather than reasoning about anchoring, ask Git. It accepts a folder path and reports the file, line and pattern responsible:
$ git check-ignore -v build
.gitignore:1:build/ build
$ git check-ignore -v build/sub/y.txt
.gitignore:1:build/ build/sub/y.txtQuerying the folder itself is the quicker test — if no pattern is reported for build but one is reported for a file inside it, your rule is a contents rule rather than a folder rule, and negation will behave accordingly. Silence for a file that is still showing up usually means that file is already tracked. Read the printed pattern rather than the exit code, which with -v is 0 whenever any pattern matched, including a negation that leaves the path visible.
For the wider set of reasons an ignore rule can fail to bite, including tracked files and ignore-file placement, seegitignore not working.