Check your script: ShellCheck, shfmt and Bats
Lint, format and test a script before it runs anywhere that matters.
tar -xzf bo-shellcheck.tar.gz, which creates bo-shellcheck/. SHA-256: c86c811732bd07eda4f8fb6651e9d700164e8dd80852cc756f299e40f0cb66d0Three tools check a Bash script before it runs anywhere that matters, and each one catches a different kind of mistake. In this lesson you will run ShellCheck on a script with real bugs and read every finding, fix them and re-check until it exits 0, silence one finding with a directive that says why, see which failures ShellCheck cannot see, format the script with shfmt, and write your first Bats tests, including one that fails and tells you exactly what went wrong. Everything here uses the versions in the Ubuntu 26.04 archive, and the examples run in ~/bash-ops/check.
Read what ShellCheck reports
ShellCheck 0.11.0, shfmt 3.12.0 and Bats 1.13.0 come from the Ubuntu archive (sudo apt install shellcheck shfmt bats). ShellCheck 0.11.0 is also the newest upstream release. Upstream shfmt and Bats have moved on (shfmt 3.14, Bats 1.14), but nothing in this lesson depends on the difference; pin whatever version your team uses so everyone sees the same results.
Here is a script someone wrote to empty the cache directories of a small web shop. It reads the directory names from caches.txt, one per line:
#!/usr/bin/env bash# clear-cache.sh SITE: empty the cache directories listed in SITE/caches.txt.site=$1log() {local stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)echo "$stamp clear-cache.sh: $*" >&2}cd $sitewhile read dir; dorm -rf $dir/*log "emptied $dir"done < caches.txt
It looks reasonable, and it runs without an error. make-shop.sh builds a small shop to try it on, with a cache directory called media cache, with a space, next to media, where customer uploads live:
#!/usr/bin/env bash# make-shop.sh: build shop/, the sample site clear-cache.sh works on (the same files the# Bats setup creates). Run it again whenever a run has damaged shop/.set -Eeuo pipefailmkdir -p shop/pages 'shop/media cache' shop/mediaprintf '<html>cached home page</html>\n' > shop/pages/index.htmlprintf 'thumbnail\n' > 'shop/media cache/thumb-1.jpg'printf 'customer upload, keep\n' > shop/media/upload-1.jpgprintf 'pages\nmedia cache\n' > shop/caches.txt
The script reported "emptied media cache" and exited 0. In fact it deleted the uploads and left the cache full. $dir/* without quotes was split at the space into two words, media and cache/*, so rm -rf removed the directory media. (cache/* matched nothing, and -f kept rm quiet about it.) This is the word splitting from "Quoting, word splitting and globbing", and it did its damage silently.
ShellCheck finds this kind of mistake without running anything. It is a linter: it reads the script, works out what each line means to Bash, and reports patterns that are known to go wrong.
Each finding names the file and line, prints the line, and puts a marker under the exact part it means. Then comes a code, a severity and a one-line explanation. Take the last one: SC2115 (warning): Use "${var:?}" to ensure this never expands to /* . If a line of caches.txt is empty, $dir is empty, and rm -rf $dir/* becomes rm -rf /*. The SC2086 under it is the quoting bug you just watched happen. "Did you mean:" shows a corrected line, and every code has a wiki page (the links at the end) with the reasoning and examples.
The other findings: SC2155 says local stamp=$(...) hides the status of date (the masking from "Strict mode, honestly"). SC2164 says that if cd fails, the loop runs in whatever directory the script started in. SC2162 says read without -r treats backslashes as escapes. Severities run from error through warning and info to style; -S warning would show only the first two, but on a new script read them all. The line that matters for automation is the last: status 1. ShellCheck exits 0 when it finds nothing, 1 when it reports findings, and 2 to 4 when it could not check a file or was called wrongly, so a check step can stop on anything other than 0.
Fix it, or silence it with a reason
The fix follows the suggestions: quote every expansion, check cd, use read -r with IFS=, split local from the assignment, and give the rm path ${dir:?...} so an empty line stops the script instead of reaching /. The strict-mode header and printf come from earlier lessons. After your edit the file reads:
#!/usr/bin/env bash# clear-cache.sh SITE: empty the cache directories listed in SITE/caches.txt.set -Eeuo pipefailsite=${1:?usage: clear-cache.sh SITE}log() {local stampstamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)printf '%s clear-cache.sh: %s\n' "$stamp" "$*" >&2}cd -- "$site" || exit 1while IFS= read -r dirdorm -rf -- "${dir:?empty line in caches.txt}"/*log "emptied $dir"done <caches.txt
The first run destroyed part of the shop, so rebuild it before trying the fixed version:
ShellCheck prints nothing and returns 0. The script emptied both caches and media/upload-1.jpg is still there.
Sometimes a finding describes exactly what you mean. This helper prints a line to paste into a crontab. $HOME must stay literal, because cron's shell expands it when the job runs, so the single quotes are deliberate:
#!/usr/bin/env bash# cron-line.sh: print the crontab line that runs clear-cache.sh every night at 03:15.# shellcheck disable=SC2016 # $HOME is for cron's shell to expand, not this oneprintf '%s\n' '15 3 * * * $HOME/bin/clear-cache.sh $HOME/shop'
The first command removed the directive line and fed the rest to ShellCheck on standard input (-), which reported SC2016: expressions do not expand in single quotes. With the directive the file is clean. A # shellcheck disable=... comment applies to the command right after it (or to a whole block, such as a function or loop); placed before the first command it applies to the whole file, which is rarely what you want. Always put the reason on the same line. A bare disable tells the next reader nothing, and they cannot tell a considered exception from a bug someone wanted to hide.
What ShellCheck cannot see, and .shellcheckrc
ShellCheck reads code; it does not run it. "Strict mode, honestly" showed failures that set -e misses: a function called as f || die, a test && action as the last line, a command substitution used as an argument, and a pipe into head that dies with status 141 under pipefail. Copy those four scripts from your ~/bash-ops/strict into strict/, and check them:
Silence and status 0: by default ShellCheck finds none of them. Two optional checks, off by default because they are noisy, point at two of the patterns. Optional checks and other project-wide settings go in a .shellcheckrc file. ShellCheck looks for it in the checked script's directory and then in each parent directory, so one file at the top of a project covers every script under it. Create it now, in ~/bash-ops/check, and run the same check again:
# .shellcheckrc: read by ShellCheck for every script in this directory and below.# Two optional checks (off by default) that point at failures set -e does not see.enable=check-set-e-suppressedenable=check-extra-masked-returns
SC2310 flags install_conf || die: set -e is off inside the function. SC2312 flags the substitution passed to printf. report-end.sh and first-error.sh still pass: the last-line && and the head that turns into status 141 show up only when the script runs. And do not take the "or use '|| true'" half of the SC2312 advice: as "Strict mode, honestly" showed, that throws away the failure you wanted to know about. Handle the status instead. shellcheck --list-optional lists the other optional checks.
shfmt: one layout for everyone
The fixed script works, but it was typed with four-space indents and do on its own line, while the rest of this project uses two spaces. shfmt prints shell code in one consistent layout, so a code review shows real changes instead of whitespace. It changes layout only, never what the script does. It takes its settings from an .editorconfig file, the format many editors also read:
# .editorconfig: shfmt reads the [*.sh] / [*.bats] settings (and editors the rest).root = true[*.{sh,bats}]indent_style = spaceindent_size = 2switch_case_indent = truespace_redirects = true
-d prints what it would change as a diff and exits 1; lines starting with - are the current file and + lines are shfmt's version. It re-indented to two spaces, joined do onto the while line, and wrote < caches.txt with a space because of space_redirects. -w writes the changes into the file:
Now shfmt -d is silent and returns 0. One trap: if you pass any formatting flag, such as -i 4, shfmt ignores .editorconfig completely, so keep the settings in the file and call shfmt -d or shfmt -w with no style flags.
A first Bats test
ShellCheck and shfmt read the script. Neither can tell you whether it empties the right directories, or what it returns when something is missing. For that you run it, and Bats (Bash Automated Testing System) runs it the same way every time. A Bats file is Bash with one addition: each @test "description" { ... } block is a test, and it fails as soon as a command in it fails.
# clear-cache.bats: tests for clear-cache.sh. Run them from the project directory: bats --tap test/setup() {script=$BATS_TEST_DIRNAME/../clear-cache.shsite=$BATS_TEST_TMPDIR/shopmkdir -p "$site/pages" "$site/media cache" "$site/media"printf 'cached\n' > "$site/pages/index.html"printf 'thumbnail\n' > "$site/media cache/thumb-1.jpg"printf 'upload\n' > "$site/media/upload-1.jpg"printf 'pages\nmedia cache\n' > "$site/caches.txt"}@test "empties every listed cache and keeps the uploads" {run "$script" "$site"[ "$status" -eq 0 ]run find "$site/pages" "$site/media cache" -mindepth 1[ "$status" -eq 0 ][ "$output" = "" ][ -f "$site/media/upload-1.jpg" ]}@test "a missing site directory fails with status 1" {run "$script" "$BATS_TEST_TMPDIR/no-such-site"[ "$status" -eq 1 ][[ $output == *"No such file or directory"* ]]}@test "a listed cache directory that does not exist is reported" {printf 'old cache\n' >> "$site/caches.txt"run "$script" "$site"[ "$status" -eq 1 ][[ $output == *"not a directory: old cache"* ]]}@test "a path that leaves SITE is refused and nothing outside is touched" {mkdir "$BATS_TEST_TMPDIR/uploads" # a sibling of the shop, like SITE's parent's other dataprintf 'customer\n' > "$BATS_TEST_TMPDIR/uploads/photo.jpg"printf '..\n%s\n' "$BATS_TEST_TMPDIR/uploads" >> "$site/caches.txt"run "$script" "$site"[ "$status" -eq 1 ][[ $output == *"refusing cache path: '..'"* ]][ -f "$BATS_TEST_TMPDIR/uploads/photo.jpg" ][ -f "$site/media/upload-1.jpg" ]}
setup runs before every test and builds a small shop in $BATS_TEST_TMPDIR, an empty directory Bats creates for each test and removes when the run ends, so tests never touch real data or each other's files. run executes a command without letting its failure end the test, and records its exit status in $status and everything it printed, standard output and standard error together, in $output. The [ ... ] lines after it are the checks. The first test uses run find ... -mindepth 1 to prove both caches still exist and are empty. A check written as [ -z "$(ls -A dir)" ] would also pass if the script had deleted dir itself, because ls then fails and prints nothing to standard output: exactly what SC2312 warns about. --tap prints plain TAP (Test Anything Protocol) lines, the format a CI log shows; --print-output-on-failure adds $output for each failing test:
Read it line by line. 1..4 means four tests. Two are ok. not ok 3 names a failed test; the next line gives the file and line of the check that failed, and the line after that the check itself: [ "$status" -eq 1 ]. "Last output" is $output: the script said "emptied old cache" for a directory that does not exist, because rm -rf on a missing path succeeds quietly, so a cache that was renamed would never be cleaned. Test 4 is worse. A line .. emptied the shop's parent directory: in the test that is only the test's own temporary directory, but on a server it would be every site next to the shop, and the absolute path emptied an uploads directory outside the shop. Both runs ended with status 0. ${dir:?} only stops an empty line; a path read from a file needs an allowlist, as "Arrays and parameter expansion" said. The tests found real bugs, so fix the script, not the tests:
#!/usr/bin/env bash# clear-cache.sh SITE: empty the cache directories listed in SITE/caches.txt.# Exit status: 0 all emptied, 1 SITE or a listed directory is missing or refused.set -Eeuo pipefailsite=${1:?usage: clear-cache.sh SITE}# One directory name inside SITE: the valid_name allowlist from "Functions, local and# logging", plus spaces. It refuses '', '.', '..', '/', any path with a / and a leading -.readonly SAFE_NAME='^[A-Za-z0-9_][A-Za-z0-9 ._-]*$'log() {local stampstamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)printf '%s clear-cache.sh: %s\n' "$stamp" "$*" >&2}cd -- "$site" || exit 1status=0while IFS= read -r dir; doif [[ ! $dir =~ $SAFE_NAME || -L $dir ]]; then # a symbolic link could point anywherelog "refusing cache path: '$dir'"status=1continuefiif [[ ! -d $dir ]]; thenlog "not a directory: $dir"status=1continuefirm -rf -- "${dir:?empty line in caches.txt}"/*log "emptied $dir"done < caches.txtexit "$status"
The script now accepts only one directory name inside SITE: SAFE_NAME (readonly makes it a constant) is the valid_name allowlist from "Functions, local and logging", widened to allow spaces, and it refuses an empty line, ., .., anything with a / and a leading dash. A cache that is a symbolic link is refused too, because it could point anywhere. All four tests pass, and the script is clean for ShellCheck and shfmt. The same guard on the real shop:
Both lines were refused, status 1, and the four scripts in ../strict are still there. Run the three tools in this order before every change: shfmt so the diff is readable, ShellCheck for the patterns it knows, Bats for behaviour. "Testing shell automation" in the advanced course runs them as a CI gate and adds fakes for commands that reach other systems.
Try this
Copy the final clear-cache.sh, test/, .shellcheckrc and .editorconfig into a new directory. Break the script in three separate ways, restoring it after each, and before running anything predict which of shellcheck clear-cache.sh, shfmt -d clear-cache.sh and bats --tap test/ will complain:
1. Remove the double quotes around ${dir:?empty line in caches.txt} on the rm line. Expect ShellCheck to report SC2086 (status 1), shfmt to stay quiet, and tests 1 and 4 to fail: test 1 at [ "$output" = "" ], because media cache still holds its thumbnail, and test 4 at its last line, because the upload is gone. Explain what that same rm line did to media/.
2. Replace the whole if [[ ! -d $dir ]]; then ... fi block with [[ -d $dir ]] || log "not a directory: $dir". Expect ShellCheck and shfmt to pass and test 3 to fail at [ "$status" -eq 1 ]. Explain why no linter can find this one.
3. Indent the log and status=1 lines inside that if block by eight spaces instead of four. Expect only shfmt -d to complain (status 1), then fix it with shfmt -w.
Takeaway
Run shfmt, ShellCheck and Bats on every change to a script: fix findings rather than silencing them, give every directive a reason, and write a Bats test for each failure you care about, because a clean ShellCheck run says nothing about what the script actually does.
SC2115: Use "${var:?}" to ensure this never expands to /* on the line rm -rf "$dir"/* inside a loop that reads $dir from a file. Which line of that file makes it dangerous?shellcheck with status 0. Which of these bugs can it still contain?[ -z "$(ls -A "$site/pages")" ]. The script under test has a bug that deletes $site/pages itself. What does the test do?