I was trimming what the agent could read in the repository behind one of my wallpaper apps, which is mostly distribution images. I wrote assets/ into .antigravityignore, then added !assets/keep.png right underneath, because that one file still had to be visible.
Half of that worked. The assets/ tree went quiet, and keep.png disappeared along with it. I spent a while blaming caching before it occurred to me that the file was doing exactly what I had written, just not what I meant.
.antigravityignore uses the same syntax as .gitignore, and that syntax has a handful of rules you will not notice by reading them. I took the patterns apart one at a time against a matcher, and the failures — both the silent no-ops and the over-eager matches — clustered into four places.
Stop reading the file, start querying it
Start with the checking method, because eyeballing patterns stops working the moment the file grows past a dozen lines.
Since the syntax is the gitignore family, the fastest way to test a rule is to borrow git as a matcher. Recreate the path structure in a scratch directory, drop the rules in, and look at what survives.
mkdir -p /tmp/ignore-lab && cd /tmp/ignore-lab && git init -q .
mkdir -p assets/thumbs/ja
touch assets/a.png assets/keep.png assets/thumbs/b.png assets/thumbs/ja/c.png
printf 'assets/\n!assets/keep.png\n' > .gitignore
# List the files that are still on the readable side
git ls-files -o --exclude-standardgit ls-files -o --exclude-standard prints only what was not excluded. I prefer looking at what remains rather than what disappeared, because over-exclusion is far easier to spot that way.
One caveat cost me a detour. git check-ignore -v also prints negation lines — the ones starting with ! — and still exits 0 when it hits one. I read that as "excluded" and had to redo the first round of triage. When you want a mechanical yes or no, use -q and read exit code 1 as "not excluded."
git check-ignore -v assets/keep.png # → .gitignore:2:!assets/keep.png (exit 0)
git check-ignore -q assets/keep.png; echo $? # → 1 (not excluded)Everything measured below came from git 2.34.1. The syntax is shared, but I have no guarantee the two implementations are byte-for-byte identical, so it is worth confirming the final result against Antigravity's own behavior once.
1. Excluding a directory puts its contents out of reach of !
This was the one that tripped me up. Once a directory itself is excluded, nothing inside it gets walked — and what is never walked cannot be pulled back with a negation.
Here is the same "keep one file" goal written three ways.
| Rules | Files still readable |
|---|---|
assets/ + !assets/keep.png | none (keep.png was excluded too) |
assets/** + !assets/keep.png | assets/keep.png |
assets/** + !assets/thumbs/** | none |
assets/** + !assets/thumbs/ + !assets/thumbs/** | assets/thumbs/b.png, assets/thumbs/ja/c.png |
The third row is the interesting one. !assets/thumbs/** on its own brings back nothing, because assets/** also matches the assets/thumbs directory node, and the walk stops before it descends. You need two lines: re-include the directory (!assets/thumbs/), then re-include its contents (!assets/thumbs/**).
Put differently, the most intuitive way to write "exclude this folder, except that one file" is precisely the way that does not work.
Order matters too. Placing !assets/keep.png above assets/** left the file excluded. The last matching line wins, so exceptions always go after the rule they carve out of.
2. A slash changes where matching starts
This is where the over-exclusion came from. Whether the pattern contains a slash anywhere but the end changes its meaning entirely.
| Pattern | src/tmp/t.txt | docs/src/tmp/z.txt |
|---|---|---|
tmp/ (no internal slash) | excluded | excluded |
src/tmp/ (internal slash) | excluded | not excluded |
A pattern with no internal slash matches a directory of that name at any depth. A pattern that contains one is anchored to the directory holding the ignore file.
I also wanted to see how that plays out at scale, so I ran it against a 2,250-file Next.js project with a single line in the ignore file.
| The line | Paths matched |
|---|---|
content | 2,133 of 2,250 (about 95%) |
content/articles/en/, public/content/, *.lock (three lines) | 1,032 of 2,250 |
One unanchored word took almost the entire repository with it — src/content, docs/content, and every similarly named directory further down. When an agent suddenly starts answering as if it has never seen your code, the model is not always the thing to suspect first.
Worth noting: the public/content/ line matched zero paths. In that repository it is a build-time output that does not exist in a fresh clone. A rule that appears not to be working is sometimes a rule with nothing to match.
3. * does not cross directory boundaries
The classic version of this is writing assets/*.png and wondering why the nested images are still being read.
| Pattern | assets/a.png | assets/thumbs/b.png | assets/thumbs/ja/c.png |
|---|---|---|---|
assets/*.png | excluded | not excluded | not excluded |
assets/**/*.png | excluded | excluded | excluded |
A single * covers exactly one path segment. For arbitrary depth you need ** in the middle. Assets and build output, which tend to nest, are where this gap shows up most.
4. Invisible characters and leading punctuation
The last group is the kind you cannot see by opening the file. Four cases, each measured.
| Case | Result |
|---|---|
Trailing space (assets/a.png␣) | The space is stripped; the rule still matched |
Trailing space protected with a backslash (assets/a.png\␣) | No match — it now demands a filename that ends in a space |
| CRLF line endings | The rule still matched |
Filename starting with # (#temp.txt) | Treated as a comment. \#temp.txt works |
Trailing whitespace and CRLF look suspicious and turned out to be harmless, so they belong near the bottom of your triage list. Real filenames that begin with # or !, on the other hand, do need the backslash.
Count what is left, not what you removed
Auditing your rules takes one command. Count the paths still on the readable side rather than the ones you managed to exclude.
# Run your actual .antigravityignore through the matcher and count
find . -path ./.git -prune -o -type f -print | sed 's|^\./||' > /tmp/all-paths.txt
git -c core.excludesFile=.antigravityignore \
check-ignore --no-index --stdin < /tmp/all-paths.txt | wc -l # excluded
wc -l < /tmp/all-paths.txt # totalI look at those two numbers, and if the magnitude is off from what I expected, the rules get rewritten. A case like the 2,133 above is obvious the moment you count it. A stubborn zero, on the other hand, points at a typo in the path — or at a path that is not there yet.
For the design question underneath all this — what counts as a secret, what is merely noise — I have written that up separately in Keep the Wrong Files Out of Antigravity's AI Context and Mastering Antigravity's .antigravityignore. If exclusion is working correctly and things are still slow, When Antigravity's Workspace Indexing Won't Finish is probably the closer match.
The one step I would suggest today is running those two lines against whatever project you have open and writing the numbers down. Having a baseline means that the next time something goes strange, the ignore file makes it onto your list of suspects instead of being assumed innocent.
Once you hand the agent write and delete permissions, of course, controlling what it reads is no longer enough. I traced an actual incident and its recovery in The Cleanup Step Removed the Working Directory, Not Its Contents, which is about stopping destructive work after the approval has already gone through.
Thank you for reading this far. I used to consider an ignore file finished the moment I saved it, and this particular afternoon is what got me into the habit of counting instead.