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
-printis the common failure, and it fails quietly. GNU find adds an implicit-printonly 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-printyourself on the branch you want. - Precedence is where these commands actually break, because
-abinds tighter than-oand it is invisible.find . -name node_modules -o -name .git -prune -o -type f -name '*.ts' -printlooks equivalent to the version with parentheses and is not: it parses as-name node_modulesor(-name .git -a -prune), sonode_modulesis 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' -printbinds-printto the last branch only, so the.tsfiles match, print nothing, and vanish. Group the alternatives:-prune -o \( -name '*.ts' -o -name '*.tsx' \) -print. -depthturns-pruneinto 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-deleteimplies-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.
-notis an extension POSIX never specified;!is the standard spelling and works everywhere.-printfdoes not exist on BSD find at all, so a pipeline built around it fails on macOS withfind: -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