Globbing, find and xargs · about 40 min

Globbing in depth

Why this matters

You have used *.txt since module 3. Globs are the shell's way of naming groups of files, and they are the right way: they produce real file names as separate words, spaces and all, with no text parsing in between (unlike $(ls), the pitfall from module 12). Knowing the full pattern language, and the handful of options that change how it behaves, lets you select exactly the files you mean, avoid the "no match" surprises, and often replace a find command with something simpler.

Who does the globbing

Globbing, officially pathname expansion, is done by bash, not by the command. It is one of the expansion steps from module 7, after word splitting and before quote removal:

$ ls
a.txt  b.txt  notes.md
$ echo *.txt
a.txt b.txt
$ echo '*.txt'
*.txt

echo never sees a star in the first case: bash read the directory, matched the names, sorted them, and passed two arguments. Quoting a pattern turns globbing off, which is how you pass a literal pattern to a program that does its own matching (find -name '*.txt', lesson 2).

Underneath, bash opens the directory, reads its entries (the getdents system call), keeps the names that match, and sorts them according to the locale (LC_COLLATE). Patterns with slashes are matched one directory level at a time: src/*/test_*.py lists src, keeps the subdirectories, and lists each of those.

The pattern language

PatternMatches
*any string, including the empty string
?exactly one character
[abc]one character from the set
[a-z], [0-9]one character in the range
[!abc] or [^abc]one character not in the set
[[:digit:]], [[:alpha:]], [[:upper:]], [[:space:]]one character of a class

Examples, with a directory holding IMG_0001.jpg IMG_12.jpg IMG_0010.JPG report-2024.txt report-final.txt:

$ echo IMG_????.jpg
IMG_0001.jpg
$ echo IMG_[0-9][0-9][0-9][0-9].*
IMG_0001.jpg IMG_0010.JPG
$ echo report-[0-9]*.txt
report-2024.txt
$ echo *[[:upper:]]
IMG_0010.JPG
$ echo report-[!0-9]*
report-final.txt

Important differences from regular expressions (module 6): in a glob * alone means "anything" (in a regex it means "repeat the previous item"), ? is one character (in a regex, "optional"), and a glob must match the whole name, not just part of it. *.txt does not match a.txt.bak.

Note: ranges like [a-z] depend on the locale; in some locales they include uppercase letters. The classes [[:lower:]] and [[:digit:]] always mean what they say. This sandbox uses C.UTF-8, where ranges behave as expected.

Rules about slashes and dots

When nothing matches

By default an unmatched pattern is passed through unchanged:

$ echo *.log
*.log
$ for f in *.log; do echo "processing $f"; done
processing *.log

Three options change that, set with shopt -s NAME (and unset with shopt -u NAME):

OptionUnmatched pattern becomes
(default)the pattern itself
nullglobnothing at all (zero words)
failgloban error; the command is not run
$ shopt -s nullglob
$ for f in *.log; do echo "processing $f"; done
$                                         (loop body never runs)
$ shopt -u nullglob; shopt -s failglob
$ echo *.log
bash: no match: *.log

nullglob is great in loops and with set -- counting, but has a trap: a command given only an unmatched glob gets no arguments, and many commands then do something different. ls *.log with nullglob and no logs runs plain ls, listing everything; cat *.log waits for keyboard input. Without nullglob, the portable idiom is to test each match:

for f in *.log; do
  [ -e "$f" ] || continue     # skips the literal pattern when nothing matched
  gzip -- "$f"
done

More options: dotglob, nocaseglob, globstar

$ shopt -s globstar
$ echo src/**/*.py
src/main.py src/lib/util.py src/lib/net/http.py src/tests/test_util.py

**/ is a simple alternative to find when you just want all files of a type under a tree. For very large trees, or tests on size, time or type, find (lesson 2) is better: globstar builds the whole list in memory before the command starts.

Extended globs: extglob

With shopt -s extglob, five extra operators take a |-separated list of patterns:

$ shopt -s extglob
$ ls
a.c  a.h  a.o  b.c  b.o  Makefile  README
$ echo *.@(c|h)
a.c a.h b.c
$ echo !(*.o)
a.c a.h b.c Makefile README
$ echo +([[:digit:]]).txt         # names made only of digits

rm !(*.keep) (remove everything except) is the classic use. extglob must be enabled before bash parses the line containing the pattern, so put shopt -s extglob on its own line, near the top of a script. (Inside [[ ... == pattern ]] extended patterns always work in recent bash.)

Brace expansion is not globbing

{a,b,c} and {1..5} look similar but are a different step: brace expansion generates words whether or not files exist, before any globbing.

$ echo file{1..3}.txt
file1.txt file2.txt file3.txt         (even if none exist)
$ echo [0-9].txt
[0-9].txt                             (no such files: left as is)
$ mkdir -p project/{src,tests,docs}   # creates three directories

Globs elsewhere

The same pattern language appears in case patterns (module 9), in [[ $x == *.txt ]], in parameter expansion like ${file%.*} (module 15), and in find -name. In all of those, matching is done against a string, not against the filesystem.

Common mistakes

Exercises

A quiz on unmatched patterns, two hands-on tasks in prepared directories (precise character classes, then extglob's "everything except"), and a counting script that must handle "no matches".

Recap

Going further: man 7 glob (installed here) describes the POSIX rules. Run shopt with no arguments to see every option and its state.

Exercises

Exercise 1 · quiz

When nothing matches

The current directory contains only a.txt and b.txt. With default bash settings, what does echo *.log print?

Answer
  • *.log

By default a glob that matches nothing is left as it is, so echo receives the literal word *.log. That is why for f in *.log runs once with f='*.log' in an empty directory, and why rm *.log says rm: cannot remove '*.log': No such file or directory. shopt -s nullglob makes such a glob expand to nothing; shopt -s failglob makes it an error that stops the command.

Exercise 2 · task in a shell

Move exactly the right photos

/work/camera holds a messy mix of files. Move into /work/sorted exactly the files whose names are IMG_, then four digits, then .jpg in lowercase, such as IMG_0001.jpg.

Leave everything else in camera, including IMG_12.jpg (two digits), IMG_0010.JPG (uppercase extension), IMG_backup.jpg, IMG_00001.jpg (five digits) and the hidden .IMG_0099.jpg. Use a single glob pattern; ls camera first to see what is there.

Hint 1

[0-9] matches exactly one digit. You need four of them in a row.

Hint 2

* would also match backup and five digits. A glob never matches a leading dot unless you write the dot.

Hint 3

mv camera/IMG_[0-9][0-9][0-9][0-9].jpg sorted/

Reference solution
mv camera/IMG_[0-9][0-9][0-9][0-9].jpg sorted/

Exercise 3 · task in a shell

Delete all but the keepers

/work/junk contains many files. Delete every non-hidden file in it except the ones ending in .keep. Hidden files (starting with .) must stay too.

Do it with one rm command using an extended glob: first shopt -s extglob, then the pattern !(...) means "anything except ...". Test the pattern with echo or ls before you rm!

Hint 1

echo junk/!(*.keep) shows what would be removed, once extglob is on.

Hint 2

shopt -s extglob on one line, then rm junk/!(*.keep) on the next. (In a single line typed together, bash parses the pattern before shopt runs.)

Reference solution
shopt -s extglob
rm junk/!(*.keep)

Exercise 4 · write code

Count matches correctly

script.sh DIR must print the number of files ending in .log directly inside DIR, as a bare number.

The starter uses a neat trick, set -- "$1"/*.log to load the matches into the positional parameters and then echo $#, but it prints 1 for a directory with no .log files. Fix it with the right shell option so that it prints 0 in that case.

The checker provides logs (4 log files, one with a space in its name, plus a readme.txt) and nologs (only a readme.txt).

File: script.sh

Starting point

set -- "$1"/*.log
echo $#

Example with arguments logs

expected output

4

Example with arguments nologs

expected output

0
Hint 1

With no match, the pattern itself becomes the single argument. Which option makes it vanish instead?

Hint 2

shopt -s nullglob before the set -- line.

Reference solution
shopt -s nullglob
set -- "$1"/*.log
echo $#