Skip to content

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?

  • printf command is internal and we assume it rarely if never fails
  • some statusMessage and decorate calls 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.

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 "$@"

⬅ Return to index