Test Files and Conditions
Problem statement
Check a list of paths before using them and print what each one is: a missing path, a folder, an empty file, a file with content, and whether it can be run. Then see the number-comparison trap inside [[ ]]. Scripts that check before they act fail with clear messages instead of strange errors halfway through.
The script creates these paths in a temporary folder:
paths
app.conf a file with contentempty.txt an empty filelogs/ a folderdeploy.sh a file with content, made executablemissing.txt does not exist- For each path, print what it is, and add
(executable)when it can be run. - Compare the numbers 9 and 10 with
>inside[[ ]], with-gt, and with(( )). - Use
caseto describe each file by its extension.
Expected output:
== what is each path? ==app.conf file with contentempty.txt empty filelogs folderdeploy.sh file with content (executable)missing.txt missing== comparing 9 and 10 ==[[ 9 > 10 ]] true (text comparison, wrong)[[ 9 -gt 10 ]] false (number comparison)(( 9 > 10 )) false (number comparison)== case on the name ==app.conf: config filedeploy.sh: shell scriptlogs/: foldernotes.md: something elseHints
[[ -e x ]] is true if x exists, -d if it is a folder, -f if it is a regular file, -s if it is a file that is not empty, -x if it can be run. Test the most specific things in a sensible order with if, elif and else.Approach
Optimal: [[ ]] tests with if and case
Covers: exit codes and if, [[ ]], file tests -e -f -d -s -x, string tests == and -z, number tests -eq -lt -gt, (( )), && and ||, case, [ ] vs [[ ]].
Strict mode, used on every page in this section. The second line, set -euo pipefail, makes bash stop on mistakes instead of carrying on:
| Option | Means |
|---|---|
-e |
exit as soon as a command fails (with some exceptions, see Handle Command Failures) |
-u |
treat an unset variable as an error, instead of silently using empty text |
-o pipefail |
a pipeline fails if any command in it fails, not just the last one |
Put it right after the shebang line #!/usr/bin/env bash in every script you write.
A test is a command that answers yes or no. [[ -f app.conf ]] runs, and exits with 0 for "true" or 1 for "false". if does not look at text; it runs the command and checks that exit code:
That is why any command can go after if: if grep -q error app.log; then ... works the same way. It is also why tests inside if do not trigger set -e.
File tests.
| Test | True when |
|---|---|
-e path |
it exists (any kind) |
-f path |
it is a regular file |
-d path |
it is a folder |
-s path |
it is a file with at least one byte |
-x path |
you can run it (or enter it, for a folder) |
-L path |
it is a symlink |
! test |
the opposite |
Order matters: test "missing" first, then "folder", then "empty or not", so each path lands in the right branch.
Strings and numbers are different.
| Compare | Strings | Numbers |
|---|---|---|
| equal | [[ $a == $b ]] |
[[ $a -eq $b ]] or (( a == b )) |
| less / greater | < > (alphabetical) |
-lt -gt or (( a < b )) |
| empty | [[ -z $a ]] |
The trap: [[ 9 > 10 ]] is true, because as text 9 comes after 1. Always use -gt or (( )) for numbers.
9 after 1"]:::red --> S2["true"]:::red end subgraph N["(( 9 > 10 ))"] direction TB N1["compares numbers"]:::green --> N2["false"]:::green end S ~~~ N classDef blue fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,stroke-width:2px classDef yellow fill:#fef3c7,stroke:#d97706,color:#78350f,stroke-width:2px classDef green fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px classDef red fill:#fee2e2,stroke:#dc2626,color:#7f1d1d,stroke-width:2px classDef purple fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,stroke-width:2px classDef gray fill:#f3f4f6,stroke:#6b7280,color:#111827,stroke-width:2px linkStyle default stroke:#94a3b8,stroke-width:2px style S fill:transparent,stroke:#dc2626,stroke-width:2px style N fill:transparent,stroke:#059669,stroke-width:2px
Short forms. [[ -d logs ]] && echo "has logs" runs the second command only if the test passed; [[ -f app.conf ]] || exit 1 runs it only if the test failed. They are handy one-liners, but if is clearer for anything longer.
case matches patterns. case $f in *.conf) ... ;; *.sh) ... ;; *) ... ;; esac tries each pattern in order and runs the first match. It is cleaner than a chain of elif [[ $f == *.conf ]].
Walking through the code. The # Setup: lines only create the sample paths, so skip past them.
describetests each path in order, and adds(executable)for files that pass-x.- The three number comparisons show the trap and both correct forms.
- The
casegives a description per extension, and*/matches the folder.
Edge cases. Running as root, -r and -w are almost always true, so do not use them to check permissions for other users. -x on a folder means "can enter", so test -d first. A broken symlink fails -e, because the target is missing, but passes -L.
#!/usr/bin/env bash
set -euo pipefail
# Setup: create the sample paths in a fresh temporary folder
cd "$(mktemp -d)"
echo "port=8080" > app.conf
: > empty.txt
mkdir logs
printf '#!/bin/sh\necho deploying\n' > deploy.sh
chmod +x deploy.sh
describe() {
local p=$1 kind
if [[ ! -e $p ]]; then kind="missing"
elif [[ -d $p ]]; then kind="folder"
elif [[ -s $p ]]; then kind="file with content"
else kind="empty file"
fi
if [[ -f $p && -x $p ]]; then kind="$kind (executable)"; fi
printf '%-12s %s\n' "$p" "$kind"
}
echo "== what is each path? =="
for p in app.conf empty.txt logs deploy.sh missing.txt; do describe "$p"; done
echo "== comparing 9 and 10 =="
if [[ 9 > 10 ]]; then echo '[[ 9 > 10 ]] true (text comparison, wrong)'; fi
if [[ 9 -gt 10 ]]; then echo "[[ 9 -gt 10 ]] true"; else echo "[[ 9 -gt 10 ]] false (number comparison)"; fi
if (( 9 > 10 )); then echo "(( 9 > 10 )) true"; else echo "(( 9 > 10 )) false (number comparison)"; fi
echo "== case on the name =="
for f in app.conf deploy.sh logs/ notes.md; do
case $f in
*.conf) echo "$f: config file" ;;
*.sh) echo "$f: shell script" ;;
*/) echo "$f: folder" ;;
*) echo "$f: something else" ;;
esac
doneRecapThe whole problem in a few lines, for the night before
- Spot it: "check before you act", "does this file exist"
- Idea:
if [[ -f x ]]; then ...; elif [[ -d x ]]; ...,-sfor not empty,-gtfor numbers - Cost: one quick system call per test
- Trap: comparing numbers with
>inside[[ ]], which compares text
Interview follow-ups
Also say whether each file is owned by the user running the script.
Add a test with
-O, which is true when the file is owned by the current user:[[ -O $p ]] && kind="$kind, yours".-Gdoes the same for your group. To print the actual owner, usestat -c '%U' "$p"(GNU). Combined with-x, this tells you quickly whether a deploy script will run and whether you are allowed to change it.
Frequently asked questions
[ is an old command (also called test) that follows normal word splitting, so an unquoted empty variable vanishes and [ $x == y ] becomes [ == y ], an error. [[ ]] is bash syntax: it does not split variables, supports && and || inside, and adds pattern matching (== *.log) and regex (=~). In bash scripts, use [[ ]]. Use [ ] with every variable quoted only when the script must run in plain sh.
With [ ], an empty unquoted $name disappears completely, so bash runs [ == admin ] and reports "unary operator expected". Quoting fixes it, [ "$name" == admin ], because an empty quoted string is still an argument. [[ $name == admin ]] does not have this problem at all. That is one more reason to prefer [[ ]], and to quote anyway out of habit.
command -v jq > /dev/null exits 0 when jq can be run, so if ! command -v jq > /dev/null; then echo "jq is required" >&2; exit 1; fi stops early with a clear message. Avoid which, which differs between systems and does not know about shell functions or aliases. Checking all tools at the top of a script is much friendlier than failing with "command not found" in the middle.