Lesson 13 of 14
Archives, tmux, and Shell Scripts
This lesson covers three groups of tools that appear in almost every developer workflow: archive commands that pack and compress files, tmux for terminals that survive a closed connection, and Bash scripts that turn a sequence of commands into a repeatable program. After it, the reader can bundle a project directory, run a job in a detached terminal, and write a script that stops on the first error and cleans up after itself.
| Command or construct | Purpose |
|---|---|
tar -cvf, tar -tf, tar -xf |
Create, list, and extract an archive |
tar -czf, tar -cJf |
Create gzip and xz compressed archives |
zip, unzip |
Create and extract .zip files |
gzip, gunzip, gzip -t |
Compress, decompress, and test single files |
tmux new, ls, kill-session |
Manage named sessions |
tmux new-window, select-window |
Manage windows inside a session |
tmux split-window, select-layout |
Manage panes inside a window |
tmux attach, detach-client |
Leave and re-enter a running session |
set -euo pipefail |
Make a script stop on errors |
$1, $@, $#, if, for, while read |
Arguments and control flow |
functions, trap, bash -n |
Reuse, cleanup, and syntax checking |
All output below comes from GNU tar 1.35, Info-ZIP zip and unzip, GNU gzip, tmux 3.4, and Bash on Linux. Each example creates its own files, so the commands can be repeated in an empty directory.
Example 1: create an archive with tar
An archive is one file that holds many files and their directory structure. The tar command (tape archive) creates one without compressing it. The example builds a small project tree and packs it.
mkdir -p demo && cd demo
mkdir -p project/src project/docs
printf 'public class App {}\n' > project/src/App.java
printf 'Hello docs\n' > project/docs/readme.txt
printf 'name=demo\n' > project/config.properties
tar -cvf project.tar project
ls -l project.tar | awk '{print $5, $9}'project/
project/src/
project/src/App.java
project/config.properties
project/docs/
project/docs/readme.txt
10240 project.tarThe option letters are combined after one dash:
| Option | Meaning |
|---|---|
-c |
Create a new archive |
-v |
Verbose: print each path as it is added |
-f project.tar |
The archive file name; it must come directly after the letters that precede it |
The last argument, project, is the directory to add. tar adds it recursively and stores the relative path, so the archive contains project/src/App.java, not an absolute path. The size is 10240 bytes although the content is about 40 bytes: tar writes in 512-byte blocks and pads the file to a 10240-byte record. Without -v, tar prints nothing on success. On macOS the default tar is BSD tar, which accepts the same -cvf form.
Example 2: list an archive without extracting it
Listing shows what an archive holds before anything is written to disk. Reading the list first prevents an extraction from scattering files across the current directory.
tar -tf project.tar
tar -tvf project.tar | awk '{print $1, $3, $6}'project/
project/src/
project/src/App.java
project/config.properties
project/docs/
project/docs/readme.txt
drwxr-xr-x 0 project/
drwxr-xr-x 0 project/src/
-rw-r--r-- 20 project/src/App.java
-rw-r--r-- 10 project/config.properties
drwxr-xr-x 0 project/docs/
-rw-r--r-- 11 project/docs/readme.txt-t lists the table of contents. With -v the long format shows permissions, owner, size, date, and path. The awk command keeps the permission column, the size column, and the path column; the owner and date columns vary between machines. A list that does not start with a single top-level directory (a "tarbomb") would extract its files directly into the current directory. Checking the first lines of tar -tf detects that case.
Example 3: extract an archive
Extraction recreates the stored files. By default tar writes into the current directory, so -C selects a different target directory. Names after the archive restrict extraction to those members.
mkdir -p restore && tar -xvf project.tar -C restore
mkdir -p one && tar -xf project.tar -C one project/config.properties && find one -type f
mkdir -p two && tar -xf project.tar -C two --strip-components=1 project/docs && find two | sortproject/
project/src/
project/src/App.java
project/config.properties
project/docs/
project/docs/readme.txt
one/project/config.properties
two
two/docs
two/docs/readme.txt| Option | Meaning |
|---|---|
-x |
Extract |
-C DIR |
Change to DIR before extracting (the directory must exist) |
--strip-components=N |
Remove the first N leading path parts from each member |
The second command extracts one member by its exact stored path, so the result keeps the project/ prefix. The third command removes the leading project/ part, which places docs directly under two. --strip-components is common when a downloaded release archive wraps everything in a versioned top-level directory. BSD tar on macOS also supports --strip-components; the GNU-only --transform option has the BSD equivalent -s.
Example 4: compress with gzip and xz through tar
tar itself does not compress. It calls a compressor when one option letter is added. The example adds a repetitive log file so the compression ratio is visible.
yes 'repeat this line to make it compress well' | head -n 2000 > project/big.log
tar -czvf project.tar.gz project
tar -cJf project.tar.xz project
ls -l project.tar project.tar.gz project.tar.xz | awk '{print $5, $9}'
tar -tzf project.tar.gz | head -3
tar -xf project.tar.xz -C restore && echo "auto-detected xz on extract"project/
project/src/
project/src/App.java
project/big.log
project/config.properties
project/docs/
project/docs/readme.txt
10240 project.tar
666 project.tar.gz
440 project.tar.xz
project/
project/src/
project/src/App.java
auto-detected xz on extract| Option | Compressor | Typical file name |
|---|---|---|
-z |
gzip | .tar.gz, .tgz |
-J |
xz | .tar.xz |
-j |
bzip2 | .tar.bz2 |
The 84,000-byte log compresses to 666 bytes with gzip and 440 bytes with xz. The first ls line still shows the old project.tar because it was created before big.log existed. xz usually produces smaller files than gzip but takes more time and memory to compress; gzip is faster and available everywhere. When extracting, GNU tar detects the compression from the file contents, so tar -xf works without -z or -J. The explicit letter is required only when creating an archive or when reading from a pipe on older versions.
Example 5: zip and unzip
The .zip format compresses each file separately and stores a central index, so single files can be read without scanning the whole archive. It is the common exchange format with Windows users. The zip and unzip packages may need installing (apt install zip unzip); macOS ships both.
which zip unzip
zip -r project.zip project
unzip -l project.zip
mkdir -p zout && unzip -q project.zip -d zout && find zout -type f | sort/usr/bin/zip
/usr/bin/unzip
adding: project/ (stored 0%)
adding: project/src/ (stored 0%)
adding: project/src/App.java (stored 0%)
adding: project/big.log (deflated 100%)
adding: project/config.properties (stored 0%)
adding: project/docs/ (stored 0%)
adding: project/docs/readme.txt (stored 0%)
Archive: project.zip
Length Date Time Name
--------- ---------- ----- ----
0 2026-10-03 15:18 project/
0 2026-10-03 15:18 project/src/
20 2026-10-03 15:18 project/src/App.java
84000 2026-10-03 15:18 project/big.log
10 2026-10-03 15:18 project/config.properties
0 2026-10-03 15:18 project/docs/
11 2026-10-03 15:18 project/docs/readme.txt
--------- -------
84041 7 files
zout/project/big.log
zout/project/config.properties
zout/project/docs/readme.txt
zout/project/src/App.javazip -r adds a directory recursively; without -r, only the empty directory entry is stored. Each line says how the entry was stored: tiny files are stored as they are (0% saved), and big.log is deflated by 100% (rounded). unzip -l lists contents with sizes and dates (the date and time columns here depend on when the files were created). unzip -q suppresses the per-file messages, and -d selects the target directory. Compared with tar, zip keeps compression per file, so extracting one file is fast, but it does not compress across files and stores less Unix metadata.
Example 6: gzip, gunzip, and testing a file
gzip compresses one file, not a directory. By default it replaces the original with a .gz file, which is why -k (keep) is useful when the original must stay.
cp project/big.log single.log
gzip -k single.log
ls -l single.log single.log.gz | awk '{print $5, $9}'
gzip -l single.log.gz
rm single.log && gunzip single.log.gz && ls single.log*
gzip -t project.tar.gz && echo "gzip -t ok"84000 single.log
328 single.log.gz
compressed uncompressed ratio uncompressed_name
328 84000 99.6% single.log
single.log
gzip -t okgzip -l reports the compressed size, the original size, and the ratio without decompressing. gunzip is the same program as gzip -d; it restores single.log and deletes single.log.gz. gzip -t checks the integrity of the compressed data and prints nothing when the file is sound, so && runs the echo only on success. Other useful forms are gzip -9 (smallest output, slowest) and zcat file.gz (print decompressed content to the terminal without writing a file). The xz command has matching options: xz -k, xz -d, and xz -t.
Example 7: tmux sessions
tmux is a terminal multiplexer: a server process that owns terminal sessions independently of the window that displays them. A session keeps running when the terminal closes or an SSH connection drops. A session is a named group of windows. The examples run detached (-d) so that no interactive terminal is needed.
tmux -V
tmux new-session -d -s work
tmux new-session -d -s logs
tmux list-sessions -F '#{session_name}: #{session_windows} window(s)'
tmux has-session -t work && echo "work exists"
tmux rename-session -t logs build
tmux list-sessions -F '#{session_name}'
tmux kill-session -t build
tmux list-sessions -F '#{session_name}'tmux 3.4
logs: 1 window(s)
work: 1 window(s)
work exists
build
work
work| Command | Effect |
|---|---|
new-session -d -s NAME |
Create a session named NAME without attaching |
list-sessions (alias ls) |
List sessions; -F sets a format string |
has-session -t NAME |
Exit status 0 when the session exists |
rename-session -t OLD NEW |
Rename a session |
kill-session -t NAME |
Stop a session and everything in it |
-t names the target. has-session prints nothing, so scripts use its exit status. Without -d, tmux new -s work attaches at once. The default prefix key is Ctrl-b: pressing Ctrl-b and then $ renames the current session from the keyboard. The sessions here exist only in the tmux server, so tmux kill-server removes all of them.
Example 8: tmux windows
A window fills the whole terminal and acts like a tab. One session holds many windows, each with its own shell. Windows are numbered from 0 by default.
tmux new-window -t work -n editor
tmux new-window -t work -n server
tmux select-window -t work:editor
tmux list-windows -t work -F '#{window_index}:#{window_name} active=#{window_active}'
tmux send-keys -t work:server 'echo from-server-window' Enter
sleep 0.5
tmux capture-pane -p -t work:server | grep from-server-window | tail -1
tmux kill-window -t work:editor
tmux list-windows -t work -F '#{window_index}:#{window_name}'0:bash active=0
1:editor active=1
2:server active=0
from-server-window
0:bash
2:servernew-window -n NAME creates and names a window and makes it active. The target form session:window accepts a name or an index. select-window switches the active window, so after it only editor shows active=1. send-keys types text into a window as if the user had typed it, and Enter is the key name for the return key. capture-pane -p prints the visible text of a pane to standard output, which makes tmux output testable from scripts. The capture is filtered with grep here because the pane also contains the shell prompt, whose text depends on the user and host. Removing window 1 leaves a gap in the numbering; indexes are not renumbered unless the renumber-windows option is on. Keyboard shortcuts at the default prefix: Ctrl-b c creates a window, Ctrl-b n and Ctrl-b p move to the next and previous window, and Ctrl-b & kills the current one.
Example 9: tmux panes
A pane is a rectangular section of one window with its own shell. Splitting a window shows several programs at once, such as an editor, a test runner, and a log tail. The size values below come from a detached session of default size (80 columns by 24 rows); an attached terminal reports its own size.
tmux split-window -h -t work:server
tmux split-window -v -t work:server
tmux list-panes -t work:server -F '#{pane_index}: #{pane_width}x#{pane_height}'
tmux send-keys -t work:server.1 'echo pane-one' Enter
sleep 0.5
tmux capture-pane -p -t work:server.1 | grep pane-one | tail -1
tmux select-layout -t work:server even-horizontal
tmux list-panes -t work:server -F '#{pane_index}: #{pane_width}x#{pane_height}'0: 40x24
1: 39x12
2: 39x11
pane-one
0: 26x24
1: 26x24
2: 26x24split-window -h splits side by side (a vertical dividing line) and -v splits top and bottom. The first split halves the 80 columns into panes 0 and 1. The second split halves pane 1 vertically into panes 1 and 2, so pane 2 is 11 rows because one row is used by the border. The target work:server.1 means pane 1 of window server of session work. select-layout even-horizontal rearranges all panes into equal-width columns, 26 each, with the remaining columns used by two borders. Other layouts are even-vertical, main-vertical, and tiled. Default keys: Ctrl-b % splits side by side, Ctrl-b " splits top and bottom, Ctrl-b o moves to the next pane, Ctrl-b z zooms one pane, and Ctrl-b x closes the current pane.
Example 10: detach and attach
Detaching leaves a session running in the background, and attaching reconnects to it. This is the main reason to use tmux on remote machines: a long build keeps running when the SSH connection ends. A real attach needs a terminal, so this example attaches from inside a second detached session (TMUX= clears the variable that otherwise blocks nesting).
tmux new-session -d -s job 'sleep 60'
tmux new-session -d -s viewer -x 100 -y 20
tmux send-keys -t viewer 'TMUX= tmux attach -t job' Enter
sleep 1
tmux list-clients -t job -F 'client attached to #{session_name}'
tmux list-sessions -F '#{session_name} attached=#{session_attached}'
tmux detach-client -s job
sleep 0.5
tmux list-sessions -F '#{session_name} attached=#{session_attached}'
tmux kill-serverclient attached to job
job attached=1
viewer attached=0
job attached=0
viewer attached=0new-session -d -s job 'sleep 60' starts a session whose first window runs the given command instead of a shell. While a client is attached, session_attached is 1. detach-client -s job detaches every client of that session, and the session keeps running (job attached=0). In normal use the keys are Ctrl-b d to detach and tmux attach -t job (or tmux a) to return; tmux attach without -t picks the most recent session. A session that runs a command ends when that command ends, so a shell window is the usual choice for long-lived work. The last command, kill-server, removes the demo sessions; after it, tmux ls reports an error because no server is running.
Example 11: a script with set -euo pipefail
A shell script is a text file of commands that Bash runs in order. By default Bash continues after a failing command, which lets later commands act on a broken state. Three options change that. The first line, #!/usr/bin/env bash, is the shebang: it names the interpreter when the file is run as ./script.sh.
mkdir -p ../w && cd ../w
cat > fail.sh <<'X'
#!/usr/bin/env bash
echo "start"
cd /no/such/dir
echo "still running in $(pwd)"
X
cat > strict.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
echo "start"
cd /no/such/dir
echo "never reached"
X
cat > pipe.sh <<'X'
#!/usr/bin/env bash
set -eu
false | cat
echo "no pipefail: continued"
set -o pipefail
false | cat
echo "never reached"
X
cat > unset.sh <<'X'
#!/usr/bin/env bash
set -u
echo "dir is: $TARGET_DIR"
X
for f in fail strict pipe unset; do echo "--- $f.sh"; bash $f.sh; echo "exit=$?"; done 2>&1--- fail.sh
start
fail.sh: line 3: cd: /no/such/dir: No such file or directory
still running in /tmp/claude-0/cmd-l13/w
exit=0
--- strict.sh
start
strict.sh: line 4: cd: /no/such/dir: No such file or directory
exit=1
--- pipe.sh
no pipefail: continued
exit=1
--- unset.sh
unset.sh: line 3: TARGET_DIR: unbound variable
exit=1| Option | Effect |
|---|---|
set -e |
Exit when a command fails (non-zero status) |
set -u |
Treat an unset variable as an error |
set -o pipefail |
A pipeline fails when any command in it fails, not only the last |
fail.sh shows the default: the cd fails, but the script continues in the wrong directory and exits with status 0 (the path in the output is the demo directory and differs on other machines). strict.sh stops at the failing cd with status 1. In pipe.sh, false | cat reports success because cat, the last command, succeeded; after set -o pipefail the same pipeline fails and set -e ends the script. unset.sh shows -u catching a misspelled or missing variable. set -e has exceptions: failures inside an if condition, in a && or || list (except the last command), and in ! negations do not exit.
Example 12: arguments with $1, $@, and $#
Arguments are the words after the script name. Bash exposes them as positional parameters, so a script can behave differently for each call.
cat > args.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
echo "script: $0"
echo "count: $#"
echo "first: ${1:-none}"
for a in "$@"; do echo "arg: [$a]"; done
echo "joined: $*"
X
bash args.sh
bash args.sh alpha "two words" gammascript: args.sh
count: 0
first: none
joined:
script: args.sh
count: 3
first: alpha
arg: [alpha]
arg: [two words]
arg: [gamma]
joined: alpha two words gamma| Parameter | Meaning |
|---|---|
$0 |
The script name as it was invoked |
$1, $2, ... |
The first, second, ... argument |
$# |
The number of arguments |
"$@" |
Every argument, each kept as a separate word |
$* |
Every argument joined into one string |
${1:-none} |
$1, or none when it is unset or empty |
The quotes in "two words" make the shell pass one argument with a space inside. "$@" keeps that argument intact, which the bracketed output proves. Under set -u, reading $1 with no arguments would be an error, so the default form ${1:-none} supplies a fallback. ${1:?message} is the opposite choice: it aborts with the message when the argument is missing, as the next example shows.
Example 13: conditions with if
if runs a command and branches on its exit status: 0 means true, anything else means false. The [[ ... ]] construct tests files, strings, and patterns; (( ... )) tests numbers.
cat > check.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
file="${1:?usage: check.sh FILE}"
if [[ ! -e "$file" ]]; then
echo "missing: $file"
elif [[ -d "$file" ]]; then
echo "directory: $file"
elif [[ -s "$file" ]]; then
echo "non-empty file: $file"
else
echo "empty file: $file"
fi
if [[ "$file" == *.sh ]] && [[ -s "$file" ]]; then echo "is a script"; fi
if (( $# > 1 )); then echo "extra args ignored"; fi
X
: > empty.txt
for t in nothing . empty.txt check.sh; do bash check.sh $t; done 2>&1
bash check.sh 2>&1; echo "exit=$?"missing: nothing
directory: .
empty file: empty.txt
non-empty file: check.sh
is a script
check.sh: line 3: 1: usage: check.sh FILE
exit=1| Test | True when |
|---|---|
-e FILE |
The path exists |
-d FILE |
It is a directory |
-s FILE |
It exists and is not empty |
-f FILE |
It is a regular file |
A == *.sh |
String A matches the glob pattern (inside [[ ]]) |
(( $# > 1 )) |
The arithmetic comparison holds |
The chain if, elif, else, fi takes the first branch that is true. The final call without an argument hits ${1:?usage: ...} and the script exits with status 1 and a message. The message begins with line 3: 1: because Bash names the failing parameter. Variables are quoted ("$file") so that a path with spaces stays one word.
Example 14: loops with for
for repeats a block once per item. The items can be a literal list, a glob pattern, a counter, or the arguments.
cat > loop.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
for n in 1 2 3; do echo "n=$n"; done
for ((i = 0; i < 3; i++)); do echo "i=$i"; done
for f in *.txt; do echo "txt: $f"; done
for f in *.nomatch; do echo "literal: $f"; done
shopt -s nullglob
for f in *.nomatch; do echo "never"; done
echo "nullglob: no iterations"
for w in $(echo "a b"); do echo "word: $w"; done
X
bash loop.shn=1
n=2
n=3
i=0
i=1
i=2
txt: empty.txt
literal: *.nomatch
nullglob: no iterations
word: a
word: bThe first loop walks a fixed list. The second is the C-style form with a counter. The third expands *.txt into the matching files, here only empty.txt; a glob in a for is safe for names with spaces because the shell expands each match into one item. The fourth loop shows a default behavior: when nothing matches, Bash passes the pattern itself, so the body runs once with the text *.nomatch. shopt -s nullglob makes an unmatched pattern expand to nothing, so the loop runs zero times. The last loop splits the output of a command on whitespace, which breaks names that contain spaces; prefer globs or while read for file names.
Example 15: reading input with while read
while read processes input one line at a time. It suits files and command output of any size, because it does not load everything into one variable.
printf 'alice:30:admin\nbob:25:dev\ncarol:41:ops\n' > users.txt
cat > readloop.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
while IFS=: read -r name age role; do
echo "$name is $age ($role)"
done < users.txt
count=0
while read -r line; do count=$((count + 1)); done < users.txt
echo "lines: $count"
printf ' padded line\\n\n' | { read line; echo "[$line]"; }
printf ' padded line\\n\n' | { IFS= read -r line; echo "[$line]"; }
n=0; while (( n < 3 )); do echo "tick $n"; n=$((n+1)); done
X
bash readloop.shalice is 30 (admin)
bob is 25 (dev)
carol is 41 (ops)
lines: 3
[padded linen]
[ padded line\n]
tick 0
tick 1
tick 2IFS=: sets the field separator for that one read, so the line alice:30:admin splits into name, age, and role. done < users.txt redirects the file into the whole loop. read -r keeps backslashes as they are. The two printf lines show why both safeguards matter: plain read trimmed the leading spaces and consumed the backslash in \n (the first result is padded linen), while IFS= read -r preserved the line exactly. The last loop uses while with an arithmetic condition instead of input. A loop fed by a pipe runs in a subshell, which Example 18 shows.
Example 16: functions
A function groups commands under a name, so a script defines each step once and calls it many times. Functions take arguments as $1, $2, and $@, like scripts. They return an exit status, and they print values that the caller captures with $(...).
cat > func.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[log] $*"; }
add() { local a=$1 b=$2; echo $((a + b)); }
is_even() { (( $1 % 2 == 0 )); }
need() { command -v "$1" >/dev/null || { log "missing tool: $1"; return 1; }; }
log "starting"
total=$(add 2 3)
log "total=$total"
if is_even 4; then log "4 is even"; fi
if ! is_even 5; then log "5 is odd"; fi
need tar && log "tar found"
need nonexistent-tool || log "continuing after missing tool"
x=global; f() { local x=inner; echo "in f: $x"; }; f; echo "after f: $x"
X
bash func.sh[log] starting
[log] total=5
[log] 4 is even
[log] 5 is odd
[log] tar found
[log] missing tool: nonexistent-tool
[log] continuing after missing tool
in f: inner
after f: globaladd returns its result by printing it; total=$(add 2 3) captures that output. is_even returns the exit status of its arithmetic test, so it works directly as an if condition. need checks that a program exists with command -v and returns 1 when it does not. local limits a variable to the function: the last line shows that x inside f does not change the global x. Without local, assignments inside a function change the variable for the whole script. The exit status of a function is the status of its last command, or the value given to return (0 to 255).
Example 17: cleanup with trap
trap registers a command that Bash runs when the script receives a signal or exits. A script that creates a temporary directory needs this, because set -e can end the script at any line.
cat > cleanup.sh <<'X'
#!/usr/bin/env bash
set -euo pipefail
work=$(mktemp -d)
cleanup() { rc=$?; rm -rf "$work"; echo "cleanup: removed temp dir (exit code $rc)"; }
trap cleanup EXIT
trap 'echo "interrupted"; exit 130' INT TERM
echo "temp dir exists: $([[ -d $work ]] && echo yes)"
echo data > "$work/file.txt"
if [[ "${1:-}" == fail ]]; then echo "failing on purpose"; false; fi
echo "finished normally"
X
bash cleanup.sh; echo "exit=$?"
bash cleanup.sh fail; echo "exit=$?"
cat > sig.sh <<'X'
#!/usr/bin/env bash
trap 'echo "got TERM, stopping"; exit 143' TERM
echo "ready"
sleep 30 & wait $!
X
bash sig.sh & P=$!; sleep 0.5; kill -TERM $P; wait $P; echo "exit=$?"temp dir exists: yes
finished normally
cleanup: removed temp dir (exit code 0)
exit=0
temp dir exists: yes
failing on purpose
cleanup: removed temp dir (exit code 1)
exit=1
ready
got TERM, stopping
exit=143| Trap name | Runs when |
|---|---|
EXIT |
The script ends for any reason: normal end, exit, or an error under set -e |
INT |
The user presses Ctrl-c |
TERM |
Another process sends kill (signal 15) |
ERR |
A command fails (before set -e exits) |
mktemp -d creates a uniquely named temporary directory. In cleanup, rc=$? captures the exit status before any other command changes it, and the message shows that the trap ran with status 0 on success and 1 after the deliberate false. The INT TERM trap turns a signal into a normal exit, which in turn runs the EXIT trap, so cleanup happens once for every path. The sig.sh part sends TERM to a running script: the handler runs, and the exit status 143 equals 128 plus the signal number 15. The script waits with wait $! because Bash defers a trap until a foreground command finishes, and wait is interruptible. A SIGKILL (kill -9) cannot be trapped.
Example 18: shellcheck-style pitfalls
shellcheck is a static analyzer for shell scripts. It is not installed on the machine that produced this lesson, so the example runs the mistakes it reports and shows their effects. Its usual call is shellcheck script.sh, and each finding has a code such as SC2086 (unquoted variable) that links to an explanation. The built-in bash -n checks syntax only, without running anything.
cat > pitfalls.sh <<'X'
#!/usr/bin/env bash
mkdir -p "p dir"; touch "p dir/a.txt"
f="p dir/a.txt"
echo "unquoted:"; for x in $f; do echo " [$x]"; done
echo "quoted:"; for x in "$f"; do echo " [$x]"; done
echo "[ ] with empty var:"; v=""; [ $v = "x" ] 2>&1 || echo " test failed with error above"
echo "[[ ]] with empty var:"; [[ $v = "x" ]] || echo " false, no error"
cd /no/such/dir 2>/dev/null; echo "ran in: $(basename "$PWD")"
n=0; printf 'a\nb\n' | while read -r l; do n=$((n+1)); done; echo "n after pipe loop: $n"
n=0; while read -r l; do n=$((n+1)); done < <(printf 'a\nb\n'); echo "n after redirect loop: $n"
( unset dir; set -u; rm -rf "${dir:?}/" ) 2>&1; echo "exit=$?"
X
bash pitfalls.sh
bash -n pitfalls.sh && echo "bash -n: syntax ok"
printf 'if true; then\necho hi\n' > bad.sh; bash -n bad.sh 2>&1; echo "exit=$?"unquoted:
[p]
[dir/a.txt]
quoted:
[p dir/a.txt]
[ ] with empty var:
pitfalls.sh: line 6: [: =: unary operator expected
test failed with error above
[[ ]] with empty var:
false, no error
ran in: w
n after pipe loop: 0
n after redirect loop: 2
pitfalls.sh: line 11: dir: parameter null or not set
exit=1
bash -n: syntax ok
bad.sh: line 3: syntax error: unexpected end of file
exit=2| Pitfall | Effect shown | Fix |
|---|---|---|
Unquoted $f |
A path with a space splits into two words | Quote: "$f" |
[ $v = x ] with empty $v |
The test loses an operand and errors | Use [[ ]] and quote variables |
cd without set -e or ` |
exit` | |
cmd | while read |
The loop runs in a subshell, so n stays 0 |
Redirect: done < <(cmd) |
rm -rf "$dir/" with empty dir |
Would target / |
"${dir:?}" aborts when empty |
The syntax check passes the first file because every statement is valid, although the script has the runtime mistakes above. bash -n finds the unclosed if in bad.sh and exits with status 2. A static analyzer such as shellcheck reports the unquoted variables, the unchecked cd, and the pipe-into-loop pattern without running the script. When both are available, bash -n catches syntax errors and shellcheck catches the semantic ones. A rm -rf on a computed path always needs the :? guard or a prior check.
Interview points
tarbundles files, and compression is a separate step selected by-z(gzip),-J(xz), or-j(bzip2). GNU tar detects the compression when extracting. The file name suffix is a convention only.- Before extracting an unknown archive,
tar -tfshows the paths. An archive without one top-level directory writes files straight into the current directory, and entries with absolute paths or..are a security concern. --strip-components=Ndrops leading path parts on extraction, and-C DIRsets the target directory.zipcompresses each entry separately and allows reading one entry without scanning everything; a compressed tar must be decompressed from the start to reach a member.gzipreplaces the original file unless-kis given, andgzip -tverifies integrity without writing output.- tmux sessions live in a server process, so they survive a closed terminal or a dropped SSH connection. The hierarchy is session, window, pane, and a target is written
session:window.pane. tmux send-keysandtmux capture-pane -plet a script drive and read a pane, which is how tmux output is tested.set -euo pipefailstops on a failed command, an unset variable, and a failure inside a pipeline.set -eis ignored inifconditions and&&/||lists, so it does not replace explicit error handling."$@"preserves each argument as one word,$*joins them, and$#counts them. Unquoted expansions split on whitespace and expand globs.trap cleanup EXITruns on normal exit and on errors; aSIGKILLcannot be trapped.cmd | while readruns the loop in a subshell, so variables set inside do not survive.
Summary
tar -cvf archive.tar dircreates,tar -tflists, andtar -xf archive.tar -C targetextracts an archive.-zselects gzip and-Jselects xz when creating; xz output is smaller and slower to produce.- The 84,000-byte sample log became 666 bytes with gzip and 440 bytes with xz inside a tar archive.
zip -r out.zip dirandunzip -l,unzip -dhandle.zipfiles; each entry is compressed on its own.gzip -kkeeps the original,gunziprestores it,gzip -lshows ratios, andgzip -ttests integrity.- tmux uses
new-session -d -s,list-sessions,kill-session, and-tto name a target; the default prefix isCtrl-b. new-window -nandselect-windowmanage windows;split-window -hor-vandselect-layoutmanage panes.detach-clientandattach -tleave and re-enter a session, which keeps running while detached.set -e,set -u, andset -o pipefailmake a script fail early on errors, unset variables, and failed pipeline stages.$1,$#,"$@", and${1:-default}read arguments;if [[ ]]tests files and strings, and(( ))tests numbers.foriterates over lists, globs, and counters;while IFS= read -r linereads files line by line.- Functions use
localfor private variables, return a status, and print values for$(...);trap ... EXITremoves temporary files on every exit path. bash -nchecks syntax, andshellcheckreports quoting, subshell, and unchecked-cdmistakes before they run.
