Check your script: ShellCheck, shfmt and Bats

Lint, format and test a script before it runs anywhere that matters.

Beginner25 min · lesson 14 of 15
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (12 files, 2 KB): bo-shellcheck.tar.gz. Unpack it with tar -xzf bo-shellcheck.tar.gz, which creates bo-shellcheck/. SHA-256: c86c811732bd07eda4f8fb6651e9d700164e8dd80852cc756f299e40f0cb66d0

Three 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

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ dpkg-query -W shellcheck shfmt bats shellcheck --version | sed -n 2p shfmt --version bats --version
bats 1.13.0-1 shellcheck 0.11.0-2 shfmt 3.12.0-1 version: 0.11.0 3.12.0 Bats 1.13.0

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:

clear-cache.sh
#!/usr/bin/env bash
# clear-cache.sh SITE: empty the cache directories listed in SITE/caches.txt.
site=$1
log() {
local stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
echo "$stamp clear-cache.sh: $*" >&2
}
cd $site
while read dir; do
rm -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:

make-shop.sh
#!/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 pipefail
mkdir -p shop/pages 'shop/media cache' shop/media
printf '<html>cached home page</html>\n' > shop/pages/index.html
printf 'thumbnail\n' > 'shop/media cache/thumb-1.jpg'
printf 'customer upload, keep\n' > shop/media/upload-1.jpg
printf 'pages\nmedia cache\n' > shop/caches.txt
deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ chmod +x make-shop.sh clear-cache.sh ./make-shop.sh cat shop/caches.txt find shop -type f | sort ./clear-cache.sh shop echo "status: $?" find shop -type f | sort
pages media cache shop/caches.txt shop/media cache/thumb-1.jpg shop/media/upload-1.jpg shop/pages/index.html 2026-09-28T16:56:23Z clear-cache.sh: emptied pages 2026-09-28T16:56:23Z clear-cache.sh: emptied media cache status: 0 shop/caches.txt shop/media cache/thumb-1.jpg

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.

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ shellcheck clear-cache.sh echo "status: $?"
In clear-cache.sh line 6: local stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) ^---^ SC2155 (warning): Declare and assign separately to avoid masking return values. In clear-cache.sh line 10: cd $site ^------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails. ^---^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: cd "$site" || exit In clear-cache.sh line 11: while read dir; do ^--^ SC2162 (info): read without -r will mangle backslashes. In clear-cache.sh line 12: rm -rf $dir/* ^----^ SC2115 (warning): Use "${var:?}" to ensure this never expands to /* . ^--^ SC2086 (info): Double quote to prevent globbing and word splitting. Did you mean: rm -rf "$dir"/* For more information: https://www.shellcheck.net/wiki/SC2115 -- Use "${var:?}" to ensure this nev... https://www.shellcheck.net/wiki/SC2155 -- Declare and assign separately to ... https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |... status: 1

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:

clear-cache.sh (fixed)
#!/usr/bin/env bash
# clear-cache.sh SITE: empty the cache directories listed in SITE/caches.txt.
set -Eeuo pipefail
site=${1:?usage: clear-cache.sh SITE}
log() {
local stamp
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s clear-cache.sh: %s\n' "$stamp" "$*" >&2
}
cd -- "$site" || exit 1
while IFS= read -r dir
do
rm -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:

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ rm -rf shop && ./make-shop.sh shellcheck clear-cache.sh echo "status: $?" ./clear-cache.sh shop echo "status: $?" find shop -type f | sort
status: 0 2026-09-28T16:56:23Z clear-cache.sh: emptied pages 2026-09-28T16:56:23Z clear-cache.sh: emptied media cache status: 0 shop/caches.txt shop/media/upload-1.jpg

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:

cron-line.sh
#!/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 one
printf '%s\n' '15 3 * * * $HOME/bin/clear-cache.sh $HOME/shop'
deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ chmod +x cron-line.sh sed '/shellcheck disable/d' cron-line.sh | shellcheck - shellcheck cron-line.sh echo "status: $?" ./cron-line.sh
In - line 3: printf '%s\n' '15 3 * * * $HOME/bin/clear-cache.sh $HOME/shop' ^-- SC2016 (info): Expressions don't expand in single quotes, use double quotes for that. For more information: https://www.shellcheck.net/wiki/SC2016 -- Expressions don't expand in singl... status: 0 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:

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ mkdir strict cp ../strict/deploy-conf.sh ../strict/report-end.sh ../strict/waiting-inline.sh ../strict/first-error.sh strict/ shellcheck strict/*.sh echo "status: $?"
status: 0

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
# .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-suppressed
enable=check-extra-masked-returns
deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ shellcheck strict/*.sh echo "status: $?"
In strict/deploy-conf.sh line 9: install_conf || die "could not install app.conf" ^----------^ SC2310 (info): This function is invoked in an || condition so set -e will be disabled. Invoke separately if failures should cause the script to exit. In strict/waiting-inline.sh line 5: printf 'waiting in incoming: %s\n' "$(cd incoming; printf '%s ' *)" ^---------^ SC2312 (info): Consider invoking this command separately to avoid masking its return value (or use '|| true' to ignore). For more information: https://www.shellcheck.net/wiki/SC2310 -- This function is invoked in an ||... https://www.shellcheck.net/wiki/SC2312 -- Consider invoking this command se... status: 1

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
# .editorconfig: shfmt reads the [*.sh] / [*.bats] settings (and editors the rest).
root = true
[*.{sh,bats}]
indent_style = space
indent_size = 2
switch_case_indent = true
space_redirects = true
deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ shfmt -d clear-cache.sh echo "status: $?"
diff clear-cache.sh.orig clear-cache.sh --- clear-cache.sh.orig +++ clear-cache.sh @@ -4,14 +4,13 @@ site=${1:?usage: clear-cache.sh SITE} log() { - local stamp - stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) - printf '%s clear-cache.sh: %s\n' "$stamp" "$*" >&2 + local stamp + stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ) + printf '%s clear-cache.sh: %s\n' "$stamp" "$*" >&2 } cd -- "$site" || exit 1 -while IFS= read -r dir -do - rm -rf -- "${dir:?empty line in caches.txt}"/* - log "emptied $dir" -done <caches.txt +while IFS= read -r dir; do + rm -rf -- "${dir:?empty line in caches.txt}"/* + log "emptied $dir" +done < caches.txt status: 1

-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:

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ shfmt -w clear-cache.sh shfmt -d clear-cache.sh echo "status: $?"
status: 0

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.

test/clear-cache.bats
# clear-cache.bats: tests for clear-cache.sh. Run them from the project directory: bats --tap test/
setup() {
script=$BATS_TEST_DIRNAME/../clear-cache.sh
site=$BATS_TEST_TMPDIR/shop
mkdir -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 data
printf '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:

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ bats --tap --print-output-on-failure test/ echo "status: $?"
1..4 ok 1 empties every listed cache and keeps the uploads ok 2 a missing site directory fails with status 1 not ok 3 a listed cache directory that does not exist is reported # (in test file test/clear-cache.bats, line 30) # `[ "$status" -eq 1 ]' failed # Last output: # 2026-09-28T16:56:24Z clear-cache.sh: emptied pages # 2026-09-28T16:56:24Z clear-cache.sh: emptied media cache # 2026-09-28T16:56:24Z clear-cache.sh: emptied old cache not ok 4 a path that leaves SITE is refused and nothing outside is touched # (in test file test/clear-cache.bats, line 39) # `[ "$status" -eq 1 ]' failed # Last output: # 2026-09-28T16:56:24Z clear-cache.sh: emptied pages # 2026-09-28T16:56:24Z clear-cache.sh: emptied media cache # 2026-09-28T16:56:24Z clear-cache.sh: emptied .. # 2026-09-28T16:56:24Z clear-cache.sh: emptied /tmp/bats-run-kRkICt/test/4/uploads status: 1

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:

clear-cache.sh (final)
#!/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 pipefail
site=${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 stamp
stamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
printf '%s clear-cache.sh: %s\n' "$stamp" "$*" >&2
}
cd -- "$site" || exit 1
status=0
while IFS= read -r dir; do
if [[ ! $dir =~ $SAFE_NAME || -L $dir ]]; then # a symbolic link could point anywhere
log "refusing cache path: '$dir'"
status=1
continue
fi
if [[ ! -d $dir ]]; then
log "not a directory: $dir"
status=1
continue
fi
rm -rf -- "${dir:?empty line in caches.txt}"/*
log "emptied $dir"
done < caches.txt
exit "$status"
deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ bats --tap test/ echo "status: $?" shellcheck clear-cache.sh test/clear-cache.bats && shfmt -d clear-cache.sh test/clear-cache.bats && echo "shellcheck and shfmt: clean"
1..4 ok 1 empties every listed cache and keeps the uploads ok 2 a missing site directory fails with status 1 ok 3 a listed cache directory that does not exist is reported ok 4 a path that leaves SITE is refused and nothing outside is touched status: 0 shellcheck and shfmt: clean

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:

deploy@web01:~/bash-ops/check · Ubuntu 26.04 LTS
$ rm -rf shop && ./make-shop.sh printf '..\n../strict\n' >> shop/caches.txt ./clear-cache.sh shop echo "status: $?" ls ../strict
2026-09-28T16:56:24Z clear-cache.sh: emptied pages 2026-09-28T16:56:24Z clear-cache.sh: emptied media cache 2026-09-28T16:56:24Z clear-cache.sh: refusing cache path: '..' 2026-09-28T16:56:24Z clear-cache.sh: refusing cache path: '../strict' status: 1 deploy-conf.sh first-error.sh report-end.sh waiting-inline.sh

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.

Which tool catches what
ShellCheck: reads the code
quoting, cd, read -r
SC2086, SC2164, SC2162
rm on a path that can be empty
SC2115
local x=$(cmd)
SC2155; more via .shellcheckrc
shfmt: reads the layout
indentation, line breaks
from .editorconfig
never changes behaviour
shfmt -d exits 1 on a difference
Bats: runs the script
what it deletes or keeps
checks on real files, a .. line
exit status and messages
$status, $output
failures set -e misses
last-line &&, status 141
Each tool finds mistakes the other two cannot.

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.

Quick check
01ShellCheck reports 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?
Incorrect — The quotes around $dir already keep a spaced name in one piece; that was the SC2086 bug.
Correct — An empty $dir turns the path into /*. ${dir:?} stops the script with a message before rm runs.
Incorrect — rm -rf on a missing path does nothing and succeeds quietly: a silent miss, not a danger to other files.
Incorrect — A leading dash is a separate problem, solved with --; SC2115 is only about $dir being empty.
02A deploy script passes shellcheck with status 0. Which of these bugs can it still contain?
Correct — With default checks ShellCheck is silent on f || die; only the optional SC2310 points at it, and only running the script shows the lost failure.
Incorrect — That is SC2086 and SC2115, both reported by default, so the run would not have been clean.
Incorrect — SC2162 is reported by default (info), so the status would have been 1.
Incorrect — SC2155 is reported by default (warning); the lesson's first run showed it.
03A Bats test checks that a cache was emptied with [ -z "$(ls -A "$site/pages")" ]. The script under test has a bug that deletes $site/pages itself. What does the test do?
Incorrect — The error goes to standard error, and $( ) captures only standard output, so the check sees an empty string.
Incorrect — The ls failure is masked by the [ command; this is the SC2312 pattern.
Incorrect — ls fails, but that status is lost; the [ -z "" ] test succeeds.
Correct — The empty output makes -z true. run find DIR -mindepth 1 checks the status as well as the output.

Related