Bash Coding Patterns
We follow various patterns in our bash coding to make life easier. These patterns are adopted to make coding in Bash
less error prone and more enjoyable.
Clean deprecated code often
Code here has been written by evolution so older code shows signs of older patterns; know that the intent is to get
everything to the same standard. We use cannon liberally when changing code so it is considered a best practice to
run this against your code regularly, it must run against the entire codebase and have zero replacements prior to
release.
Handle even the most mundane errors, love the || construct
And yes, talking about worrying about date not being installed. Or rather, not within the PATH.
The general code pattern should be:
! someCondition || catchReturn "$handler" someAction >"$tempFile" || returnUndo $? undoSomeAction || returnClean $? "$tempFile" || return $?
Yes, assignments and assignments within if statements capture errors as we want and output errors correctly:
local home
home=$(catchReturn "$handler" buildHome) || return $?
if ! item=$(catchReturn "$handler" findItem "$home" "$itemIdentifier"); then
...
fi
Note that usage of catchReturn, catchEnvironment and catchArgument should be used liberally and in front of any
function which is either native or does not have an error handler.
As a general rule, we follow the sense that normal code execution follows a string of true patterns on each line
unless it exits the function.
Remember that the || often reverses the logic in this case, so:
[ ! -f "$f" ] || catchReturn "$handler" processTheFile "$f" || return $?
An if statement uses the reverse logic:
if [ -f "$f" ]; then catchReturn "$handler" processTheFile "$f" || return $?; fi
The first version is preferred due to brevity, but either can be used and in many cases an if works best.
Clean up after ourselves
Always delete temporary files and write tests which monitor the entire file system to ensure that all successes and failures do not leave stray artifacts.
In cases where clean up is impossible, document that temporary directories will be polluted to ensure that clean up occurs either by a background or maintenance task or by the operating system on reboot.
Our tests run housekeeper on the main project directory as well as the temporary directory for all tests so all
functions (unless marked otherwise) do not leak files.
Be strict about local leaks
Bash has the problem that any variable declared anywhere, unless it is explicitly marked as local is then added to
the global scope. As a mundane example:
logger() {
for f in LOG_FILE EXTRA_LOG_FILE; do
[ ! -f "${!f}" ] || printf "%s\n" "$@" >>"$f"
done
}
See the issue? f is leaked to the global scope. While in most cases not an issue, it's considered a bad practice and
as such all functions (unless they explicitly modify environment variables) are checked with the plumber function to
ensure they do not leak variables.
Error Handlers via underscore functions
Simply this pattern is that every function which is exported has an error handler function which is the same name prefixed with an underscore:
usefulThing() {
local handler="_${FUNCNAME[0]}"
...
catchEnvironment "$handler" ...
}
_usefulThing() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
The underscore error handler acts as an effective "pointer" which allows us to reference the source document where the function resides and then do some magic to automatically output the documentation for a function.
Use the example.sh and IDENTICAL tags
Our example.sh code is kept up to date and is literally copied regularly for new functions and the IDENTICAL (and
_IDENTICAL_/__IDENTICAL__) functionality helps to keep code synchronized. Use these patterns found in the
identical directories in your code as they have been adapted over time.
Validate arguments
For most external functions, validate your argument with an argument spinner:
# _IDENTICAL_ argumentNonBlankLoopHandler 6
local __saved=("$@") __count=$#
while [ $# -gt 0 ]; do
local argument="$1" __index=$((__count - $# + 1))
# __IDENTICAL__ __checkBlankArgumentHandler 1
[ -n "$argument" ] || throwArgument "$handler" "blank #$__index/$__count ($(decorate each quote -- "${__saved[@]}"))" || return $?
case "$argument" in
# _IDENTICAL_ helpHandler 1
--help) "$handler" 0 && return $? || return $? ;;
*)
...
;;
esac
shift
done
For internal functions (usually prefixed with one or more underscores _) we assume inputs have already been validated
in many cases.
pre-commit Filters
Run pre-commit filters on all code which formats and cleans it up and checks for errors. Every project should have
this TBH. You can add this to any project here by doing:
`gitInstallHooks`
And then add your own bin/hooks/pre-commit.sh to run anything you want on the incoming files.
See git tools and specifically gitPreCommitHasExtension
amd gitPreCommitListExtension to process files (this has been already
called when pre-commit
is run).
local stays local
Upon first using bash it made sense to put local at the top of a function to have them in one place. Unfortunately
this leads to moving declarations far away from usages at times and so we have shifted to doing local declarations at
the scope needed as well as as near to its initial usage as possible. This leads to easier refactorings and better
readability all around.
Avoid depending on set -eou pipefail
Again, this is good for testing scripts but should be avoided in production as it does not work as a good method to
catch errors; code should catch errors itself using the || return $? structures you see everywhere.
In short - good for debugging but scripts internally should NOT depend on this behavior unless they set it and unset it.
Do not leave set -x around in code
So much so there is a pre-commit filter set up to avoid doing this. (For .sh files)
Avoid exit like the plague
exit in bash functions is not recommended largely because it can exit the shell or another program inadvertently when
exit is called incorrectly or a file is sourced when it should be run as a subprocess.
The use of return to pass exit status is always preferred; and when exit is required the addition of a function to
wrap it (see above) can avoid exit again.
Check all results*
Even commands piped to a file - if the file descriptor becomes invalid or gasp the disk becomes full – it will fail. The sooner it gets handled the cleaner things are.
So just do:
foo=$(catchEnvironment "$handler" thingWhichFails) || return $?
or
catchEnvironment "$handler" thingWhichFails || return $?
MOST of the time. When not to?
printfcommand is internal and we assume it rarely if never fails- some
statusMessageanddecoratecalls can be safely ignored - when the error will occur anyway very soon (next statement or so)
Clean up after ourselves, || statements to fail are more succinct
Use returnClean and returnUndo to back out of functions. If you create temp files, create them as close as possible
to needing them and make sure to delete them prior to returning (unless part of a cache, for example.)
Commands typically are:
condition || action || returnUndo $? undoActionCommand ... || returnClean $? fileToDelete directoryToDelete || return $?
Standard handler and error handling with underscore handler function
Using the usageDocument function we can automatically report errors and handler for any bash function:
Pattern:
# This describes the function's operation
# Argument: file - Required. File. Description of file argument.
functionName() {
local handler="_${FUNCNAME[0]}"
...
if ! trySomething; then
throwEnvironment "$handler" "Error message why" || return $?
fi
}
_functionName() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
Typically, any defined function deployApplication has a mirror underscore-prefixed usageDocument function used for
error handling:
deployApplication() {
...
}
_deployApplication() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
Our handler function signature is identical:
_usageFunction returnCode [ message ... ]
And it ALWAYS returns the returnCode passed into it, so you can guarantee a non-zero return code will fail and
this
pattern will always work:
_usageFunction "1" "S N A F U" || return $?
The above code MUST always return 1 - coding your handler function in any other way is
unsupported and will cause problems.
Use __functionLoader for more complex code
Zesk Build has grown in size over time and as such it makes sense to not load the entire codebase unless needed.
The function __functionLoader was added which runs an internal function which checks whether a primary function is
defined, and if it is NOT it then loads an entire directory at ./bin/build/tools/libraryName where libraryName is
specified by the calling function. The pattern used here is a loader function defined in each module:
__awsLoader() {
__functionLoader __awsInstall aws "$@"
}
And then the actual implementation for a function consists of:
awsInstall() {
__awsLoader "_${FUNCNAME[0]}" "__${FUNCNAME[0]}" "$@"
}
The arguments passed are the error handler (_awsInstall here as expanded via ${FUNCNAME[0]}), and the actual
function to call (__awsInstall - defined in ./bin/build/tools/aws/install.sh)
This allows us to defer loading of entire modules of code until needed.
Standard error codes
Two types of errors prevail in Zesk Build and those are:
- Environment errors - anything having to do with the system environment, file system, or resources in the system
- Argument errors - anything having to do with bash functions being called incorrectly or with the wrong parameters
Additional errors typically extend these two types with more specific information or specific error codes for specific applications.
Environment errors (Exit Code 1)
Examples:
- File is not found
- Directories not found
- Files are not where they should be
- Environment values are not configured correctly
- System requirements are not sufficient
Code:
return "$(_code environment)"
handler:
tempFile=(fileTemporaryName "$handler") || return $?
throwEnvironment "$handler" "No deployment application directory exists" || return $?
See:
Argument errors (Exit Code 2)
Examples:
- Missing or blank arguments
- Unknown arguments
- Arguments are formatted incorrectly
- Arguments should be valid directories and are not
- Arguments should be a file path which exists and does not
- Arguments should match a specific pattern
- Invalid argument semantics
Code:
return "$(_code argument)"
handler:
catchArgument "$handler" isInteger "$argument" || return $?
throwArgument "$handler" "No deployment application directory exists" || return $?
Argument utilities
These will be replaced with validate commands in an upcoming release.
usageArgumentDirectory- Argument must be a valid directoryusageArgumentFile- Argument must be a valid fileusageArgumentFileDirectory- Argument must be a file which may or may not exist in a directory which existsusageArgumentDirectory- Argument must be a directoryusageArgumentRealDirectory- Argument must be a directory and converted to the real pathusageArgumentFile- Argument must be a valid fileusageArgumentFileDirectory- Argument must be a file path which is a directory that existsusageArgumentInteger- Argument must be an integerusageArgumentPositiveInteger- Argument must be a positive integer (1 or greater)usageArgumentUnsignedInteger- Argument must be an unsigned integer (0 or greater)usageArgumentLoadEnvironmentFile- Argument must be an environment file which is also loaded immediately.usageArgumentString- Argument must be a non-blank stringusageArgumentEmptyString- Argument may be anythingusageArgumentBoolean- Argument must be a boolean value (trueorfalse)usageArgumentEnvironmentVariable- Argument must be a valid environment variable nameusageArgumentURL- Argument must be a valid URLusageArgumentCallable- Argument must be callable (a function or executable)usageArgumentFunction- Argument must be a functionusageArgumentExecutable- Argument must be a binary which can be executed
See
Code:
myFile=$(usageArgumentFile "_${FUNCNAME[0]}" "myFile" "${1-}") || return $?
read loops the right way
Instead of:
while read -r item; do
# something with item
done
Try:
local finished=false
while ! $finished; do
local item
read -r item || finished=true
[ -n "$item" ] || continue
# something with item
done
This handles lines and content without a trailing newline better.
The first version is permitted when you can absolutely guarantee that each line has a newline.
Appendix - simpleBashFunction
A simple example to show some standard patterns:
#!/usr/bin/env bash
#
# Example code and patterns
#
# Copyright © 2026 Market Acumen, Inc.
#
# Docs: ./documentation/source/tools/example.md
# Test: ./test/tools/example-tests.sh
# Current Code Cleaning:
#
# - use `a || b || c || return $?` format when possible
# - Any code unwrap functions add a `_` to function beginning (see `deployment.sh` for example)
_usageFunction() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
#
# DOC TEMPLATE: --help 1
# Argument: --help - Optional. Flag. Display this help.
#
# DOC TEMPLATE: --handler 1
# Argument: --handler handler - Optional. Function. Use this error handler instead of the default error handler.
#
# Argument: --easy - Optional. Flag. Easy mode.
# Argument: binary - Required. String. The binary to look for.
# Argument: remoteUrl - Required. URL. Remote URL.
# Argument: --target target - Optional. File. File to create. File must exist.
# Argument: --path path - Optional. Directory. Directory of path of thing.
# Argument: --title title - Optional. String. Title of the thing.
# Argument: --name name - Optional. String. Name of the thing.
# Argument: --url url - Optional. URL. URL to download.
# Argument: --callable callable - Optional. Callable. Function to call when url is downloaded.
# This is a sample function with example code and patterns used in Zesk Build.
#
# DOC TEMPLATE: noArgumentsForHelp 1
# Without arguments, displays help.
# DOC TEMPLATE: dashDashAllowsHelpParameters 1
# Argument: -- - Optional. Flag. Stops command processing to enable arbitrary text to be passed as additional arguments without special meaning.
exampleFunction() {
local handler="_${FUNCNAME[0]}"
local name="" easyFlag=false width=50 target=""
# _IDENTICAL_ argumentNonBlankLoopHandler 6
local __saved=("$@") __count=$#
while [ $# -gt 0 ]; do
local argument="$1" __index=$((__count - $# + 1))
# __IDENTICAL__ __checkBlankArgumentHandler 1
[ -n "$argument" ] || throwArgument "$handler" "blank #$__index/$__count ($(decorate each quote -- "${__saved[@]}"))" || return $?
case "$argument" in
# _IDENTICAL_ helpHandler 1
--help) "$handler" 0 && return $? || return $? ;;
# _IDENTICAL_ handlerHandler 1
--handler) shift && handler=$(usageArgumentFunction "$handler" "$argument" "${1-}") || return $? ;;
--easy)
easyFlag=true
;;
--name)
# shift here never fails as [ #$ -gt 0 ]
shift
name="$(usageArgumentString "$handler" "$argument" "${1-}")" || return $?
;;
--path)
shift
path="$(usageArgumentDirectory "$handler" "$argument" "${1-}")" || return $?
;;
--target)
shift
target="$(usageArgumentFileDirectory "$handler" "$argument" "${1-}")" || return $?
;;
*)
# _IDENTICAL_ argumentUnknownHandler 1
throwArgument "$handler" "unknown #$__index/$__count \"$argument\" ($(decorate each code -- "${__saved[@]}"))" || return $?
;;
esac
shift
done
local start
# IDENTICAL startBeginTiming 1
start=$(timingStart) || return $?
# Load MANPATH environment
export MANPATH
catchReturn "$handler" buildEnvironmentLoad MANPATH || return $?
! $easyFlag || catchEnvironment "$handler" decorate pair "$width" "$name: Easy mode enabled" || return $?
! $easyFlag || catchEnvironment "$handler" decorate pair "path" "$path" || return $?
! $easyFlag || catchEnvironment "$handler" decorate pair "target" "$target" || return $?
# Trouble debugging
whichExists library-which-should-be-there || throwEnvironment "$handler" "missing thing" || return $?
# DEBUG LINE
printf -- "%s:%s %s\n" "$(decorate code "${BASH_SOURCE[0]}")" "$(decorate magenta "$LINENO")" "$(decorate each code "$@")" # DEBUG LINE
timingReport "$start" "Completed in"
# LOG ALL CALLS TO A FUNCTION
# TO DO REMOVE THIS LATER
printf "%s\n" "-- CALLED ARGS:" "$@" "--STACK" "$(debuggingStack)" >>"${BUILD_HOME-}/.${FUNCNAME[0]}.log"
}
_exampleFunction() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
# IDENTICAL __source 21
# Load a source file and run a command
# Argument: source - Required. File. Path to source relative to application root..
# Argument: relativeHome - Optional. Directory. Path to application root. Defaults to `..`
# Argument: command ... - Optional. Callable. A command to run and optional arguments.
# Requires: returnMessage
# Security: source
# Return Code: 253 - source failed to load (internal error)
# Return Code: 0 - source loaded (and command succeeded)
# Return Code: ? - All other codes are returned by the command itself
__source() {
local here="${BASH_SOURCE[0]%/*}" e=253
local source="$here/${2:-".."}/${1-}" && shift 2 || returnMessage $e "missing source" || return $?
[ -d "${source%/*}" ] || returnMessage $e "${source%/*} is not a directory" || return $?
[ -f "$source" ] && [ -x "$source" ] || returnMessage $e "$source not an executable file" "$@" || return $?
local a=("$@") && set --
# shellcheck source=/dev/null
source "$source" || returnMessage $e source "$source" "$@" || return $?
[ ${#a[@]} -gt 0 ] || return 0
"${a[@]}" || return $?
}
# IDENTICAL __tools 8
# Load build tools and run command
# Argument: relativeHome - Required. Directory. Path to application root.
# Argument: command ... - Optional. Callable. A command to run and optional arguments.
# Requires: __source
__tools() {
__source bin/build/tools.sh "$@"
}
# IDENTICAL returnMessage 39
# Return passed in integer return code and output message to `stderr` (non-zero) or `stdout` (zero)
# Argument: exitCode - Required. UnsignedInteger. Exit code to return. Default is 1.
# Argument: message ... - Optional. String. Message to output
# Return Code: exitCode
# Requires: isUnsignedInteger printf returnMessage
returnMessage() {
local handler="_${FUNCNAME[0]}"
local to=1 icon="✅" code="${1:-1}" && shift 2>/dev/null
if [ "$code" = "--help" ]; then "$handler" 0 && return; fi
isUnsignedInteger "$code" || returnMessage 2 "${FUNCNAME[1]-none}:${BASH_LINENO[1]-} -> ${handler#_} non-integer \"$code\"" "$@" || return $?
if [ "$code" -gt 0 ]; then icon="❌ [$code]" && to=2; fi
printf -- "%s %s\n" "$icon" "${*-§}" 1>&"$to"
return "$code"
}
_returnMessage() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
# Test if an argument is an unsigned integer
# Source: https://stackoverflow.com/questions/806906/how-do-i-test-if-a-variable-is-a-number-in-bash
# Credits: F. Hauri - Give Up GitHub (isnum_Case)
# Original: is_uint
# Argument: value - EmptyString. Value to test if it is an unsigned integer.
# Usage: {fn} argument ...
# Return Code: 0 - if it is an unsigned integer
# Return Code: 1 - if it is not an unsigned integer
# Requires: returnMessage
isUnsignedInteger() {
[ $# -eq 1 ] || returnMessage 2 "Single argument only: $*" || return $?
case "${1#+}" in --help) usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]}" 0 ;; '' | *[!0-9]*) return 1 ;; esac
}
_isUnsignedInteger() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
# <-- END of IDENTICAL returnMessage
__tools ../.. exampleFunction "$@"
#
# How to load arguments until -- found
#
__testFunction() {
local exceptions=()
# Load variables until "--" is found
while [ $# -gt 0 ]; do [ "$1" = "--" ] && shift && break || exceptions+=("$1") && shift; done
printf "%s\n" "${exceptions[@]+"${exceptions[@]}"}"
}
# Post-commit hook code
#
# The `git-post-commit` hook will be installed as a `git` post-commit hook in your project and will
# overwrite any existing `post-commit` hook.
#
# Merges `main` and `staging` and pushes to `origin`
#
# fn: {base}
__hookGitPostCommit() {
local handler="_${FUNCNAME[0]}"
# _IDENTICAL_ argumentNonBlankLoopHandler 6
local __saved=("$@") __count=$#
while [ $# -gt 0 ]; do
local argument="$1" __index=$((__count - $# + 1))
# __IDENTICAL__ __checkBlankArgumentHandler 1
[ -n "$argument" ] || throwArgument "$handler" "blank #$__index/$__count ($(decorate each quote -- "${__saved[@]}"))" || return $?
case "$argument" in
# _IDENTICAL_ helpHandler 1
--help) "$handler" 0 && return $? || return $? ;;
*)
# _IDENTICAL_ argumentUnknownHandler 1
throwArgument "$handler" "unknown #$__index/$__count \"$argument\" ($(decorate each code -- "${__saved[@]}"))" || return $?
;;
esac
shift
done
timingReport "$start" "Completed in"
catchEnvironment "$handler" gitInstallHook post-commit || return $?
catchEnvironment "$handler" gitMainly || return $?
catchEnvironment "$handler" git push origin || return $?
}
___hookGitPostCommit() {
# __IDENTICAL__ usageDocument 1
usageDocument "${BASH_SOURCE[0]}" "${FUNCNAME[0]#_}" "$@"
}
# __tools ../.. __hookGitPostCommit "$@"