← back to latest

Search a project without walking into node_modules

find . \( -name node_modules -o -name .git \) -prune -o -type f -name '*.ts' -print

anatomy

find
Walk a directory tree and evaluate an expression against every file it reaches. The expression is not a list of filters, it is a boolean program: each test returns true or false, adjacent tests are joined by an implied -a, and find reads the result left to right with the usual short-circuit rules. Almost every confusing find command is a precedence problem, not a flag problem. Note that find reports an unreadable directory on stderr and exits 1 even when it printed everything else correctly, so a script that checks the exit status will call a good run a failure.
\( ... \)
Grouping, so that -o binds the two names together before -prune is applied to the result. Without the parentheses, -a binds tighter than -o and the expression splits somewhere you did not intend. The backslashes are for the shell, not for find: bare parentheses are subshell syntax in bash, so they have to arrive at find as literal characters. Quoting them as '(' and ')' works identically.
-name node_modules -o -name .git
Two names, either one. -name matches against the basename only, never the full path, so this prunes a node_modules at any depth in the tree, not only the one at the top. If you want to stop exactly one directory, match on -path instead.
-prune
Do not descend into this directory. The part people misread is the return value: -prune is always true. It does not exclude anything by itself and it prints nothing. Set against a directory, it tells find to skip the whole subtree; set against a plain file, it does nothing at all but still returns true. It is specified in POSIX, so it is one of the few find features that behaves the same everywhere.
-o
Logical or, and the actual mechanism of the exclusion. Because -prune returned true for node_modules, -o short-circuits and nothing on the right side is ever evaluated for it, which is why the directory is neither descended into nor printed. Everything that was not pruned returns false on the left and falls through to the right side to be tested properly.
-type f
Regular files only. Without it the right side also matches directories and symlinks whose names happen to end in .ts, which is rare but produces a result you cannot pipe into a tool that expects files.
-name '*.ts'
The real test, quoted so the shell hands the pattern to find intact. Unquoted, bash expands *.ts against the current directory first, and find then searches every level of the tree for that one filename it found at the top, or fails with 'paths must precede expression' when the glob matches more than one file.
-print
Print the path. Spelling it out is mandatory here rather than decorative. GNU find supplies an implicit -print only when the expression contains no action at all, and it wraps the entire expression, so leaving it off prints the pruned directories alongside your matches.

Sample output

$ find . \( -name node_modules -o -name .git \) -prune -o -type f -name '*.ts' -print
./vendor/legacy.ts
./tests/app.test.ts
./src/utils/throttle.ts
./src/utils/api.ts
./src/utils/debounce.ts

$ find . \( -name node_modules -o -name .git \) -prune -o -type f -name '*.ts'
./.git
./vendor/legacy.ts
./tests/app.test.ts
./node_modules
./src/utils/throttle.ts
./src/utils/api.ts
./src/utils/debounce.ts
./src/vendor-lib/node_modules

Five source files, and nothing from the 2,800 entries sitting under node_modules. The second run is the same expression with the trailing -print removed, and the three directories you asked find to skip are now in your results: they were pruned, which stopped the descent, but pruning is not exclusion, and with no action anywhere in the expression find printed everything the expression returned true for.

When you would reach for it

Any search inside a repository, which in practice means any search where a dependency directory outnumbers your own code by three orders of magnitude. The same shape covers .git, vendor, target, build, .venv, and the cache directory whichever tool you are using invented this year. It matters most when the walk is feeding something else: a grep across the tree, a file count, a checksum pass, anything where descending into 2,800 files you do not care about costs real time on every run.

Gotchas

  • Leaving off the final -print is the common failure, and it fails quietly. GNU find adds an implicit -print only when the whole expression contains no action, and that implicit action applies to the entire expression rather than to the branch you were thinking about, so the pruned directories are printed as results. Adding any action, -print, -exec, -delete, suppresses the implicit one, which is why the fix is to write -print yourself on the branch you want.
  • Precedence is where these commands actually break, because -a binds tighter than -o and it is invisible. find . -name node_modules -o -name .git -prune -o -type f -name '*.ts' -print looks equivalent to the version with parentheses and is not: it parses as -name node_modules or (-name .git -a -prune), so node_modules is never pruned and its contents come back in the results. The same trap sits on the action side. -prune -o -name '*.ts' -o -name '*.tsx' -print binds -print to the last branch only, so the .ts files match, print nothing, and vanish. Group the alternatives: -prune -o \( -name '*.ts' -o -name '*.tsx' \) -print.
  • -depth turns -prune into a no-op, and the manual says so in one line most people read past. Depth-first traversal visits a directory’s contents before the directory itself, so by the time the expression runs there is nothing left to skip. This matters more than it sounds, because -delete implies -depth: a command that looks like it prunes a directory before deleting is walking straight into it.
  • macOS and Alpine run different implementations, and this command is portable because it stays inside POSIX. Two habits will take you outside it. -not is an extension POSIX never specified; ! is the standard spelling and works everywhere. -printf does not exist on BSD find at all, so a pipeline built around it fails on macOS with find: -printf: unknown primary or operator. BSD find also requires a starting path, so the GNU shorthand of omitting the leading . is not portable.

Variants

$ find . -type f -name '*.ts' ! -path '*/node_modules/*' ! -path '*/.git/*'

Reads more clearly and returns the same five files, but it filters instead of pruning: find still walks every excluded directory and tests every file inside it. On the tree above, 2,835 entries visited against 22, and 43ms against 1ms. Fine for a directory you type once, wrong for anything on a loop

$ find . \( -name node_modules -o -name .git \) -prune -o -type f -print0 | xargs -0 grep -l 'debounce'

Hand the surviving files to another tool. -print0 separates paths with a null byte and xargs -0 reads them back the same way, which is the only pairing that survives a filename containing a space or a newline. xargs also batches, so grep runs a handful of times rather than once per file

$ find . -path './node_modules' -prune -o -name '*.ts' -print

Prune exactly one directory rather than every directory with that name. -path matches the whole path as find constructed it, so it is anchored to the . you started from, and a nested src/vendor-lib/node_modules stays in the results

lineage

find arrived in Version 5 Unix in 1974, and by most accounts Dick Haight wrote it at Bell Labs along with cpio, xargs, and expr. What makes it feel unlike its neighbours is the design decision underneath: where grep and ls take flags, find takes an expression to evaluate against each file, with tests, operators, precedence, and side effects. That is why it has parentheses and an -o at all, and why its command lines read like arithmetic rather than options. The syntax survived the split between the BSD lineage that macOS still ships and the GNU findutils rewrite the FSF started in the 1980s, and POSIX standardized the core of it, -prune included. The GNU version accumulated conveniences the standard never took up, notably -printf and -regextype, which is the source of most portability trouble people hit today.