Bash Style Guide
Bash Style Guide
Default-IFS bash is a field of footguns. for f in $files breaks on filenames with spaces; [[ $x == $y ]] accidentally glob-matches; defensive quoting becomes universal and obscures which expansions actually need protection. The bash you write fights you, and the noise it generates fights the reader. This guide describes a disciplined alternative — IFS=$'\n' and set -o noglob as a floor, plus the naming, quoting, comment, and layout conventions that the floor makes possible. Once the floor is in place, code can drop noise quoting, lean on visible naming for safety contracts, and use shape and whitespace to make structure legible at a glance. The unifying theme is visual expressiveness: bash code is communication to the reader’s eye before it is instruction to the parser.
The frame: visual expressiveness
A reader scanning a well-written bash function sees, in this order: blank-line stanzas marking conceptual seams; cuddled near-duplicate lines whose differing token is the only column that doesn’t repeat; aligned columns of ), =, #, or && that turn parallel structure into a visual table; and one-line predicates whose syntactic shape matches their semantic meaning. Words come last. The aesthetic conventions below — naming suffixes, comment placement, file order, layout choices — exist to support that reading order. Correctness rules (quoting, error handling, scoping) sit alongside; they derive from bash semantics rather than aesthetics, but the same discipline of “let the visible shape carry intent” runs through both.
Harmony has two paths to one goal: let the eye lock into a frame so only the variations register.
Cuddling works when lines can be made structurally similar. The repeated parts become a chorus the eye stops reading after the first instance; the differing token leaps out as the only column that doesn’t repeat. Three siblings stacked vertically with one varying token communicate their relationship before the reader parses any of it.
Breathing works when lines must differ — different operations, different shapes. A blank line tells the eye “different thought, reset your frame.” The reader accepts each line on its own terms instead of hunting for a chorus that isn’t there.
Three failure modes break harmony, all testable by inspection. False cuddling — adjacent lines share visual layout but not semantic role, forcing the reader to re-parse each line individually because the chorus turns out to be misleading. Missing breathing — branches with materially different structure pack into one visual block instead of being separated by blank lines. Broken symmetry — an established pattern is violated without semantic justification, like one entry in an aligned data structure using a different shape. If none of these failure modes applies to a piece of code being criticized, the criticism is aesthetic intuition, not analysis.
Shebang and bash version
#!/usr/bin/env bash. Bash 4.4 or newer (for ${var@Q}). Libraries use the .bash extension; executables have no extension.
Safety preamble
Libraries leave strict mode and IFS to their callers. Library files themselves don’t set set -e, IFS=$'\n', or noglob; consumers do that after sourcing. This lets a library be sourced into a shell whose error policy the library has no business overriding.
Scripts defer strict mode until after option parsing. Option parsing inspects ${1:-} and uses unquoted $*; both interact poorly with set -eu before args are validated. The standard for a new script is set -euo pipefail. Add f (equivalent to set -o noglob) if noglob isn’t already on.
Place the sourcing-test guard before strict mode. Tests source the script and call individual functions; functions written under IFS+noglob+set -u+pipefail behave differently without those settings, so testing without them gives false confidence. But set -e after a source call kills an interactive shell on any failed command, and most failed commands in interactive use are intentional probes. Split the boilerplate on one test — does this setting bork an interactive shell that sources the file? Only set -e does: it exits the shell on the next nonzero command. IFS=$'\n', set -o noglob, and set -o pipefail do not — pipefail merely changes a pipeline’s exit status, IFS/noglob merely alter word-splitting/globbing — so they go above the guard (where sourced functions inherit the discipline they’re written for) and set -e alone goes below. Yes, IFS/noglob above the guard mildly surprises an interactive sourcer (their shell’s word-splitting/globbing changes until they reset it); that non-fatal surprise is the accepted cost of correctness. The alternative — pushing IFS/noglob below the guard for a “pristine” interactive shell — leaves sourced functions running under default IFS/glob, where they are not guaranteed to behave, which is the worse failure. (Test files sidestep the whole question by setting their own IFS=$'\n'; set -o noglob — they own their execution regardless of where the script puts it.) The same ordering is load-bearing for the executed path, for a distinct reason: a top-level return fails when the script is executed rather than sourced, so set -e placed above the guard turns that failure into an abort at the guard line — dispatch/main never runs and every invocation is a silent no-op. Get the order right (set -uo pipefail above, set -e below) and both the interactive and executed paths are safe; see the pipefail section below for the full failure mode and anchor.
Script bottom (sourceable for testing):
IFS=$'\n'
set -o noglob
set -uo pipefail
return 2>/dev/null # stop here if sourced — tests get the functions without set -e
set -e
main "$@"
Library-consumer boilerplate mirrors the shape:
source ~/.local/lib/mylib.bash 2>/dev/null || { echo 'fatal: mylib.bash not found' >&2; exit 1; }
IFS=$'\n'
set -o noglob
return 2>/dev/null
main $*
Naming
Names are the documentation. A reader who sees a name should know what discipline applies without grepping. A _ suffix means “may contain newlines or be empty; must quote on expansion.” A *List suffix means “serialized list in a single string, IFS-separated.” An UPPERCASE name in a nameref position means “this is a cross-scope return slot, never collides with a camelCase local.” A cmd.PascalCase function in an mk.bash script is the framework’s marker for a user-invocable subcommand.
Functions
Libraries use namespace.PascalCase for public functions and namespace.camelCase for private ones, where the namespace is the project name lowercased (e.g. lib.). The namespace prevents collisions when two libraries are sourced together.
Standalone scripts skip the namespace. mk.bash entry points use cmd.PascalCase; everything else — helpers in mk.bash scripts, all functions in non-mk.bash standalone tools — uses plain camelCase. The mk.bash cmd. prefix is a framework affordance, not a general pattern. Do not introduce script-local sub-namespaces like forward.* or module.* in a standalone script. The script’s name on disk is already the namespace; an extra dot-prefix ornaments the surface for nothing.
Locals
camelCase, lowercase initial. Compound words that name a single semantic concept stay lowercase: filename, testname, fieldname — not fileName, testName. Arrays use plural names (testnames, requestedTests); scalars use singular. Unpack positional parameters on one local line: local got=$1 want=$2, local msg=$1 rc=${2:-$?}.
Globals
PascalCase, uppercase initial. Libraries append a randomly chosen project-specific suffix letter — DebugQ, ShowProgressQ — to prevent collisions when multiple libraries are sourced together. Standalone scripts omit the suffix.
Mutability is encoded in case. Default globals are bootstrap-initialized: written once at startup (sourced registries, function-populated lookup tables, parsed-arg arrays) and never mutated afterward. They stay PascalCase. The rare mutable global — counters, accumulators, caches written from multiple sites during normal execution — uses ALL_CAPS_SNAKE_CASE. The case difference is visible at every call site, so a reader knows whether a name can change under them without grepping for writers. A (C)-marked Calculation may read PascalCase globals freely; reading ALL_CAPS_SNAKE_CASE disqualifies. Where the contract can be locked at the shell level, use declare -Agr, declare -agr, declare -gr, or readonly NAME on the declaration.
Cross-scope return variables
When a function writes to a caller-supplied variable name — via local -n, printf -v "$outVar", eval "$outVar=...", or any other mechanism — the caller’s variable name should be UPPERCASE. The convention borrows the environment-variable namespace so cross-scope names can’t collide with the caller’s locals (always camelCase) or the function’s own locals.
The collision risk is general, not specific to local -n. If the helper has a local tmpDir and does printf -v "$1" '%s' "$tmpDir", and the caller passed tmpDir as the out-param name, the printf writes to the helper’s local; the caller’s variable is never set. Using TMP_DIR for the caller’s variable eliminates the collision because no function should declare a local in that case style.
List suffixes
*List (singular) holds a serialized list in one string, IFS-separated: commandList, groupList. Quote on expansion. Initialize as local xList='', never local xList=() (the parens form is the array variant; see *Lists below).
*Lists (plural) holds a true bash array whose elements may contain IFS characters: commandLists, groupLists. Quote element accesses and the "${arr[@]}" expansion to preserve boundaries.
Plain plural holds a true bash array of plain scalars with no IFS hazard: testnames, filenames. No suffix needed; safe unquoted under IFS+noglob.
The _ suffix is for things that aren’t lists but still need quoting: single multi-line blobs, optional flag values that may be empty, trap output. _ is mutually exclusive with both *List and *Lists on the same variable.
The decision criterion: is this conceptually a list of items? Scalar serialized blob → *List. True array of IFS-bearing elements → *Lists. Single thing that happens to contain newlines or be optional → _. Array of plain scalars → plain plural.
Injectable dependencies
Use the command name directly, lowercase, with underscores replacing hyphens: ssh_keygen=${ssh_keygen:-ssh-keygen}. Despite being global declarations, they follow local naming because callers override them with local declarations in tests: local ssh_keygen=mockSshKeygen. The lowercase signals “this is designed to be shadowed.” Libraries append the namespace suffix letter: ssh_keygenQ.
Standard exceptions
NL=$'\n' for string interpolation in double quotes. Prog=$(basename "$0") for scripts that report their own name. These conventional exceptions skip the namespace-suffix rule even in libraries.
Naming policy header
Libraries begin with a Naming Policy header comment that names the conventions in play; consumers need it to source the library correctly. A CLI script can use a much shorter header or skip the policy block entirely — the bash style guide is the source of truth for naming, and replicating it per-script is mini-style-guide-for-this-file noise.
Library header:
# Naming Policy:
#
# All function and variable names are camelCased.
#
# Private function names begin with lowercase letters.
# Public function names begin with uppercase letters.
# Function names are prefixed with "lib." (always lowercase) so they are namespaced.
#
# Local variable names begin with lowercase letters, e.g. localVariable.
#
# Global variable names begin with uppercase letters, e.g. GlobalVariable.
# Since this is a library, global variable names are also namespaced by suffixing them with
# the randomly-generated letter Q, e.g. GlobalVariableQ.
# Global variables are not public. Library consumers should not be aware of them.
# If users need to interact with them, create accessor functions for the purpose.
#
# Variable declarations that are name references borrow the environment namespace, e.g.
# "local -n ARRAY=$1".
Standalone-script header (minimal — operator-facing orientation lives in the usage message):
# evtctl publishes events to era streams. See docs/evtctl.md.
Enforcement
Many of these conventions are mechanically checked by the shellcheck-convention-plugin.
- SC9001–SC9004 — IFS/noglob taint discipline and
_/*Listsuffix rules (this section and Quoting below). - SC9005 — numeric comparisons in
[[ ]]/[ ](Conditionals). - SC9006 — inclusive language in identifiers and comments (cross-cutting).
- SC9007 — docstring shape (Comments).
- SC9008 —
*Listinitialized as an IFS-serialized string, not an array. - SC9009 — uninitialized-then-appended variable (Variable Scoping).
Per-check rationale, severity, opt-in cdName, and source-rule citations live in the plugin’s docs/design.md §3 check catalog. Suppress per site with # shellcheck disable=SC9xxx; opt in to optional checks with enable=<cdName> in ~/.shellcheckrc or --enable=<cdName> per invocation.
Quoting
Quoting carries intent. Under IFS+noglob, most scalar expansions are safe unquoted, so a quote signals “this value needs protection — either it contains IFS characters or the context demands exact word boundaries.” Quoting every expansion adds noise without adding safety and obscures which values actually require care. When a reviewer sees quotes, they should trust that those quotes are there for a reason.
The _ suffix is the ongoing contract. A variable name ending in _ says “may contain IFS characters, may be empty; must quote on every expansion outside no-split contexts.”
The suffix applies in either of two cases. IFS content — the variable may contain newlines (under IFS=$'\n'); unquoted expansion would split it into multiple words. Emptiness — the variable may be empty; unquoted expansion would disappear entirely as a positional arg, shifting downstream args. set -u catches unset, not empty.
Prefer non-empty initialization over the _ suffix when emptiness can be eliminated by construction. A counter local i=0 is better than local i_ — 0 is non-empty, so unquoted expansion is safe. But local s='' is NOT better than local s_ — empty-string initialization leaves the variable empty, so unquoted expansion still disappears. The _ suffix is for cases where emptiness is load-bearing: an optional flag that’s absent, a parsed field that may be missing, a trap that captures whatever output exists, a builder string that starts empty. Marking variables that could be initialized non-empty as _ defeats the discipline’s purpose — it creates a forest of _ suffixes that obscures the truly-quote-required cases.
The same construction-eliminates-emptiness logic applies to two-state literal sentinels. local taskSeen=no (or =false) is non-empty from declaration through every reassignment — it’s only ever set to one fixed literal from a closed two-value set (yes/no, true/false), never to an empty string. local taskSeen_ for that variable repeats the same over-marking mistake as local i_ for a counter: emptiness was never reachable, so the _ suffix asserts a risk the variable’s construction already rules out.
Integer types drop the _ suffix entirely. local -i n=$(cmd) coerces every assignment to an integer; the variable can’t hold IFS content. Use -i for counters, loop indices, exit codes, byte sizes, PIDs — anything whose semantic type is integer. local -i parallel=0, local -i rc=0, local -i bucket. Arithmetic context ((( parallel == 0 )), return $rc) needs no quoting because the value’s type rules out IFS-bearing content. The _ rule applies to STRINGS that may be empty or may contain IFS characters; typed ints and fixed-shape string locals don’t qualify.
In practice: commands_ (trap output — emptiness is load-bearing), content_ (user input, may contain newlines), usage_ (multiline heredoc), tags_ (optional flag, empty when not provided).
Promote after validation
After validating a _-suffixed value, rebind to a non-_ name. The same discipline applies to empty-possible values, not just IFS-bearing ones:
local foo_
foo_=$(maybeEmptyOp)
[[ -n $foo_ ]] || return 0
local foo=$foo_ # foo is now trusted scalar; use unquoted hereafter
local jsonl=$dir/$foo/data.jsonl
Carrying _ past the validation point blurs the distinction between “still possibly empty” and “validated.” The suffix system loses signal when every variable that was ever uncertain stays _-marked forever.
The same discipline applies to IFS-content validation: before promoting an untrusted value to a non-_ name, check it explicitly ([[ $value_ == *$'\n'* ]] && fatal "newline in path"). The check is the sanitization; assigning to a non-_ name is the claim that sanitization has been done. After that, do not defensively re-quote a non-_ variable.
Eval-safe quoting
printf %q escapes a value for shell re-evaluation:
printf -v output '%q ' "$@" # output is safe to eval
${var@Q} renders a human-readable quoted literal, useful for debug output and test copy-paste lines:
CMD="sudo -u ${RunAsUser@Q} bash -c ${CMD@Q}" # readable in logs
echo "want=${got@Q}" # tests — paste to update expected value
read -r discipline
Always use read -r to avoid backslash interpretation. Use IFS='' read -r when consuming raw lines where leading and trailing whitespace are significant. Use IFS=' ' read -r when lines may be indented (heredoc or otherwise) and whitespace should be stripped before the value is used — see map in FP Pipeline Helpers.
Braces in expansion
$var, not ${var} — braces add noise when the variable name is unambiguous. For disambiguation when text follows the name, prefer quotes over braces: "$var"Suffix concatenates the quoted expansion with the literal. Use braces when the variable is embedded mid-string and quotes can’t delimit it: "prefix${var}suffix".
Array and positional expansion
"${array[@]}" and "$@" preserve element boundaries — each element stays a separate word. "$*" joins elements with the first character of IFS (useful for serialization). Unquoted, both ${array[@]} and $@ undergo word splitting on IFS, so elements containing newlines get broken apart. Under set -u, an empty array needs ${args[@]:-} as fallback.
Quoting decision tree
For any expansion you’re unsure about, walk this:
- No-split context? Assignment RHS,
[[ ]](except RHS of==and=~),(( )),case, array subscripts,${...}operators, redirections, here-strings — quoting is unnecessary. These contexts never split or glob regardless of IFS/noglob settings. _-suffixed variable? Must quote in non-assignment contexts:echo "$Usage_",eval "$testSource_".- Required-quoting context? Array expansion (
"${arr[@]}"), RHS of==in[[for literal match,evalarguments,trapstrings, process substitution with multi-line content — must quote. - Otherwise — safe unquoted under IFS+noglob. The variable has no
_suffix (newline-free by convention), and the context is a command invocation with scalar arguments.
Single vs double quotes
Single quotes are the default for string literals; reach for double only when the value contains a single quote or you need parameter, command, or arithmetic expansion. Single quotes guarantee the string is taken verbatim — no $var, no $(cmd), no \-escapes, no surprise expansion. Reserve double quotes for cases where one of those behaviors is intended.
fatal 'op-run: not in a git repo' 64 # no expansion, single quotes
fatal "op-run: realpath failed for $raw" 1 # $raw expanded, double quotes
echo 'literal $foo, no expansion' # $foo stays literal
Heredoc terminator
END by default. Use END consistently across the codebase so terminator search and grep are uniform. Quote it (<<'END') by default; only use unquoted (<<END) when the body needs $var or $(cmd) expansion. Unquoted heredocs are a risk when the body is content the author didn’t fully parse — pasted markdown can introduce backtick command-substitutions by accident, and $ sequences expand silently. Quoted terminators take the body verbatim with no exceptions.
Multi-line echos use heredocs
A sequence of echo statements emitting a multi-line message is a heredoc waiting to happen. Replace with cat <<'END' ... END (or cat <<END ... END when expanding variables). The heredoc form is fewer lines, avoids quote-juggling for embedded apostrophes or double quotes, lets the message be edited as a block, and preserves embedded blank lines naturally.
# Avoid:
echo 'BLOCKED: foo'
echo
echo 'See docs.'
echo "Match: $hits"
# Prefer:
cat <<END
BLOCKED: foo
See docs.
Match: $hits
END
When to quote
Quotes are required in these contexts.
Trust boundaries. User input must be presumed to contain IFS characters until sanitized. Assign untrusted input to a _-suffixed variable and quote it on use. Validate, then promote (see Promote after validation above).
"${array[@]}" / "$@" / "$*" — preserve element boundaries. Unquote only when IFS splitting is intentional: local arr=( $(command) ).
RHS of == in [[ — [[ $x == "$y" ]] for literal match. Unquoted RHS is a glob pattern: *, ?, [ become wildcards. Leave unquoted for intentional pattern matching: [[ $OSTYPE == darwin* ]]. Embedded literals between glob anchors ([[ $output == *WARNING:* ]]) don’t need quoting unless the embedded literal contains whitespace, in which case quote for readability: [[ $output == *"docs refreshed"* ]].
RHS of =~ in [[ — quoting disables regex metacharacter interpretation. Leave unquoted for regex matching. For complex patterns, store in a variable: local pattern='^[0-9]+$'; [[ $x =~ $pattern ]].
_-suffixed variables in non-assignment contexts.
eval arguments — eval "$CMD". Without quotes, newlines become argument separators.
Command substitution as argument — judgment call. func "$(command)" when the result should be a single word; unquoted $(command) splits on newlines when that’s desired (local arr=( $(listItems) )).
trap command strings — trap "$command$NL$(existing)" EXIT. The string is stored for later eval; must be a single coherent argument.
Process substitution with multi-line content — diff <(echo "$got") <(echo "$want"). Unquoted echo $var splits on newlines, destroying line structure.
Positional pairing arguments — APIs that consume arguments in key-value pairs (jq --arg name value, custom key-value functions) break when an empty variable expands to nothing, shifting subsequent pairs. Quote empty-possible values: --arg t "$type_".
When quoting is unnecessary
These contexts never split or glob:
- Assignment RHS —
local var=$value,var=$(command),var=${1:-default}. [[ ]]operands (except RHS of==and=~) —[[ -e $file ]],[[ $var == pattern ]](LHS).(( ))arithmetic —(( rc == 0 )),(( ${#array[@]} )).caseword —case $var in.- Array subscripts —
${map[$key]},array[$idx]=val. - Inside
${...}operators —${1:-$default},${var#$prefix}. - Redirection targets —
>$file,<$file,<<<$var. Bash takes the single word. Cuddle the operator with its target:>$file, not> $file;>>"$path", not>> "$path". The space-free form binds the redirection visually with its argument, parallels stdin/stderr forms (2>&1, no space), and avoids implying the target is a separate command argument. Same rule for<,<<<,>>,<>. - Scalar command arguments —
func $simplevar,mkdir -p $dir. Under IFS+noglob, splitting only occurs on newlines and globbing is disabled. This applies identically to functions, builtins, and external commands. This is the default for variables without the_suffix.
Outside the IFS+noglob discipline
IFS=$'\n'; set -o noglob is the strong default for every bash file that owns its own execution policy. Skipping it is a deviation, not a stylistic option, and deviations need a stated reason. The reason must be about the execution environment (you don’t control the shell that runs this file) or about the file’s role (see the library carve-out below) — never “the existing files elsewhere don’t have it either.” Grandfathering explains how a file got that way; it doesn’t justify a new file, or an edit to an old one, continuing to skip the discipline.
Libraries are a separate, already-legitimate exemption, not an instance of grandfathering. Per “Safety preamble” above: a library leaves strict mode and IFS to its callers by design — the library itself must not force a policy on whatever sources it. This is a role distinction (library vs. script/test), stated once here, not something each library needs to re-justify per file. It does NOT extend to that library’s own test file or CLI wrappers in the same repo — a test file sets the discipline (test files own their own execution), and a repo’s scripts should too unless they hit one of the environmental reasons below.
The rules above assume IFS=$'\n'; set -o noglob is in effect. That assumption fails in several common contexts where bash code runs under tooling that controls the shell environment:
- Home Manager activation scripts (
home.activation.<name>) — activated by HM’s bash without IFS or noglob. - Nix builders (
mkScriptBin’s wrapper,pkgs.writeShellScript,pkgs.runCommand’sbuildCommand) — Nix’s build sandbox bash runs with default settings. - systemd unit ExecStart with inline shell (
bash -c '...') — systemd executes with default bash. - Embedded shell snippets in YAML, TOML, or Nix attribute values consumed by tools you don’t control.
- Heredocs that get extracted and run separately — the surrounding script’s discipline doesn’t transfer.
These are the accepted reasons because they share one property: the file genuinely cannot set its own shell options, because something else invokes the interpreter. A test file, a standalone script, or anything else that owns its own #!/usr/bin/env bash line does not have this excuse — it can always add the three-line preamble, so not having it is a gap, not a constraint.
In these contexts, apply standard bash quoting: quote every expansion. Treat all variables as if _-suffixed regardless of their actual name. The _ and *List conventions are an optimization enabled by IFS+noglob; without that floor, the safe default is universal quoting. "$var" everywhere it would be expanded; "$@" and "${array[@]}" for positional and array expansion; "$(cmd)" for command substitution; globs disabled defensively where filename expansion isn’t wanted.
If the embedded snippet is more than a few lines, consider opening with the safety preamble (IFS=$'\n'; set -o noglob; set -uo pipefail) so the IFS+noglob discipline applies inside the snippet. For short snippets, universal quoting is simpler than entering the discipline.
A reviewer reading bash code under unknown discipline should default to expecting universal quoting. If a snippet uses unquoted expansions and isn’t under a documented IFS+noglob preamble, treat it as a quoting bug regardless of variable naming.
Variable scoping
Bash has dynamic scoping: a function can read and modify variables in its caller’s scope, even local variables. This is the opposite of lexical scoping in C, Python, or Go, where a function can only see its own locals and globals.
Array declaration
Use parens to declare arrays, and skip declare for indexed arrays unless -g is needed:
Arr=( a b c ) # indexed, populated — no declare needed
Arr=() # indexed, empty — no declare needed; parens signal "array, currently empty"
The parens convey “this is an array” visually and let you skip declare for indexed arrays. For empty arrays especially, Arr=() is preferable to declare -a Arr; the parens are a strong visual reminder that the variable is an array AND that it’s currently empty.
declare -g for globals declared inside functions
declare without -g always creates a local variable when executed inside a function scope, including scalars, arrays, and associative arrays. A plain assignment (Var=value) creates or modifies a global, but declare Var=value in a function scope is local. The same trap applies to files sourced from within a function: the source call inherits the caller’s scope, so every declare in the sourced file creates a local to the calling function.
declare -g Scalar=value # scalar global — use -g when declare is needed
declare -ag Arr=( a b c ) # indexed array global
declare -Ag Map=( [k]=v ) # associative array global — -A also required
Arr=( a b c ) # plain assignment: global if no enclosing local
Indexed arrays often don’t need declare at all — plain assignment with parens creates or modifies a global. Associative arrays do need declare -A (or -Ag) because bash requires the -A flag to allocate the hash structure; without it, brackets parse as indexed subscripts and silently misbehave.
This is load-bearing whenever a sourced file declares state intended to outlive the source call. If projects.bash declares declare -A ProjectPath=(...) and is sourced from inside main(), ProjectPath is local to main — visible to functions called by main via dynamic scoping, but gone after main returns. Make the global intent explicit with -Ag.
Bare $ArrayVar for known-single-element arrays
Bash flags expanding an array without an index (SC2128) — “expanding an array without an index only gives the first element.” For well-known single-element-in-practice arrays like $BASH_SOURCE (always at least one element; index 0 is the running script’s path), the bare form reads as a scalar and matches reader intuition. Prefer the bare form for these cases and suppress shellcheck per site:
# shellcheck disable=SC2128 # BASH_SOURCE always has at least one element
Here=$(dirname "$BASH_SOURCE")
For arrays where multi-element semantics matter, use the explicit ${Arr[0]} form.
Initialize at declaration
Declare and initialize together. A local x with no = initializer creates a variable whose value is the empty string, but the intent is ambiguous — is x supposed to be a string, an array, a sentinel for “not yet computed”, or an unfinished thought? Subsequent appends or assignments force the reader to scan ahead to learn the variable’s shape.
The operative case is the uninitialized-then-appended antipattern (SC9009): within a single scope (function body, file top-level, compound block), if every path between the variable’s declaration and its first read uses += and never uses a plain = assignment, the variable’s first materialization is an append mutation. The fix is to initialize at declaration:
| Antipattern | Preferred | Container shape |
|---|---|---|
local arr then arr+=( ... ) |
local arr=() |
array |
local content then content+="..." |
local content_="" |
string (empty-init is empty-able) |
local xList then xList+="..." |
local xList="" |
*List serialized string |
local arr; [[ cond ]] && arr+=( ... ) |
local arr=() |
array (conditional append) |
declare Arr then Arr+=( ... ) elsewhere |
Arr=() at declaration |
array (global) |
Canonical recommended forms:
local content_='' # string, initially empty (_ per Quoting — empty-init is empty-able)
local args=( "$@" ) # array, populated from positional params
local items=() # array, initially empty
local docList='' # *List-suffix string (IFS-serialized list, initially empty)
local -A cache=() # associative array, initially empty (needs -A)
local -i count=0 # integer scalar, default 0 (-i marker makes the type explicit)
local result_=$(someCmd) # string from cmdsub — always assigns, empty on no output
Exceptions — declaration-coincident initializers — a bare declaration is acceptable when the variable is fully populated in an adjacent statement and the populating command is guaranteed to assign the variable:
local result_=$(some-command) # cmdsub initializer (always assigns)
local arr=( a b c ) # array literal initializer
local arr; mapfile -t arr < <(cmd) # mapfile/readarray always assigns
local line_=""; read -r line_ # explicit empty-init guards against read rc=1
local line; read -r line < file # UNSAFE under set -u — read on EOF may not assign
mapfile always assigns the target array, even on empty input. Bare read on EOF may not assign the target at all — a subsequent $line reference under set -u raises unbound-variable. Use the explicit empty-init form for read.
Other exceptions — intentional unset or sentinel (rare; document at the site): a genuine “not yet computed” sentinel where downstream code checks [[ -v x ]] or [[ -n $x ]] deliberately; an out-param nameref (local -n REF=$1) whose “value” is the binding rather than a value.
Mechanism
When bash resolves a variable name, it walks up the call stack. A callee’s local x shadows the caller’s x, but without local, the callee accesses the caller’s variable directly. This applies to both reads and writes.
A test runner can exploit dynamic scoping intentionally for callback counting. The callback modifies passCount and failCount, which are locals in the calling function:
passCount+=1 # in caller's scope
The comment # in caller's scope documents the intentional cross-scope access. Without this pattern, the runner would pass counters through return values or globals.
The collision risk is the inverse case. If a callee declares local x and the caller also has local x, the callee gets its own copy. But if the callee doesn’t declare local and uses x, it silently modifies the caller’s x. The risk is highest with namerefs: local -n REF=$1 — if $1 is REF, the nameref points to itself (circular reference).
Naming conventions are the primary protection: camelCase locals and PascalCase+suffix globals occupy separate namespaces, so two callees in the same chain are unlikely to collide if they follow conventions. UPPERCASE namerefs (local -n ARRAY=$1) borrow the environment-variable namespace, which never collides with camelCase locals in the caller. Subshell () function bodies provide hard isolation when dynamic scoping is unwanted; changes to variables, working directory, and shell options are discarded when the subshell exits:
createCloneRepo() ( # () not {} — subshell isolates side effects
git init clone
cd clone # doesn't affect caller's pwd
echo hello >hello.txt
git add hello.txt && git commit -m init
) >/dev/null
Use () when a helper needs to cd or modify shell state; use {} (the default) when the caller needs to see the function’s side effects.
Returning multiple values via stdout
When a function needs to return multiple values, namerefs (local -n PROJ=$2 SID=$3) are one option but couple the function’s signature to the caller’s local-variable names and reintroduce the dynamic-scoping shadowing risk. An alternative for small tuples: emit a delimited string via stdout and parse at the call site:
# resolveSession echoes "<proj>:<sid>" or empty for cold start.
resolveSession() {
local path=$1
...
echo $sanitized:$latest
}
# Caller:
local resolution_
resolution_=$(resolveSession $pathReal)
[[ -n $resolution_ ]] || { coldStart; return; }
local proj=${resolution_%%:*} sid=${resolution_#*:}
This works when the components are guaranteed not to contain the delimiter (sanitized paths and UUID-like ids are safe with :). The trade-offs: no signature coupling, the function follows bash’s natural “echo the result, return rc” idiom, no dynamic-scoping shadowing risk, but the delimiter is an implicit contract that must be documented, and there’s an extra subshell from $(...).
For wider tuples (4+ values), nameref out-params are usually clearer than parsing a longer delimited string. For 2-3 values where the delimiter is safe, stdout return is often the simpler choice.
Conditionals
[[ exclusively for string and file tests. [[ is bash’s compound command with pattern matching, no word splitting, and &&/|| inside.
(( )) for arithmetic and booleans
Boolean flags are 0/1 integers tested bare: (( failed )) && return 1, (( hasSubtests )) && echo .... Numeric variables use explicit comparison: (( rc == 0 )), (( pid != 0 )). Arithmetic expansion: $(( endTime - startTime )).
Avoid switch-style numeric comparisons in [[ ]] — numeric comparisons belong in (( )):
(( $# > 0 )) # any positional args present
(( $# == 0 )) # no positional args
(( count == 0 )) # no count
(( rc != 0 )) # nonzero rc
(( a > b )) # numeric comparison
over [[ $# -gt 0 ]] / [[ $count -eq 0 ]] / [[ $rc -ne 0 ]]. Arithmetic context is more idiomatic for numbers — operators (>, >=, ==, !=) match math notation, no $ needed inside (( )), and there’s no implicit string-vs-int conversion to reason about.
The switch-style operators that DO belong in [[ ]] are string-emptiness and file/path tests: -z / -n (string emptiness), -f / -e / -d / -r / -w / -x (file/path predicates). These have no (( )) equivalent.
C-style for loops live in (( )) arithmetic context
When iterating with a numeric counter:
for (( i = 0; i < n; i++ )); do
...
done
over for i in $(seq 0 $((n-1))). C-style is more idiomatic for numeric iteration, doesn’t fork a subprocess, and keeps the bounds explicit at the loop head.
(( i++ )) is a set -e trap
(( expr )) exits 1 when the arithmetic result is zero. Post-increment (( i++ )) returns the old value, so when i=0 it exits 1 — silently aborting the script under set -e. The i++ in a C-style for (( ...; i++ )) header is safe because the for-loop ignores the increment expression’s exit code, but standalone (( i++ )) as a statement is not.
Preferred idiom for while-loop counters:
declare -i i=0 # (local -i inside functions) — arithmetic type, no (( )) needed
while (( i < ${#args[@]} )); do
...
i+=1 # arithmetic add; never falsy-exits; reads cleanly
done
If you need (( )) and can’t use declare -i, prefer pre-increment (( ++i )) (returns new value, truthy for any i≥0) over post-increment (( i++ )).
Case statements as tabular dispatch
A case with N branches that differ only in their pattern and action reads best as a two-column table: pattern on the left, action on the right. Pad shorter patterns so the ) falls in the same column across all arms; the actions then column up too, and the case reads as data, not as a list of irregular branches.
case ${1:-} in
-h|--help ) echo "$Usage"; exit;;
--version ) exit;;
--trace ) shift; set -x;;
esac
--trace gets two extra spaces so its ) lines up with the others. The eye reads three options and what each does in three lines.
Error handling
fatal()
fatal() {
local msg=$1 rc=${2:-$?}
echo "fatal: $msg"
exit $rc
}
Libraries namespace this (lib.Fatal) and typically print to stderr.
Return code 128 as fatal signal
A test framework can detect 128 and report “fatal” distinct from regular failure:
case $rc in
0 ) printf $columns $Pass $duration $testname; passCount+=1;;
128 ) printf $columns $Fatal $duration $Yellow$testname$Reset;;
* ) printf $columns $Fail $duration $Yellow$testname$Reset;;
esac
RC capture
cmd && rc=$? || rc=$? preserves the exit code that set -e would otherwise lose. Safe under set -e because the || makes the overall compound always succeed; set -e only triggers on unchecked failures.
output=$(eval "$cmd" 2>&1) && rc=$? || rc=$?
The trailing && bug
A function whose last command is [[ test ]] && cmd returns the test’s exit code when the test is false. Under set -e at the call site, that propagates as a non-zero return and terminates the caller — even when the function did exactly what it was meant to do (skip the conditional action).
# Bug: when $stashRef is empty, the [[ -n ]] test fails (rc 1), the
# function returns 1, and a caller under set -e aborts.
gitUpdate() {
local stashRef
stashRef=$(git stash list | head -1)
[[ -n $stashRef ]] && git stash drop $stashRef
}
# Fix 1 (preferred): invert the test so the no-op branch returns success.
[[ -z $stashRef ]] || git stash drop $stashRef
# Fix 2: explicit conditional.
if [[ -n $stashRef ]]; then git stash drop $stashRef; fi
# Fix 3: catch-all trailing return.
[[ -n $stashRef ]] && git stash drop $stashRef
return 0
Any compound where the failure branch is “do nothing” needs the function to still return zero. Inverting the test with || is usually the cleanest form — the conditional reads as “skip unless” rather than “do if.”
Expected non-zero exits under set -e
Many tools return non-zero for normal and expected outcomes — grep exits 1 on no-match, diff exits 1 on differences, era query exits 2 on silent-truncation. Under set -e these abort the script even when the caller’s logic is fine with the outcome.
! cmd blocks set -e on either outcome (success OR failure of the inverted command). Bash’s set -e documentation explicitly excludes !-inverted commands from the trigger list. The negator works as a general suppressor in any position, not just conditional heads:
# Standalone: ignore cmd's exit entirely
! grep -q "$pattern" "$file"
# In an `if` head: branch on cmd's failure case (the natural reading)
if ! grep -q "$pattern" "$file"; then
echo "no match in $file"
fi
The !-exclusion applies only to the inverted compound itself, NOT to enclosing constructs. var=$(! cmd) — the ! blocks set -e within the substitution, but the assignment statement’s exit IS the substitution’s inverted exit (rc=1 when cmd succeeded); set -e then fires on the assignment. This is NOT the same as negating the assignment itself.
! var=$(cmd) (the ! on the outer assignment, not inside the substitution) DOES work, including when cmd is a pipeline failing under pipefail:
! cachedNarHash_=$(jq -r '.nodes["x"].locked.narHash // empty' "$file" 2>/dev/null)
if [[ -z $cachedNarHash_ ]]; then
# jq failed (malformed JSON) or the node was absent -- both look the same: empty
...
fi
Bash’s set -e exemption is keyed on whether the command bash is about to execute is itself directly prefixed with ! — here that command is the assignment, so the exemption applies regardless of what $(...) inside it evaluates to. This is the right tool specifically for a direct variable-capture assignment where the caller checks emptiness (or some other property of the captured value) rather than $? afterward — it reads as “this command’s exit code is not the signal; the captured value is,” which is usually the actual intent.
It is NOT a substitute for ||: inside a function meant to always return 0 regardless of an internal command’s outcome — ! inverts, it doesn’t force zero, so ! grep ... as a function’s tail statement makes the function return 1 whenever grep succeeds, which is backwards for a “caller only cares about stdout, not rc” contract. For that job, pair the negation with an explicit return 0 on the next line:
flakeLockRev() {
local lockfile=$1 input=$2
! jq -r --arg i "$input" '.nodes[$i].locked.rev // empty' "$lockfile" 2>/dev/null
return 0
}
! cmd here blocks set -e on that line regardless of jq’s outcome; the explicit return 0 then makes “this function always succeeds, only its stdout carries the result” a visible line of code rather than an implicit side effect of ||:. Prefer this form over cmd ||: in a function tail when the “always succeeds” contract is worth stating explicitly; ||: remains equally correct and is fine where the extra line reads as noise.
Stock shellcheck’s SC2251 (“This ! is not on a condition and skips errexit… make sure $? is checked”) fires on every one of these sites, since it can’t see that the next line is an unconditional return 0 or that the caller checks emptiness rather than $?. Suppress per site with a comment naming which of the two it is, e.g. # shellcheck disable=SC2251 # deliberate: return 0 below forces the always-succeeds contract regardless or # shellcheck disable=SC2251 # deliberate: emptiness (not $?) is checked next.
Function-tail pipelines whose rc would propagate to a set -e caller need one of: ||: at the function tail, ! cmd + explicit return 0, or an explicit return 0 alone.
cmd ||: — : is the shell’s no-op builtin that returns 0; the || makes the compound always succeed. cmd’s stdout is preserved. Use this when !’s scope-limitation rules out the ! form: variable captures inside $(...), function-tail pipelines, any case where the compound’s overall exit must be 0:
hits=$(grep -cF "$pattern" "$file" ||:) # always-0 if no match
Precedence gotcha: || binds LOOSER than |. cmd1 ||: | cmd2 parses as cmd1 || (: | cmd2). For “tolerate cmd1 AND pipe to cmd2,” group with braces:
cmd1 ||: | cmd2 # WRONG: cmd1 succeeds → cmd1's stdout goes to outer fd
{ cmd1 ||:; } | cmd2 # grouped: cmd1 ALWAYS pipes to cmd2
Antipattern: cmd || echo 0 for “default on failure”. When cmd itself emits output BEFORE its non-zero exit, || echo 0 appends a SECOND value, yielding a multi-line result that breaks downstream parsing. grep -c is the classic case — it always prints the count to stdout (even “0” for no-match) AND exits 1 on no-match:
hits=$(grep -cF X file ||:) # always-0 if no match (grep prints 0, ||: yields 0)
hits=$(grep -cF X file || echo 0) # "0\n0" on no-match (grep prints 0, fallback prints 0)
Don’t reach for set +e or loosely() to silence individual commands. The strict-mode escape is for sourcing whole optional configs or running unbounded scripts. For one expected-fail command, cmd ||: or ! cmd is precise and local.
!/||: control whether -e aborts; they say nothing about whether the failure should be visible. Reach for either only when the command’s failure is genuinely inconsequential to the caller (a duplicate route, a resolver retry, a best-effort recovery step after the real work already succeeded) — never as a reflex for “this might fail sometimes.” A command that can fail for more than one reason, where some of those reasons are real problems (bad permissions, bad config, a broken environment) and others are benign (already exists, transient timeout), needs those reasons distinguished, not uniformly silenced:
# Wrong: masks a genuine permissions/interface problem identically to the
# harmless "route already exists" case -- operator has no way to tell them apart.
sudo ip route add $ip/32 dev tun0 2>/dev/null ||:
# Right: stderr stays visible, and failure gets a diagnostic instead of
# silence -- still doesn't abort the caller, but nothing is hidden.
sudo ip route add $ip/32 dev tun0 && echo added ||
echo "note: route add failed (see error above)" >&2
The test isn’t “does this command sometimes fail” — it’s “would surfacing this failure ever matter to someone debugging a real problem.” If yes, keep it loud even while keeping it non-fatal.
pipefail
Standard for new scripts: set -euo pipefail — but placement is load-bearing, not just presence. Split it around the sourcing guard exactly as the Safety preamble shows: set -uo pipefail above the return 2>/dev/null guard, set -e below it. Never write set -euo pipefail as a single line above the guard.
Why the split is not optional: a top-level return (the sourcing guard) fails when the script is executed rather than sourced — you can’t return outside a function. If set -e is already active at that point, that failure aborts the script at the guard line, so option parsing / main / dispatch never runs and every invocation becomes a silent no-op (the guard’s own 2>/dev/null even hides the “can only return' from a function" error, so there's nothing on stderr to explain it). Putting -e` below the guard is what makes an executed script reach its own body. This is a stronger failure than the interactive-shell case the Safety preamble describes: there the wrong order annoys an interactive user; here it silently breaks the tool.
Caveat — return-offset option parsers (mk.bash) need a guarded dispatch under -e. Some frameworks parse options with a helper that returns a nonzero arg-offset rather than a status, consumed as main "${@:$?}" — mk.bash’s mk.HandleOptions/mk.Main is the canonical case (0 options consumed → return 1 → ${@:1}). A BARE handleOpts "$@" under -e aborts before dispatch — -e reads the intentional nonzero return as failure, even with -e correctly placed below the guard. This is NOT an incompatibility: set -e is isomorphic to the non--e form given the appropriate guard. To keep -e, capture the offset without tripping it (the RC-capture idiom above):
handleOpts "$@" && off=$? || off=$? # || makes the compound succeed; $? preserved
main "${@:$off}"
The mk.bash canonical bootstrap instead uses the bare mk.HandleOptions "$@"; mk.Main "${@:$?}", so mk.bash scripts conventionally run with no top-level set -e and get fail-closed behavior the isomorphic way — inside the command functions (someStep || fatal ...). Both forms are valid; match your framework’s convention. (-u/pipefail are unaffected either way — they don’t trip on the offset return.)
loosely() — strict-mode escape
For sourcing optional configs that may not exist or may fail benignly:
loosely() {
set +euo pipefail
"$@"
set -euo pipefail
}
loosely source /etc/profile.d/optional-tool.sh
Dependency injection
DI variables are lowercase in standalone scripts, matching the local-naming convention so override sites read consistently with other locals. Libraries append the namespace suffix letter.
Two common shapes:
# Command DI: variable holds a command path or name; call site is `$tmux args`.
tmux=${tmux:-tmux}
date=${date:-date}
# Tests override locally:
test_main_endToEnd() {
local tmux=$dir/mock-tmux
local date=$dir/mock-date
...
}
# Function-pointer DI: variable holds a function name; call site is `$timeFunc args`.
# Library form — lowercase + suffix letter.
timeFuncQ=${timeFuncQ:-mylib.UnixMilli}
# Tests override locally:
test_someFeature() {
local timeFuncQ=mockUnixMilli
...
}
The lowercase signals “designed to be shadowed.” A bare-name call site (tmux list-panes instead of $tmux list-panes) defeats the DI — every reference to an injected dependency must use $-expansion.
Historical note: older libraries (tesht, task.bash, mk.bash) use PascalCase + suffix for DI variables (e.g., UnixMilliFuncT). New code follows the lowercase convention; existing libraries aren’t blocked from updating but the migration isn’t gated.
File organization
A bash file is a story told top to bottom. The reader opens it wanting to know what it is, how to use it, and what it does — in that order. Boilerplate doesn’t precede the story.
File order
For CLI scripts, the order is: header → usage heredoc → main → workhorses → globals and defaults → option-parsing boilerplate → sourcing-test guard → strict mode → main invocation. The reader who stops after the top quarter still understands what the script does.
#!/usr/bin/env bash
# evtctl publishes events to era streams. See docs/evtctl.md.
Prog=$(basename "$0")
read -rd '' Usage <<END
Usage:
$Prog [OPTIONS] COMMAND
...
END
main() { ... }
# other functions — call-graph descent if maintainable, alphabetical otherwise
# (alphabetical wins when call-graph order is too fiddly to keep updated)
# globals, defaults, option-parsing boilerplate
# sourcing-test guard
return 2>/dev/null
# strict mode + main invocation
set -euo pipefail
main "$@"
For libraries, no main and no usage heredoc; the header carries the setup story:
#!/usr/bin/env bash
# Naming Policy: (full block; consumers need it to source correctly)
# function definitions only
# globals — namespace-suffixed
Library vs CLI-script headers
Libraries need substantive header blocks because consumers must learn setup conventions (sourcing, IFS-discipline, naming policy, the boilerplate to copy) that have no operator-facing analog — there’s no -h to print, only a programmer-with-an-editor who needs the contract for using the file.
CLI scripts need much less. The operator-facing usage message printed at -h largely fills the role Go’s package-doc-comment occupies — it tells the reader what this thing is and how to call it with more density and more relevance than a prose header could. The header can shrink to one orienting line; the usage heredoc carries the operator-facing story. Replicating a full Naming Policy block in every CLI script is mini-style-guide-for-this-file noise; the bash style guide is the source of truth.
Sourcing-test guard
The return 2>/dev/null line near the bottom lets tests source the script and call individual functions without running main. It’s part of the visual story, not a hidden afterthought — it sits at a defined seam between “what tests get when they source” and “what running the script does.”
Comments
Function docs
Mandatory for every function. Go-inspired style: directly above the definition, no blank line between. Minimum one short sentence; more text if behavior is non-obvious.
The form borrows from godoc but doesn’t inherit Go’s tooling-specific rules by authority. The portable rules carry over because they aid human readers regardless of language.
The function name leads the comment as the grammatical subject — not “this function…” or “Returns the…”. The verb is present-tense and direct: returns, writes, publishes, resolves. Avoid “will return”, “is used to”, or “can be called to” — they bury the action under modal scaffolding.
The summary sentence fits on one line. If the function needs more, keep the summary as the one-liner and add the explanation as following prose, separated from the summary by a blank # line — summary for scanning, prose for understanding. For boolean-returning functions, the canonical form is “reports whether”: # isReady reports whether the agent has finished loading. This signals “0 or 1, not the value itself” at a glance.
Algorithm details don’t belong in the docstring. Internal implementation belongs in comments inside the function body; the docstring states the contract — what the caller observes. Algorithm enumeration and incident anchors like (#27937) belong inline next to the branch they explain, where a reader scanning that branch actually needs them.
Reference arguments by name in backticks — `path`, not “the first argument” or “the path argument.” The names are the contract; using them in prose keeps the prose synchronized with the signature. Document significant side effects: mutating globals, exporting env vars, calling exec, calling fatal/exit. Add a usage example only when the calling pattern is non-obvious — nameref out-params, multi-step compositions, callback-style arguments. Ordinary functions don’t need examples.
(C) and (D) markers, per Grokking Simplicity (Eric Normand)
Default to assuming a function is an Action; flag the rare exceptions with (C) or (D) at the end of the first sentence.
A Calculation (C) is a pure function: same inputs always produce the same output, no side effects, no I/O. Safe to call repeatedly, parallelize, refactor freely. Several carve-outs preserve (C) status despite shell features that look like side effects.
Nameref out-params do not disqualify. Bash function returns are integer exit codes only, and $() capture runs in a subshell with its own pitfalls (swallowed exit, lost set -e). Writing the result into a caller-supplied nameref is the bash idiom for “return a value” — conceptually equivalent to returning, just expressed in the language we have.
Reads of immutable-by-convention globals do not disqualify. Bash’s “constants” are written once at bootstrap and never mutated thereafter (sourced registries, DI globals, lookup tables, configuration arrays). From the function’s perspective those are additional inputs whose values are stable for the program’s lifetime. The discipline required: the global is initialized before any reading caller runs; no code path mutates it after initialization; reviewers police this because bash gives no enforcement.
Deterministic-transformation subprocesses do not disqualify. sort, awk as a data filter, grep, jq on a known string, tr, cut, comm, printf, head, tail, sed as a stream editor on explicit input — these are pure transformations whose output is fully determined by their input. Subprocesses that probe the world (date, git, op, ssh, curl, mktemp, anything reading filesystem state or generating randomness) ARE Actions. Open-ended interpreters (python -c, perl -e) should be treated as Actions by default; mark (C) only if the embedded code is verifiably a pure transformation.
Beyond determinism, watch exit semantics. A deterministic transform can still be operationally hazardous: grep exits 1 on zero matches, which under set -euo pipefail aborts the calling script. That’s a separate axis from purity.
Data (D) is a function whose body is effectively a constant — a heredoc-emitter or lookup table with no inputs that change the output. Treat as configuration.
An Action depends on or affects the world: reads time, mutates state (globals other than its own out-param), reads mutable state, runs subprocesses, exec/exits, writes files, exports env vars. Anything where “what” depends on “when” or “how often” is an Action. No marker — this is the default; tagging every Action would be noise.
Marker placement and prose references: (C) and (D) belong only at the end of the summary line of a definition’s docstring, where they classify that function. Don’t repeat the marker inline when prose refers to another function — write “delegates to buildAuditPayload” rather than “delegates to buildAuditPayload (C).” The reader can look up the referenced function’s classification at its definition site.
Examples:
# add returns the sum of `x` and `y`. (C)
add() {
echo $(( $1 + $2 ))
}
# fatal prints `msg_` to stderr and exits with `rc` (default 1).
fatal() {
local msg_=$1 rc=${2:-1}
echo "$msg_" >&2
exit $rc
}
# lib.Main runs the test functions in the files given as `args`.
#
# Outputs success or failure to stdout. Returns 0 if all tests pass, 1 if
# any test fails, 128 if any test reports fatal.
lib.Main() { ... }
# isReady reports whether `pid` has finished its bootstrap probe.
isReady() { ... }
# canonicalToplevel writes $PWD's canonical git toplevel into the nameref `OUT`.
#
# Fatals with exit 64 outside a git repo, exit 1 if realpath fails. Uses a
# nameref instead of stdout because a $() subshell would swallow fatal's
# exit, leaving the caller with an empty value.
canonicalToplevel() { ... }
Inline annotations
Inline at the end of the line when the comment is a short annotation tied to one specific line’s content, and the comment fits without disrupting alignment of related lines:
local tmpname=$(mktemp -u) # -u doesn't create a file, just a name
(( $? == 128 )) && return 128 # fatal
local NL=$'\n' # newline — works with backgrounding (&) and legal semicolons; semicolon doesn't
When two related single-line settings sit together and both want inline comments, pad the shorter line so the # columns align. The eye then reads two settings plus their explanations as one unit:
IFS=$'\n' # disable word splitting on most whitespace
set -uf # unset variables fail; turn off globbing
The inline-vs-above choice is deliberate. Inline preserves the visual cuddling of adjacent settings. Above introduces a new block — for godoc-shape function headers, multi-line explanations, or section intros.
Section markers
## is the backward-compatible super-comment. # is for individual comments; ## is for group headers and section markers. The choice of ## (the heavier form) for the rarer case lets you sprinkle # freely without committing to any structure, then add ## group headers later when structure emerges. Existing # comments still work; nothing needs to be upgraded.
The convention is the inverse of Markdown’s (# = biggest header) because the density inverts: in code, individual comments are the common case and section headers are the rare case, so the lightweight form belongs to the common case.
# strict mode ← low-level annotation
## library functions ← major section
## logging ← major section
## is preceded by a blank line. Rarely more than ## in practice.
Testing
Test framework conventions.
Associative array cases
Define test data as associative arrays:
local -A case1=(
[name]='not run when ok'
[command]="cmd 'echo hello'"
[ok]=true
[wants]="(ok 'not run when ok')"
)
Inherit unpacks
Inherit unpacks case fields into locals. Unset optional fields first so missing keys don’t carry over from a previous case:
unset -v ok shortrun prog unchg want wanterr
eval "$(Inherit "$casename")"
RunCases iterates
RunCases ${!case@} passes all case variables at once and iterates internally. Returns 1 if any case failed, 128 on fatal. For per-case error handling, use a loop:
local failed=0 casename
for casename in ${!case@}; do
RunCases $casename || {
(( $? == 128 )) && return 128 # fatal
failed=1
}
done
return $failed
Assertion failure output
The preferred pattern uses AssertGot and AssertRC:
AssertGot "$got" "$want"
AssertRC $rc 0
AssertGot compares strings, shows a diff, and emits a copy-paste line for easy test updates. AssertRC compares return codes. Both return 1 on failure.
The manual equivalent (for reference; prefer the helpers above):
[[ $got == $want ]] || {
echo "${NL}cmd: got doesn't match want:$NL$(Diff "$got" "$want")$NL"
echo "use this line to update want to match this output:${NL}want=${got@Q}"
return 1
}
Subshell isolation for setup
A subshell () body isolates cd and shell-state changes in setup helpers:
createCloneRepo() (
git init clone
cd clone
echo hello >hello.txt
git add hello.txt
git commit -m init
) >/dev/null
MktempDir for temp-dir cleanup
MktempDir dir || return 128
Cleanup is registered automatically via Defer; see Trap Handling.
AAA structure
## arrange, ## act, ## assert comment sections in each subtest, matching the canonical arrange/act/assert decomposition.
Test what should be true, not what is
Don’t pin known-broken behavior. A test that asserts “this bug currently does X” actively resists the fix — when someone correctly repairs the bug, the test fails and signals “broken behavior is desired.” Two defensible alternatives: skip or xfail the test until the bug is fixed, or document an explicit compatibility contract with rationale. Otherwise, delete the test and let the bug remain documented in code comments or an issue tracker.
Assert semantic contracts, not formatting artifacts
A test that asserts the literal output 'has\ space' couples to bash’s current printf %q strategy — if bash later renders the same value as 'has space' (single-quoted) or $'has space' (ANSI-C), the test fails despite both forms being equally shell-safe. Prefer asserting the underlying contract: extract the emitted command, eval it in a subshell, observe the result:
# Brittle: locks bash's current %q output
[[ $got_ == *'has\ space && claude'* ]] || ...
# Robust: tests "the cd line lands at the expected path"
local cdLine
cdLine=$(echo $got_ | grep -E '^cd .* && claude --resume sess-x$' | head -1)
local cdPart=${cdLine%' && claude --resume '*}
local landedAt
landedAt=$(eval "$cdPart && pwd")
[[ $landedAt == "$expectedPath" ]] || ...
Cover the executable-mode startup path
Tests that source the script (__TESTING=1 source ./script) hit the test guard before strict mode and main "$@" run. Bugs that surface only under strict mode — set -u violations during DI defaulting, pipefail interactions with grep-no-match (see Risks below) — are invisible to source-mode tests. Include at least one subprocess-invocation case:
test_main_executableInvocation() {
local dir
tesht.MktempDir dir || return 128
# ... stage env ...
local got_
got_=$(projectsDir=$dir/projects tmux=$dir/mock-tmux $ScriptPath 2>&1)
[[ $got_ == *expected* ]] || ...
}
FP pipeline helpers
Stdin-based composition: command name as first arg, applied to each line via eval. Core trio: Each (side effects), Map (transform), KeepIf / RemoveIf (filter), plus Stream/Collect siblings for converting between argument lists and newline streams. The eval "$command $arg" pattern assumes trusted input; callers are responsible for escaping with printf %q if values originate from untrusted sources.
Several projects reimplement this trio independently before consolidating it into one small sourced library — worth doing early, since two subtle bugs below went undetected for months precisely because each inline copy had to be re-audited separately.
The pattern (namespaced per the namespace.PascalCase convention above):
fp.Each() {
local command
printf -v command '%q ' "$@"
local arg
while IFS='' read -r arg; do
eval "$command $arg"
done
return 0
}
fp.KeepIf() {
local command
printf -v command '%q ' "$@"
local arg
while IFS='' read -r arg; do
eval "$command $arg" && echo "$arg"
done
return 0
}
fp.Map() {
local VARNAME=$1 EXPRESSION=$2
case $VARNAME in VARNAME|EXPRESSION ) fatal "fp.Map: VARNAME may not be 'VARNAME' or 'EXPRESSION'";; esac
local "$VARNAME"
while IFS=' ' read -r "$VARNAME"; do
eval "echo \"$EXPRESSION\""
done
}
fp.Map uses IFS=' ' (not IFS='') so that leading and trailing spaces are stripped from each line on read. This lets the heredoc body be indented for readability without embedding spaces in the substituted value. fp.Each and fp.KeepIf use IFS='' because their leading spaces land before the first argument in eval "$command $arg" — shell parsing treats them as harmless whitespace. In fp.Map, the variable value is substituted into an expression (e.g., $HOME/projects/$path), so spaces in the value become embedded mid-string rather than stripped as argument separators.
Two real, not theoretical, bugs lived in earlier versions of this exact trio. First: an earlier Map used IFS='' (not IFS=' '), so indented heredoc bodies kept their leading whitespace embedded in the substituted value instead of getting stripped — a production caller with an indented heredoc hit this live. Second: Each/KeepIf used to read only $1 as the command, silently dropping every argument after it (Each try someFunc extraArg <<<... ran try $line per line, not try someFunc extraArg $line) — the fix is joining ALL positional arguments via printf -v command '%q ' "$@" before the eval loop, shown above. Both went unnoticed for a long stretch because the code paths that would have exposed them either had no real callers yet, or had an unrelated failure masking the symptom. The lesson generalizes: test the actual failure shape empirically before trusting “it probably gets collapsed anyway” intuition about eval-based helpers, or a long stretch of apparently-correct use as proof of correctness.
Call site:
fp.Each Ln <<' END'
.config ~/config
.local ~/local
.ssh ~/ssh
secrets/netrc ~/.netrc
END
fp.Map path '$HOME/projects/$path' <<' END'
era
jeeves
tesht
END
Inline versions are common in standalone scripts; a shared library consolidates them with return 0 guards to prevent error propagation from the last iteration.
Trap handling
§14 governs lifecycle and shutdown handling. Application-specific signals (HUP for config-reload, QUIT for diagnostic dumps, USR1/USR2 for app-defined events, CHLD for process supervision) encode app semantics rather than generic lifecycle and are out of scope here.
EXIT traps for cleanup. ERR, DEBUG, and RETURN are strict no-go (rationale below).
INT/TERM handlers are justified for long-running supervisory loops, daemons, and retry-watchers. For short batch scripts, EXIT alone is sufficient.
When INT/TERM handlers are justified
Immediate clean termination. Trap converts the signal-driven stop into a normal-completion exit. Use when the caller contract treats signal-driven shutdown as a successful expected outcome — daemons under systemd whose service policy treats SIGTERM as a clean stop, supervised long-polls invoked by parent scripts, batch jobs that should ignore SIGTERM during rotation. Do NOT use to mask Ctrl-C from an interactive operator who wants to know about the abort — when INT specifically means “user pressed Ctrl-C in a context where the operator wants to see exit 130,” let it propagate.
# Bare form: caller treats both signals as clean-stop
trap 'exit 0' INT TERM
# With audit: logs the signal first so the journal records WHY the process stopped
trap 'echo "<name>: <signal> received; exiting cleanly" >&2; exit 0' TERM INT
Before adopting trap 'exit 0' ..., name the caller and write down why signal-driven exit is a success for that caller. If you can’t, the trap is probably wrong — let the default 130/143 propagate.
Cooperative shutdown at iteration boundaries. Trap sets a flag; the protected work unit completes; the loop breaks at the next defined safe-point check.
Interrupted=false
trap 'Interrupted=true' INT TERM
while :; do
work || : # work unit must satisfy obligation below
[[ $Interrupted == true ]] && break
done
The flag check is [[ $Interrupted == true ]], not $Interrupted && break. The latter would execute the variable’s contents as a command — works by coincidence when the value is true (which IS a builtin), but breaks for any other truthiness convention.
What this pattern does NOT guarantee: the trap does not interrupt work. If work is blocked on a syscall or hung, the loop won’t break until work returns. Operators using this pattern must either make work idempotent (re-running on retry is safe even if a prior call was interrupted mid-stream — read-only probes, GET requests, status checks) OR make it interruption-tolerant (handles mid-call abort without state corruption — transactional writes that commit-or-rollback, work units that hold no shared mutable state across the boundary). And they must check $Interrupted at every defined safe point — typically once per iteration, after work returns. Calling work many times before any check defeats the pattern.
Why ERR / DEBUG / RETURN are no-go
ERR — propagation is unpredictable. The handler doesn’t fire reliably inside pipelines (modified by pipefail / set -E / errtrace in non-obvious ways), inside [[ ]] / [ ], or after && / ||. Operators usually want explicit if/then (or || with explicit handler) at each fallible call site — the locality outweighs the centralization benefit.
DEBUG — fires before every command, creating highly non-local control flow that’s hard to reason about. Perf cost (linear in command count) is a secondary concern.
RETURN — fires on function return, creating hidden cleanup coupling between caller and callee. Use local-scoped cleanup or local -A registry patterns instead.
EXIT-trap patterns
Single assignment — scripts and test functions that control their own trap:
dir=$(mktemp -d)
trap "rm -rf $dir" EXIT
Direct trap "..." EXIT overwrites any previous handler. Safe when the function or script owns its entire trap lifecycle.
Stacked / deferred — libraries that must not overwrite the caller’s trap:
Defer() {
local command=$1
local NL=$'\n'
trap "$command$NL$(existingDeferlist)" EXIT
}
New handlers prepend to the existing chain. existingDeferlist extracts the current handler via trap -p EXIT and strips the wrapper syntax. Commands execute in FIFO order. Use newlines (not semicolons) as separators — semicolons interact poorly with backgrounding (&).
Temp directory cleanup
MktempDir() {
local -n DIR=$1
DIR=$(mktemp -d /tmp/bash.XXXXXX) || { echo 'could not create temporary directory'; return 1; }
[[ $DIR == /*/* ]] || { echo 'temporary directory does not comply with naming requirements'; return 1; }
[[ -d $DIR ]] || { echo 'temporary directory was made but does not exist now'; return 1; }
Defer "rm -rf $DIR"
}
Validates the path before registering cleanup. The /*/* guard prevents rm -rf / if mktemp returns something unexpected.
EXIT trap with status capture
When a cleanup function must preserve the original exit code, capture $? on the function’s FIRST line:
cleanup() {
local status=$? # capture FIRST; RHS $? is expanded before local sets its own rc
trap - EXIT # prevent recursive trap if cleanup calls exit
# ... cleanup actions ...
exit $status # re-raise original code
}
trap cleanup EXIT
trap - EXIT before exit prevents the EXIT trap firing again when cleanup calls exit. It also matters that cleanup re-raises $status: an inline multi-command trap (trap 'a; b; c' EXIT) lets the last command’s exit code become the shell’s, silently turning an exit 2 into c’s status.
Capture-line discipline (verified bash 5.3.9). The capture MUST be the first statement, and the combined local status=$? is correct — NOT split across two lines:
- A bare
local status(declaration only) sets$?to 0. So the split formlocal status; status=$?captures 0, not the exit trigger. Prefer the combinedlocal status=$?(its RHS$?is expanded beforelocalruns), or a plainstatus=$?first line. - This is distinct from SC2155 (
local x=$(cmd)maskingcmd’s failure underset -e): that hazard is a command substitution’s exit code.local status=$?has no subshell and captures the real status, so SC2155 does not apply.
Dynamic file descriptor allocation
Requires bash 4.1+. The {varname}> syntax lets bash pick an unused fd (always ≥10) and write it into varname:
exec {_LOCK_FD}>"$lock_file" || { echo "FAIL: cannot open lock file"; exit 1; }
flock -n "$_LOCK_FD" || { echo "FAIL: lock held by another process"; exit 1; }
Prefer {varname}> over a hard-coded fd like 9>. Hard-coded low fds (0–9) may conflict with the parent shell’s own redirections; bash uses 10+ for internal purposes, so {varname}> safely avoids both zones.
Lock fd isolation in interactive shells
exec {FD}>file in an interactive shell leaks the fd to the shell session for its lifetime. Wrap lock acquisition in a subshell so the fd closes automatically when the subshell exits:
(
set -euo pipefail
exec {_LOCK_FD}>"$lock_file"
flock -n "$_LOCK_FD" || { echo "FAIL: lock held"; exit 1; }
# ... protected work ...
) || { echo "FAIL: initialization failed"; exit 1; }
# fd closed here — no leak to parent shell
This pattern is required when the code block will be pasted into an interactive shell or sourced multiple times.
TOCTOU-safe temp directory for git worktrees
mktemp -u (dry-run) generates a name without creating the path, introducing a race between name generation and use. Use the parent-dir pattern instead:
PARENT=$(mktemp -d "${TMPDIR:-/tmp}/prefix-${ID}.XXXXXX") \
|| { echo "FAIL: mktemp failed"; exit 1; }
TARGET="$PARENT/worktree"
git worktree add "$TARGET" "$ref"
mktemp -d creates the parent directory atomically; the worktree or subdir is created inside it. Cleanup removes the parent:
[[ -n "${PARENT:-}" ]] && rm -rf -- "$PARENT"
Add a guard against removing unexpected paths:
case "$PARENT" in
"${TMPDIR:-/tmp}"/prefix-*) rm -rf -- "$PARENT" ;;
*) echo "WARN: refusing to remove unexpected path: $PARENT" ;;
esac
Risks and limitations
IFS+noglob plus naming conventions eliminate most bash footguns, but not all. Each risk below describes the bash mechanism, how it bites, and the mitigation.
1. Dynamic scoping collision. A callee that omits local silently modifies the caller’s variable. A nameref whose name matches its target creates a circular reference:
outer() { local x=before; inner; echo $x; } # prints "after" — inner modified outer's x
inner() { x=after; } # no local — writes to caller's scope
wrapper() { local -n REF=$1; REF=value; }
wrapper REF # circular reference — bash emits "circular name reference" error
Mitigation: follow naming conventions — camelCase locals, UPPERCASE namerefs. Document intentional cross-scope access with # in caller's scope. See Variable Scoping for the full explanation.
2. Eval injection. The FP helpers execute eval "$command $arg" where $arg is a line from stdin. If arg contains shell metacharacters, they execute as code:
echo '; rm -rf /tmp/important' | each processLine # eval runs: processLine ; rm -rf /tmp/important
Mitigation: only pass trusted input through FP pipelines. For untrusted values, escape with printf -v safe '%q' "$untrusted" before piping. The trust boundary is the eval call — everything reaching it must be safe to execute as shell words.
3. [[ RHS pattern matching. In [[ $x == $y ]], the unquoted RHS is a glob pattern — *, ?, and [ are wildcards. This is independent of set -o noglob, which only affects pathname expansion in command arguments. [[ has its own pattern-matching rules:
want='file[1]'
[[ 'file[1]' == $want ]] # false — [1] is a character class matching the single character 1
[[ 'file[1]' == "$want" ]] # true — literal comparison
Mitigation: quote the RHS for literal comparison: [[ $x == "$y" ]]. Leave unquoted only for intentional pattern matching: [[ $OSTYPE == darwin* ]].
4. Trailing newline stripping. Command substitution $(command) always strips trailing newlines from the output. This is POSIX, not a bash quirk:
output=$(printf 'hello\n\n') # output is "hello" — both trailing newlines stripped
content=$(cat "$file") # file's trailing newline(s) silently lost
Mitigation: if trailing newlines matter, append a sentinel and strip it: output=$(command; echo x); output=${output%x}. In practice this rarely matters — most values are single-line identifiers or paths.
5. set -e propagation. In bash versions before 4.4, set -e does not propagate into command substitutions $(...), so failures inside are silently swallowed. Bash 4.4 introduced shopt -s inherit_errexit to fix this, but it is off by default — you must enable it explicitly. Even with inherit_errexit, compound commands inside $(...) can behave unexpectedly. Process substitutions <(...) never inherit set -e:
set -e
result=$(false; echo "still runs") # "still runs" executes — errexit not inherited without inherit_errexit
while read -r line; do
process "$line"
done < <(failing_command) # failure undetected — process substitution ignores set -e
Mitigation: don’t rely on set -e inside command substitutions. Use explicit RC capture: result=$(command) && rc=$? || rc=$?. For critical operations, check $? after every command substitution. Alternatively, add shopt -s inherit_errexit to the preamble (bash 4.4+) to propagate set -e into command substitutions — but process substitutions remain unaffected.
6. Pipeline subshell variable loss. Each stage of a pipeline runs in a subshell. Variables modified inside a pipeline stage are lost when it exits:
count=0
command | while read -r line; do count+=1; done
echo $count # still 0 — the while loop ran in a subshell
Mitigation: use process substitution instead: while read -r line; do count+=1; done < <(command). This runs the loop in the current shell while the command runs in the subshell. Code following these conventions avoids piping into loops.
7. loosely() hardcoded restore. The loosely() wrapper does set +euo pipefail then set -euo pipefail after the command. It doesn’t capture the previous shell options — it assumes the caller always uses -euo pipefail:
set -eu # no pipefail yet
loosely source lib # sets +euo pipefail, then -euo pipefail
# now pipefail is ON even though caller never set it
Mitigation: loosely() is safe only after set -euo pipefail is set. For library code that needs to temporarily relax options, save and restore with set +o:
local prevOpts
prevOpts=$(set +o) # captures restore commands for all options
set +eu; set +o pipefail
command
eval "$prevOpts" # restores exact previous state
set +o outputs set -o/set +o commands that reproduce the current option state. This handles all options including pipefail without fragile string matching.
8. pipefail + assignment + non-fatal-non-zero exit. Several common pipeline stages legitimately exit non-zero on conditions the caller doesn’t consider failures: grep exits 1 on zero matches; head -1 closes its pipe after one line and upstream stages (sort, find -printf) may receive SIGPIPE and exit non-zero; awk '/pat/' | grep chains and comm invocations have similar shapes.
Under set -e + pipefail, the pipeline’s overall rc is the highest non-zero among stages. When that pipeline is captured by command substitution, the enclosing assignment propagates the rc, and set -e fires on the assignment:
set -euo pipefail
recorded_=$(grep -oE '"cwd":"[^"]+"' "$file" | tail -1 | sed '...') # exits when grep finds nothing
The bug is invisible in test environments that source the script (skipping strict mode) — it only manifests when invoked as an executable. Always include at least one subprocess-invocation test case.
Mitigation: make the function robust to non-fatal stage failures. Two patterns:
# (A) explicit success at end of function — when the function's contract is
# "echo the result, return 0 regardless of empty/match/no-match"
recordedCwd() {
local jsonl=$1
[[ -f $jsonl ]] || return 0
$grep -oE '...' $jsonl | tail -1 | sed '...'
return 0
}
# (B) wrap the pipeline so its rc is swallowed at the capture site
latest_=$( { $find $dir ... | sort -rn | head -1 | cut -f2-; } || true )
Pattern A is preferable when the function is reusable. Pattern B fits one-off captures. Avoid shopt -s inherit_errexit here — it makes the problem worse by ensuring the rc propagates through $().
Adopting IFS+noglob in existing scripts
Adding IFS=$'\n'; set -o noglob to a script that previously relied on default IFS (space/tab/newline) requires auditing every code path. The following issues are non-obvious and will not produce syntax errors — they silently change behavior.
1. Space-separated strings stop splitting. Associative array values like "node npm npx" no longer split into three words on unquoted expansion. Under default IFS, printf '%s\n' ${map[$key]} produces three lines; under IFS=$'\n' it produces one.
Fix: use IFS=' ' read -ra to split explicitly:
commandsFor() {
local c
IFS=' ' read -ra c <<< ${map[$key]}
printf '%s\n' "${c[@]}"
}
IFS=' ' read -ra sets IFS only for the duration of the read builtin — it does not modify the global IFS.
2. ${array[*]} joins with newlines. "${arr[*]}" joins elements with the first character of IFS. Under IFS=$'\n', this produces a newline-separated string instead of space-separated.
Fix: use a subshell command substitution extracted to a variable:
local desc=$(IFS=' '; echo ${arr[*]})
echo "packages: $desc"
The $() runs in a subshell, so the IFS=' ' doesn’t leak. Extract to a named variable to satisfy the shallow nesting rule — don’t embed $(IFS=' '; echo ...) inside string interpolation.
3. Glob patterns in for loops are dead. for f in *.txt; do matches nothing because noglob disables pathname expansion. The * is treated as a literal character.
Fix: use a glob-restoring wrapper like mk.WithGlob:
for f in $(mk.WithGlob echo $dir/*.txt); do
mk.WithGlob temporarily enables globbing, runs the command, and restores the previous glob state. Do not manually toggle set +o noglob/set -o noglob — it’s error-prone (easy to miss the restore on early return).
4. set -o noglob requires its own line. It cannot be chained into set -euo pipefail:
# WRONG — "noglob" becomes positional parameter $1
set -euo noglob pipefail
# RIGHT — separate lines
IFS=$'\n'
set -o noglob
set -euo pipefail
set -euo consumes o as a flag (equivalent to set -o), then treats the next word as the option name for -o. But -euo already consumed the o, so noglob becomes a positional parameter.
5. Audit checklist. When adding IFS+noglob to an existing script:
- Search for unquoted
${assoc_array[$key]}where the value contains spaces — these relied on default-IFS word splitting. - Search for
${array[*]}in display/logging contexts — these now join with newlines. - Search for
for x inwith glob patterns (*,?,[) — these are now literal. - Search for
set -euoto ensure noglob is set separately. - Remove unnecessary quotes from scalar non-
_expansions — they are now noise and undermine the quoting convention’s signal value. - Test all code paths, not just the happy path — glob and splitting bugs are silent.