A negated .gitignore rule can name exactly the right file and still fail when an earlier rule excludes its parent directory. Git does not descend into an excluded directory to discover exceptions beneath it. To expose one untracked file, keep every required ancestor available and separately exclude the siblings you still want hidden. Verify the resulting file set, because opening a parent too broadly can expose more files than intended.
Recognize the excluded-parent failure
Consider a repository containing build/keep/report.txt, build/keep/secret.tmp, build/drop/no.txt, and build/root.tmp. This root .gitignore cannot restore the report:
/build/
!/build/keep/report.txtThe first rule excludes the directory itself. The later file exception does not reopen that directory. Moving !report.txt into build/keep/.gitignore cannot solve the excluded-parent problem either: Git must first reach that directory to apply its rules.
This is documented Git behavior. A historical ripgrep reader question showed the related pattern * followed by !*.c: root files could be eligible while nested files remained hidden. That report motivates the problem, but the executable checks here use Git in a temporary repository. They do not claim to test every ripgrep version or configuration.
Reopen the intended ancestors and keep siblings excluded
For the four-file example, place this policy in the root .gitignore:
/build/*
!/build/keep/
/build/keep/*
!/build/keep/report.txtThe first rule excludes children of build rather than excluding build itself. The second reopens the keep directory. The third excludes that directory's contents again, and the fourth selects the one report. Later matching rules within this file take precedence. The leading slashes make this example relative to the repository root containing the .gitignore.
The re-exclusion step is essential. If you use only /build/* and !/build/keep/, files inside keep may become visible together. That would include secret.tmp in this example. For a deeper selected path, apply the same ancestor-by-ancestor reasoning; do not assume one exception automatically opens every parent.
A .gitignore is not a secret protection mechanism. A user can force-add ignored content, and already tracked files are outside this ignore policy. Keep credentials outside the repository and use the appropriate security controls. These patterns control which untracked files ordinary Git discovery considers.
Verify the whole visible set in a disposable repository
This self-contained Python program runs Git without changing your existing repository, index, global settings, or network state. It clears global Git configuration only for its child processes and removes its temporary directory on exit. Run it with Python 3 and Git installed.
from pathlib import Path
import os
import subprocess
import tempfile
env = os.environ.copy()
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
for name in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
env.pop(name, None)
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
def git(*args):
return subprocess.run(
["git", *args], cwd=root, env=env,
text=True, capture_output=True, check=False
)
assert git("init", "-q").returncode == 0
paths = ["build/keep/report.txt", "build/keep/secret.tmp",
"build/drop/no.txt", "build/root.tmp"]
for name in paths:
file = root / name
file.parent.mkdir(parents=True, exist_ok=True)
file.write_text("fixture\n")
policy = root / ".gitignore"
policy.write_text("/build/\n!/build/keep/report.txt\n")
assert git("check-ignore", "-q", paths[0]).returncode == 0
policy.write_text("/build/*\n!/build/keep/\n")
assert git("check-ignore", "-q", paths[1]).returncode == 1
policy.write_text(
"/build/*\n!/build/keep/\n/build/keep/*\n"
"!/build/keep/report.txt\n"
)
assert git("check-ignore", "-q", paths[0]).returncode == 1
for name in paths[1:]:
assert git("check-ignore", "-q", name).returncode == 0
visible = git("ls-files", "--others", "--exclude-standard")
assert visible.returncode == 0
assert sorted(visible.stdout.splitlines()) == [
".gitignore", "build/keep/report.txt"
]
assert git("ls-files").stdout == ""
print(visible.stdout, end="")The negative control deliberately opens the parent without re-excluding its contents and confirms that secret.tmp becomes eligible. The final assertions check both the desired report and every unwanted sibling, followed by the complete visible untracked set. Nothing is staged or committed. The root .gitignore itself appears because the example does not ignore it.
Separate a matching rule from the ignored decision
For an untracked file, git check-ignore -q path returns zero when ignored and one when not ignored; a fatal error is a separate outcome. Do not treat any nonzero result as a successful exception without checking for errors. In a script using set -e, put this diagnostic inside an explicit conditional so the expected “not ignored” result does not abort the script.
git check-ignore -v path helps locate the applicable source and pattern. Its output can show a negation rule. A printed line, or its status alone, should not be interpreted as proof that the file remains ignored. Read the pattern and compare the quiet decision with git ls-files --others --exclude-standard. For conflicting repository, nested, or user rules, use the existing ignore-rule source diagnostic rather than deleting settings at random.
Use a broad extension allowlist only when it is the policy
The policy *, then !*/, then !*.c, on separate lines, keeps directories traversable while making C files eligible throughout the tree. It is broader than reopening one report under one directory. Choose it only if that repository-wide extension policy is intended, and test nested files and other extensions before adopting it. It also does not change already tracked files.
The safe completion criterion is the exact intended file set under ordinary Git discovery. A rule that makes the report appear but also exposes unrelated outputs has not passed. Reopen each required ancestor, narrow the contents again, and retain the unwanted-file assertions whenever the directory layout changes.