Guide contents

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.

Terminal
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}'
Output
project/
project/src/
project/src/App.java
project/config.properties
project/docs/
project/docs/readme.txt
10240 project.tar

The 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.

Terminal
tar -tf project.tar
tar -tvf project.tar | awk '{print $1, $3, $6}'
Output
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.

Terminal
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 | sort
Output
project/
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.

Terminal
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"
Output
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.

Terminal
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
Output
/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.java

zip -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.

Terminal
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"
Output
84000 single.log
328 single.log.gz
         compressed        uncompressed  ratio uncompressed_name
                328               84000  99.6% single.log
single.log
gzip -t ok

gzip -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.

Terminal
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}'
Output
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.

Terminal
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}'
Output
0:bash active=0
1:editor active=1
2:server active=0
from-server-window
0:bash
2:server

new-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.

Terminal
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}'
Output
0: 40x24
1: 39x12
2: 39x11
pane-one
0: 26x24
1: 26x24
2: 26x24

split-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).

Terminal
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-server
Output
client attached to job
job attached=1
viewer attached=0
job attached=0
viewer attached=0

new-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.

Terminal
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
Output
--- 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.

Terminal
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" gamma
Output
script: 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.

Terminal
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=$?"
Output
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.

Terminal
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.sh
Output
n=1
n=2
n=3
i=0
i=1
i=2
txt: empty.txt
literal: *.nomatch
nullglob: no iterations
word: a
word: b

The 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.

Terminal
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.sh
Output
alice is 30 (admin)
bob is 25 (dev)
carol is 41 (ops)
lines: 3
[padded linen]
[  padded line\n]
tick 0
tick 1
tick 2

IFS=: 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 $(...).

Terminal
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
Output
[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: global

add 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.

Terminal
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=$?"
Output
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.

Terminal
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=$?"
Output
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

  • tar bundles 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 -tf shows 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=N drops leading path parts on extraction, and -C DIR sets the target directory.
  • zip compresses each entry separately and allows reading one entry without scanning everything; a compressed tar must be decompressed from the start to reach a member.
  • gzip replaces the original file unless -k is given, and gzip -t verifies 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-keys and tmux capture-pane -p let a script drive and read a pane, which is how tmux output is tested.
  • set -euo pipefail stops on a failed command, an unset variable, and a failure inside a pipeline. set -e is ignored in if conditions 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 EXIT runs on normal exit and on errors; a SIGKILL cannot be trapped. cmd | while read runs the loop in a subshell, so variables set inside do not survive.

Summary

  • tar -cvf archive.tar dir creates, tar -tf lists, and tar -xf archive.tar -C target extracts an archive.
  • -z selects gzip and -J selects 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 dir and unzip -l, unzip -d handle .zip files; each entry is compressed on its own.
  • gzip -k keeps the original, gunzip restores it, gzip -l shows ratios, and gzip -t tests integrity.
  • tmux uses new-session -d -s, list-sessions, kill-session, and -t to name a target; the default prefix is Ctrl-b.
  • new-window -n and select-window manage windows; split-window -h or -v and select-layout manage panes.
  • detach-client and attach -t leave and re-enter a session, which keeps running while detached.
  • set -e, set -u, and set -o pipefail make 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.
  • for iterates over lists, globs, and counters; while IFS= read -r line reads files line by line.
  • Functions use local for private variables, return a status, and print values for $(...); trap ... EXIT removes temporary files on every exit path.
  • bash -n checks syntax, and shellcheck reports quoting, subshell, and unchecked-cd mistakes before they run.