- date
- entry
- 042
- topic
- tooling
- rev
- —
grep -r in Claude Code skips files listed in .gitignore: one string, 6 files versus 13
Same repo, same string: in Claude Code's Bash tool grep -rl returns 6 files and command grep returns 13 (measured before this post was added). The tool's grep is ugrep with --ignore-files.
I use grep -rl a lot to find which files contain a string, and rarely doubt its answer: if a file in the search scope contains the string, it gets listed. In Claude Code, an agent searching for something usually uses the Bash tool's grep too.
But in the same repo, for the same string, grep -rl indexnow-ping-defaults . in Claude Code's Bash tool returns 6 files and command grep returns 13. An earlier note was about which shell that tool runs; this one is about which grep. In order: how to check, the three ways it differs from the system grep, when that leads to a wrong conclusion, and why one session actually has two greps.
First check: grep is a shell function
Ask the tool's shell what the name refers to (Claude Code 2.1.299, macOS):
$ type grep
grep is a shell function from /Users/…/.claude/shell-snapshots/snapshot-zsh-….sh
$ grep --version | head -1
ugrep 7.8.4 aarch64-apple-macosx …
$ command grep --version | head -1
grep (BSD grep, GNU compatible) 2.6.0-FreeBSD
The function body (whence -f grep, abridged):
grep () {
… # with -Z, -z, --null, --config and the like among the arguments, call command grep instead
local _cc_bin="${CLAUDE_CODE_EXECPATH:-}"
…
ARGV0=ugrep "$_cc_bin" -G --ignore-files --hidden -I \
--exclude-dir=.git --exclude-dir=.svn --exclude-dir=.hg \
--exclude-dir=.bzr --exclude-dir=.jj --exclude-dir=.sl ${1+"$@"}
}
So grep runs Claude Code's own executable as ugrep, with a fixed set of arguments in front. find is the same idea, an embedded bfs, and in this session rg is the same executable too. Claude Code's tools reference (the Glob section) documents this: on macOS, Linux and WSL the default tool set leaves out Glob and Grep and Claude searches with find and grep through Bash, and "In Claude's shell those two commands run embedded versions of bfs and ugrep". The 2.1.117 changelog (April 22, 2026) has the same item, limited at the time to native macOS and Linux builds, with Windows and npm-installed builds unchanged. The same section says the Grep tool skips gitignored files; whether the Bash grep does, I couldn't find stated there, so what follows is measured.
Three differences: ignore files, binary files, path format
A test directory with a .gitignore listing dist/ and .env, and four files containing API_URL: .env, dist/app.js, src/app.js, and src/blob.bin, which has a NUL byte in it. The directory is not a git repo.
$ grep -rl API_URL . # the tool's grep
src/app.js
$ command grep -rl API_URL . | sort # the system grep
./.env
./dist/app.js
./src/app.js
./src/blob.bin
--ignore-files: while recursing it applies the exclusion rules of every.gitignoreit passes (including wildcards and!negations) and doesn't search what matches. No git repo needed; a.gitignorefile is enough.-I: matches in binary files are ignored.src/blob.binhas a NUL byte, is treated as binary, and so is missing.- With
.as the starting point, paths have no./prefix. Normalise paths before comparing with the system grep's output.
Ignored files aren't unsearchable; they just aren't entered during recursion. Naming them gets around the parent ignore rule in this example (other filters such as -I still apply): grep -c API_URL .env dist/app.js reports one matching line for each file (exit code 0), and grep -r API_URL dist finds it too. Searching from inside dist/ also finds it, because that .gitignore is one level up and the recursion never passes through it. The result depends on which directory you start from, which is easier to forget than "it skips ignored files".
When it leads to a wrong conclusion
Back to the two numbers from the top (measured on October 15, 2026, before this note was added to the repo). This site's .gitignore lists node_modules/ and dist/:
$ grep -rl indexnow-ping-defaults . | wc -l
6
$ command grep -rl indexnow-ping-defaults --exclude-dir=node_modules --exclude-dir=.git . | wc -l
13
According to the comparison at the time, all 7 missing files were in dist/. If you only want non-ignored source files, leaving out build output fits. The trouble is when the build output is the question: "did this string get bundled", "is the old URL still in the output", "did the key leak into the bundle". A grep -r from the repo root never enters dist/, so its result can't answer these; when the source doesn't happen to contain the string, its answer is "no".
The exit code changes with it. When only an ignored file contains the string and there are no read errors, the tool's grep returns 1 (not found) and the system grep returns 0 (TOKEN=abc and API_URL=staging are dummy test values):
$ cd only
$ cat .gitignore
secret.txt
$ grep -r TOKEN .; echo "exit=$?"
exit=1
$ command grep -r TOKEN .; echo "exit=$?"
./secret.txt:TOKEN=abc
exit=0
Suppose a pre-deploy guard reads grep -rFq -- "$API_KEY" . && { echo "key leaked"; exit 1; } (-F matches the key literally rather than as a regex), the non-empty key appears only in dist/, and the search hits no errors. Run in a terminal or CI, it takes the blocking branch; typed directly into Claude Code's Bash tool by an agent, it doesn't, and whether the deploy continues depends on how the script handles that line's exit code (a hypothetical inferred from the measurements above, not an incident I've had).
One session, two greps
The function is part of that shell's state (a parenthesised subshell inherits it). Any wrapper that executes another program, xargs, find -exec, timeout, env, can't see shell functions and looks up the real executable on PATH; with this session's PATH that's /usr/bin/grep:
$ echo . | xargs grep -rl API_URL | sort
./.env
./dist/app.js
./src/app.js
./src/blob.bin
$ bash -c 'type grep'
grep is /usr/bin/grep
bash script.sh and zsh -c '…' are the same: a newly started shell that doesn't reload the functions uses the system grep (a script run with source in the current shell still gets the function). So within one session, a grep -r typed directly skips ignored files and one going through xargs or a new shell doesn't. The function has its own exception too: with -Z, -z, --null and similar among the arguments it hands off to command grep, so grep -rlZ API_URL . lists all four files.
rg is blunter. In the snapshot, grep and find are replaced by functions unconditionally, while the rg function is a fallback defined only when no rg executable is found. This session's PATH has no ripgrep, so there's only the function:
$ timeout 5 rg --version
timeout: failed to run command ‘rg’: No such file or directory
$ echo x | xargs rg --version
xargs: rg: No such file or directory
I hit that line in the same session I wrote this in, after putting timeout 100 in front of rg to keep a search from running too long.
How I write it
- To match what the terminal shows, write
command grep. Here it resolves to/usr/bin/grep(write that absolute path to be certain). On macOS that's BSD grep, which has its own differences from GNU grep, but at least it's what I see in my terminal. - To search an ignored directory, pass it as an argument.
grep -r X dist/searches it;grep -r X .never goes in. - When comparing two searches, use the same grep for both. If one was typed directly and one went through
xargs, the difference may be the tool, not the content. - When
rgis only a function, don't puttimeoutorxargsin front of it. They can't find it; for a time limit, use the Bash tool's own timeout parameter (the docs note a timed-out command may move to the background rather than stop).
If you remember one thing
In this Claude Code 2.1.299 session's Bash tool, grep -r is ugrep applying .gitignore and ignoring matches in binary files (unless the arguments trigger the switch to the system grep or override the defaults with something like -a or --no-ignore-files). The gap between 6 and 13 at the top was build output; if the question is what's in the build output, use command grep or name the directory in the arguments.
Revision history
- First published