Searching Folders

grep: Searching Text

Chapter 2 ยท Searching Folders

Chapter 1 searched named files. Real work is usually “somewhere in this project”: dozens of folders, files you did not write, build output you do not want, hidden folders, and a few files that are not text at all. This chapter is about telling grep where to look and what to skip.

Run on two versions of grep
Every example was run on GNU grep 3.0 (Git Bash) and again on GNU grep 3.11 (WSL), on the same practice folder. There was one difference, the wording of the message for a binary file, and it is shown where it comes up. Two things in the output depend on your computer: the order of results (grep lists files in the order the folder holds them, which is not sorted and differs between file systems; add | sort when order matters) and the symlink example, which needs a Linux file system.

A Practice Project

Make an empty folder, change into it and paste this. It builds a small project folder with source files, a build folder, a hidden .git folder, a hidden .env file, a file name with spaces, and one binary file with the word TODO hidden inside it. You finish inside project.

mkdir -p project/src project/notes project/build project/logs project/data project/.git "project/my notes" cd project printf '# Demo project\nTODO write the install guide\nRun app.py to start\n' > README.md printf 'import os\n# TODO handle errors\ndef main():\n print("hello")\n' > app.py printf 'def helper():\n # TODO remove this\n return 1\n' > src/util.py printf '// TODO rename\nconsole.log("hi");\n' > src/main.js printf 'buy milk\nTODO call the plumber\n' > notes/todo.txt printf '/* generated */ var a = 1; // TODO generated noise\n' > build/bundle.js printf 'INFO started\nERROR something broke\n' > logs/app.log printf 'data\0TODO inside a binary file\n' > data/image.bin printf 'API_KEY=abc123\npassword=hunter2\n' > .env printf '[core]\n\trepositoryformatversion = 0\n# TODO placeholder inside .git\n' > .git/config printf 'TODO book the room\npassword reminder: change it\n' > "my notes/meeting notes.txt"

grep Does Not Enter Folders by Itself

grep TODO src
grep: src: Is a directory

Name a folder and plain grep refuses. The option that makes it go in is -r (recursive): search every file in the folder, every folder inside that, and so on.

Searching a Whole Tree: -r

grep -r TODO .
./.git/config:# TODO placeholder inside .git ./app.py:# TODO handle errors ./build/bundle.js:/* generated */ var a = 1; // TODO generated noise Binary file ./data/image.bin matches ./my notes/meeting notes.txt:TODO book the room ./notes/todo.txt:TODO call the plumber ./README.md:TODO write the install guide ./src/main.js:// TODO rename ./src/util.py: # TODO remove this

That is every file that contains TODO, with the path in front of each line. Notice what was searched without being asked: the hidden .git folder, the generated build folder, and data/image.bin, which is not text. Most of this chapter is about getting rid of that noise. (The binary file's line, Binary file … matches, is grep 3.0's wording; grep 3.11 prints grep: ./data/image.bin: binary file matches instead, and prints it on standard error. More on that below.)

With no folder at all, GNU grep searches the current folder; the only difference is that the paths lose their leading ./:

grep -r TODO
.git/config:# TODO placeholder inside .git app.py:# TODO handle errors build/bundle.js:/* generated */ var a = 1; // TODO generated noise Binary file data/image.bin matches my notes/meeting notes.txt:TODO book the room notes/todo.txt:TODO call the plumber README.md:TODO write the install guide src/main.js:// TODO rename src/util.py: # TODO remove this
Dot or star: they are not the same
grep -r PATTERN . asks grep to walk the folder, so it sees hidden files. grep -r PATTERN * lets the shell expand * first, and the shell does not include names that start with a dot. Compare, looking for the word password:
grep -rl password *
my notes/meeting notes.txt
grep -rl password .
./.env ./my notes/meeting notes.txt

The star missed .env, which is exactly the sort of file that holds a password. If a search seems to be missing things, check whether you used *.

Just the File Names: -l and -L

Often you do not want the lines, only which files. -l lists the files that contain a match, each once:

grep -rl TODO .
./.git/config ./app.py ./build/bundle.js ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py

-L is the opposite: the files that do not contain it.

grep -rL TODO .
./.env ./logs/app.log
-L and the exit status
The exit status of -L does not mean “I listed something”. It still means “a line matched somewhere”. So when -L lists nothing (every file matched) the status is 0, and when it lists a file (nothing matched in it) the status is 1. Both versions behaved this way:
grep -L TODO app.py README.md src/util.py; echo status $?
status 0
grep -L NOSUCHWORD app.py; echo status $?
app.py status 1

Do not use if grep -L ... as a test for “some file lacks the word”; look at its output instead.

Choosing Files: --include and --exclude

Search only files whose name matches a pattern with --include. Quote the pattern so the shell does not expand it:

grep -rl TODO --include='*.py' .
./app.py ./src/util.py

Repeat the option to allow more than one kind:

grep -rl TODO --include='*.py' --include='*.js' .
./app.py ./build/bundle.js ./src/main.js ./src/util.py

--exclude does the opposite: skip files whose name matches.

grep -rl TODO --exclude='*.js' .
./.git/config ./app.py ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/util.py

The name is matched against the file name only, not the folder it is in. So build/bundle.js and src/main.js are both removed by --exclude='*.js'.

If you give both, the last option that matches a file decides. I tried both orders on the same pattern; the first keeps the JavaScript files, the second finds nothing at all:

grep -rl TODO --exclude='*.js' --include='*.js' .
./.git/config ./app.py ./build/bundle.js ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py
grep -rl TODO --include='*.js' --exclude='*.js' .
(no output)

Skipping Folders: --exclude-dir

--exclude-dir takes a folder name and skips that folder wherever it appears in the tree. The two you will use most are .git and a build or dependency folder such as node_modules or build. Repeat the option for each:

grep -rl TODO --exclude-dir=.git --exclude-dir=build .
./app.py ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py

In bash, braces do the repeating for you (this is bash, not plain sh), with the same result:

grep -rl TODO --exclude-dir={.git,build} .
./app.py ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py

Binary Files

A file that contains a zero byte is treated as binary. If it matches, grep does not print the line (which would fill your screen with junk); it only says so:

grep TODO data/image.bin
Binary file data/image.bin matches

That is grep 3.0. In grep 3.11 the same search prints grep: data/image.bin: binary file matches, and it goes to standard error, not standard output. So a script that filters output with | grep -v '^Binary' works on one and not the other, and 2>/dev/null hides the message on the newer one. Three ways to deal with binary files:

OptionWhat it does
-ISkip binary files completely, as if they had no match
-aTreat a binary file as text and print its matching lines
--binary-files=TYPEThe long form: binary (default), text (same as -a) or without-match (same as -I)
grep -I TODO data/image.bin; echo status $?
status 1

-I printed nothing and gave status 1: “no match”, as far as it is concerned. With -a you get the text back (I used -o, which prints only the matching part, so the zero byte before it does not reach your screen):

grep -a -o 'TODO.*' data/image.bin
TODO inside a binary file

-a is for the occasional file you know is mostly text. Do not use it on a whole tree: it will dump raw binary into your terminal. For recursive searches of source code, -I is nearly always right:

grep -rIl TODO --exclude-dir=.git .
./app.py ./build/bundle.js ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py

Note that -l without -I did list data/image.bin earlier: a binary file that matches is still a match.

Symbolic Links: -r and -R

A symbolic link is a name that points at another file or folder. -r does not follow links it finds inside the tree, so it does not search the same folder twice (or go round in a circle); -R follows them all. A link you name yourself on the command line is followed by both. This needs a real Linux file system, so I ran it in WSL's home folder (grep 3.11 only; Git Bash cannot make real links without special settings and would copy the folder instead, which proves nothing):

mkdir -p ~/g2link/real && cd ~/g2link printf 'TODO in the real folder\n' > real/a.txt ln -s real link ls -l
link@ real/
grep -r TODO .
./real/a.txt:TODO in the real folder
grep -R TODO .
./link/a.txt:TODO in the real folder ./real/a.txt:TODO in the real folder
grep -r TODO link
link/a.txt:TODO in the real folder

Use -R only when you know the tree is meant to be walked through its links, and be aware that a link pointing back up the tree can make it loop.

File Names with Spaces: -Z and xargs -0

Our tree has my notes/meeting notes.txt. A common pattern is to feed grep's list of files to another command with xargs. Here it just prints one argument per line, so you can see how it split the names:

grep -rl TODO . | xargs -n1 echo
./.git/config ./app.py ./build/bundle.js ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py

xargs splits on spaces, so one file became three arguments, and any real command would complain that ./my does not exist. The cure is to separate the names with a zero byte, which cannot appear in a file name. -Z makes grep print them that way, and xargs -0 reads them that way:

grep -rlZ TODO . | xargs -0 -n1 echo
./.git/config ./app.py ./build/bundle.js ./data/image.bin ./my notes/meeting notes.txt ./notes/todo.txt ./README.md ./src/main.js ./src/util.py

Make -lZ / xargs -0 a habit whenever the next command does something, and always whenever it changes or deletes files.

grep or find?

grep -r chooses files by name (--include, --exclude) and folder name. find can choose by far more: type, size, age, depth, the whole path, permissions. When the file selection is the hard part, let find pick the files and hand them to grep with -exec ... {} +. Add -H so a single file still shows its name:

find . -name '*.py' -exec grep -H TODO {} +
./app.py:# TODO handle errors ./src/util.py: # TODO remove this
find . -name '*.py' -path './src/*' -exec grep -H TODO {} +
./src/util.py: # TODO remove this

The second one used -path, which matches the whole path, something --include cannot do. For everyday work, grep -r with --include and --exclude-dir is shorter. (Chapter 8 looks at tools such as ripgrep that make the common case shorter still.)

A Good Default for Searching Code

grep -rIn --exclude-dir=.git PATTERN .

Recursive, skip binary files, show line numbers, skip the .git folder. Add more --exclude-dir options for the folders your own projects generate. If the search finds nothing and you expected something, quiet failures are the likely cause: -s hides “no such file” errors, and the exit status is 2 either way:

grep -r TODO nosuchdir; echo status $?
grep: nosuchdir: No such file or directory status 2
grep -s TODO nosuchdir; echo status $?
status 2

Hands-On Exercises

Exercise 1

In the practice project, list, sorted, the Python and JavaScript files that contain TODO, ignoring the build and .git folders and binary files.

๐Ÿ“„ View solution
Exercise 2

Show exactly which file -I hides from a TODO search, using two searches and comm, then list the files that contain no TODO at all and show what adding -I does to that list.

๐Ÿ“„ View solution
Exercise 3

Find every file that mentions password, including hidden ones, and count the lines in each with wc -l, without breaking on my notes/meeting notes.txt. Show the version that breaks first.

๐Ÿ“„ View solution

Chapter 2 Quick Reference

  • Plain grep refuses a folder (Is a directory); -r recurses; with no folder, GNU grep searches the current folder
  • grep -r PAT . sees hidden files; grep -r PAT * does not (the shell skips names that start with a dot)
  • -l files with a match, -L files without; the exit status of -L still means “a line matched”, not “I listed something”
  • --include='*.py' / --exclude='*.js' match the file name only; the last matching option wins; quote them
  • --exclude-dir=.git skips a folder anywhere in the tree; repeat it, or --exclude-dir={.git,build} in bash
  • Binary files: default says they matched (3.0 on stdout: Binary file X matches; 3.11 on stderr: grep: X: binary file matches); -I skips them; -a prints them as text
  • -r does not follow links inside the tree, -R does; a link named on the command line is followed by both
  • File names with spaces: grep -lZ ... | xargs -0 cmd
  • find ... -exec grep -H PAT {} + when the file choice is more than a name; results come in folder order, so | sort when order matters
  • A good default: grep -rIn --exclude-dir=.git PATTERN .
Coming next
grep: Searching Text 3 turns to the pattern itself: basic against extended regular expressions, anchors, bracket expressions and the character classes, and why + and | behave differently without -E.