GitHub Copilot for Bash and Shell Scripts

GitHub Copilot Programming LanguagesAcademy lesson 37Cluster 3 · Lesson 12 of 13Intermediate → Advanced13 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for Bash and Shell ScriptsGitHub Copilot Programming Languages12Intermediate → Advanced/github-copilot/languages/bash/

Every other language in this cluster gives you something between you and a mistake. A compiler, a type checker, an exception, a failing test, a transaction you can roll back.

The shell gives you none of that. A wrong line runs immediately, with your privileges, against whatever is actually there. There is no undo, no dry run unless the specific tool provides one, and no type system to notice that a variable you thought held a path is empty.

That is the entire argument of this lesson. Generated shell is not more likely to be wrong than generated Python — it is arguably less likely, because shell scripts are short. It is that the cost of being wrong is categorically different, and the compensating control has to be process rather than tooling.

Quoting is the whole language

Almost everything that goes wrong in generated shell traces back to expansion.

rm -rf $BUILD_DIR/*

If BUILD_DIR is /tmp/build out, that becomes three arguments and removes /tmp/build and everything in the current directory matching out/*. If BUILD_DIR is unset, it becomes rm -rf /*.

rm -rf "${BUILD_DIR:?BUILD_DIR must be set}"/*

Quoted, so a space is a space; and :? makes an unset variable an immediate error with a message rather than an empty string.

The rules worth stating in a prompt, because generated shell follows the corpus and the corpus is inconsistent:

  • Quote every expansion. "$var", "${array[@]}", "$(command)".
  • Use ${var:?message} for anything whose absence should stop the script.
  • Use -- before user-controlled arguments so a value beginning with - is not read as an option.
  • Prefer arrays to space-separated strings for lists of arguments. A string of arguments re-split by the shell is where quoting bugs become command execution.

ShellCheck finds all of these mechanically, which is why it belongs in the loop rather than in your memory.

set -euo pipefail, with the nuance

The standard safety preamble, and worth understanding rather than pasting.

set -e exits on an unhandled non-zero status. Its edges are real: it does not apply inside a condition, inside a && or || chain, or to the left side of a pipe; and — the one that bites — a function whose last command returns non-zero returns non-zero, which under set -e kills the script.

That is not hypothetical. The health-check script below originally contained:

awk -v l="$load" -v n="$limit" 'BEGIN { exit !(l > n) }' &&
  warn "load ${load} exceeds ${limit}"

When load was below the limit — the normal case — awk exited 1, the && chain evaluated to false, that was the function’s last command, so the function returned 1, and set -e terminated the script silently after four lines of output. The fix is an explicit if:

if awk -v l="$load" -v n="$limit" 'BEGIN { exit !(l > n) }'; then
  warn "load ${load} exceeds ${limit}"
fi

set -u errors on an unset variable. Almost always right. The edge: "${arr[@]}" on an empty array errored under older Bash versions, so scripts that must run on old shells guard it.

set -o pipefail makes a pipeline fail if any stage fails, not just the last. Without it, curl -f url | tee out succeeds when curl fails.

Practical project: a server health check

Practical example

A read-only host health check, from a realistic first draft to a clean script

Show ShellCheck finding real defects in a plausible suggestion, and the two set -e and subshell bugs that only appeared when the corrected script was actually run.

Status
Tested implementation
Runtime
GNU bash 5.2.21, ShellCheck 0.11.0, Ubuntu
Command
shellcheck healthcheck.sh && bash -n healthcheck.sh && ./healthcheck.sh -d 99 -l 8 out
Result
ShellCheck: no findings. Three runs produced exit 0, exit 1 with a disk warning, and exit 1 with an inactive-service warning, as intended.
Run on
August 21, 2026

The first draft

This is the shape a plausible suggestion arrives in — not a strawman, just unreviewed:

#!/bin/bash
set -e

THRESHOLD=90
REPORT_DIR=$1

mkdir -p $REPORT_DIR

DISK=`df -h / | awk 'NR==2 {print $5}' | tr -d '%'`
LOAD=$(cat /proc/loadavg | cut -d' ' -f1)

if [ $DISK -gt $THRESHOLD ]; then
  echo "disk high: $DISK%"
fi

for service in $SERVICES; do
  systemctl is-active $service > $REPORT_DIR/$service.status
done

What ShellCheck said

Default settings, seven findings:

line  8: SC2086 Double quote to prevent globbing and word splitting.
line 10: SC2006 Use $(...) notation instead of legacy backticks.
line 14: SC2086 Double quote to prevent globbing and word splitting.
line 19: SC2086 (x3) Double quote to prevent globbing and word splitting.
line 22: SC2086 Double quote to prevent globbing and word splitting.

With --enable=all, twenty-seven — and among them the one that actually matters:

line 18: SC2154 (warning) SERVICES is referenced but not assigned.

SERVICES is never set. Under set -e alone the loop simply does not execute, so the script “works” and silently checks no services at all. That is precisely the failure this lesson is about: it runs, it produces output, and it does not do what it says.

The corrected script

#!/usr/bin/env bash
#
# Read-only host health check. Writes a report; changes nothing else.
#
# Usage: healthcheck.sh [-d DISK_PCT] [-l LOAD_PER_CPU] REPORT_DIR [SERVICE...]

set -euo pipefail

readonly DEFAULT_DISK_PCT=90
readonly DEFAULT_LOAD_PER_CPU=2

disk_pct=$DEFAULT_DISK_PCT
load_per_cpu=$DEFAULT_LOAD_PER_CPU

usage() {
  printf 'Usage: %s [-d DISK_PCT] [-l LOAD_PER_CPU] REPORT_DIR [SERVICE...]\n' \
    "${0##*/}" >&2
  exit 64
}

while getopts ':d:l:' opt; do
  case "$opt" in
    d) disk_pct=$OPTARG ;;
    l) load_per_cpu=$OPTARG ;;
    *) usage ;;
  esac
done
shift "$((OPTIND - 1))"

(($# >= 1)) || usage

report_dir=$1
shift
services=("$@")

# The directory is created, never cleared. A health check that can delete
# things is a deployment script wearing a disguise.
mkdir -p -- "$report_dir"

warnings=0

warn() {
  printf 'WARN  %s\n' "$1"
  warnings=$((warnings + 1))
}

check_disk() {
  local used
  used=$(df -P / | awk 'NR == 2 { print $5 }' | tr -d '%')
  printf 'disk_root_used_pct %s\n' "$used"
  ((used < disk_pct)) || warn "root filesystem is ${used}% full (threshold ${disk_pct}%)"
}

check_load() {
  local load cpus limit
  read -r load _ < /proc/loadavg
  cpus=$(nproc)
  limit=$((cpus * load_per_cpu))
  printf 'load_1min %s\ncpu_count %s\n' "$load" "$cpus"
  # Written as an `if`, not `awk ... && warn`: under `set -e` a trailing `&&`
  # whose left side is false makes the *function* return non-zero and kills
  # the script. That is not hypothetical — it is what the first version did.
  if awk -v l="$load" -v n="$limit" 'BEGIN { exit !(l > n) }'; then
    warn "1-minute load ${load} exceeds ${limit} (${load_per_cpu} per CPU)"
  fi
}

check_services() {
  local service state
  for service in "${services[@]}"; do
    state=$(systemctl is-active -- "$service" 2>/dev/null || true)
    printf 'service %s %s\n' "$service" "${state:-unknown}"
    [[ $state == active ]] || warn "service ${service} is ${state:-unknown}"
  done
}

# Deliberately not `{ ...checks... } | tee`. Each side of a pipeline runs in a
# subshell, so `warnings` incremented inside the braces would be discarded and
# the summary would always report zero. Redirect to the file, then cat it.
{
  printf 'host %s\n' "$(hostname)"
  check_load
  check_disk
  ((${#services[@]} == 0)) || check_services
} > "$report_dir/health.txt"

printf 'warnings %d\n' "$warnings" >> "$report_dir/health.txt"
cat -- "$report_dir/health.txt"

exit $((warnings > 0 ? 1 : 0))

Observed output

Three runs, all with the report directory under a scratch path:

$ ./healthcheck.sh -d 99 -l 8 out          # exit 0
host www
load_1min 0.37
cpu_count 8
memory_used_pct 5
disk_root_used_pct 3
warnings 0

$ ./healthcheck.sh -d 1 out                # exit 1
...
WARN  root filesystem is 3% full (threshold 1%)
warnings 1

$ ./healthcheck.sh -d 99 -l 8 out definitely-not-a-real-service   # exit 1
...
service definitely-not-a-real-service inactive
WARN  service definitely-not-a-real-service is inactive
warnings 1

The two bugs that only running found

Worth stating plainly, because both survived reading and both survived ShellCheck.

The set -e and && interaction described above. The script exited silently after four lines. ShellCheck said nothing; there is nothing syntactically wrong with it.

The pipeline subshell. The original ended with { ...checks... } | tee "$report_dir/health.txt". Each side of a pipeline runs in its own subshell, so every warnings=$((warnings + 1)) inside the braces incremented a copy that was then discarded. The summary would have reported warnings 0 forever, and the exit code would always have been 0 — a health check that can never fail.

That is the argument for the last step of the review gate below. Reading is necessary and it is not sufficient.

Core shell constructs

The constructs that appear in every generated script, and what to check in each.

Variables. Shell variables are strings. count=5 and count="5" are the same thing, and count + 1 is a string, not arithmetic. Use $((count + 1)) or ((count++)). Generated code usually gets this right and occasionally reaches for expr, which is a relic. Assignment takes no spaces around =, and a suggestion writing x = 5 produces “command not found: x”, which is at least a loud failure.

Arrays. Bash arrays are the correct structure for a list of arguments, and generated code under-uses them because so much public shell predates the habit. "${arr[@]}" expands to one word per element and is what you almost always want; "${arr[*]}" joins them into a single word and is almost never what you want. Declare with arr=(a b c) and append with arr+=(d). An empty array expanded under set -u was an error on older Bash, which is why defensive scripts guard it.

Functions. Declare locals with local, or every variable is global and a function quietly clobbers its caller’s state. Generated functions frequently omit local, and the resulting bug is a variable that holds the wrong value several functions away. Functions return an exit status, not a value; the idiom for returning data is to printf it and capture with $(...), which means a stray debug echo inside the function corrupts the return value.

Loops. for f in *.log iterates filenames safely; for f in $(ls *.log) splits on whitespace and breaks on any filename with a space. The corpus contains a great deal of the second form. For reading a file line by line, while IFS= read -r line is the correct incantation — without IFS= leading whitespace is stripped, and without -r backslashes are interpreted.

Exit status. Every command sets $?. if command; then tests it directly, which is cleaner than capturing and comparing. The trap to know: $? refers to the last command, so a printf between the command and the check destroys the value you wanted.

Pipes and redirection. 2>&1 order matters — > file 2>&1 sends both streams to the file, while 2>&1 > file sends stderr to the original stdout and only stdout to the file. Generated scripts get this wrong in both directions. Process substitution, < <(command), avoids the subshell problem that the worked example above ran into, and is a Bash feature rather than a POSIX one.

Traps. trap 'cleanup' EXIT is the shell’s version of RAII, and generated scripts rarely include one. Any script that creates a temporary directory, takes a lock or starts a background process should clean up on exit, including on interrupt.

Testing shell scripts

Shell is the least-tested language in this cluster, and mostly for bad reasons. Scripts that manage production deserve tests at least as much as application code does.

bats-core is the mainstream test framework. Copilot writes it competently when asked, and will not produce it unprompted. The structure that makes a script testable is the same one that makes any code testable: put the logic in functions, guard the entry point so the file can be sourced without running, and keep the side effects at the edges.

# At the bottom of the script: run main only when executed, not when sourced.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  main "$@"
fi

That one conditional is what lets a test file source the script and call individual functions. Ask for it explicitly.

Test the parsing and the decisions, not the system calls. A health check’s threshold comparison, its argument parsing and its exit-code logic are all pure and cheap to test. Whether df works is not your script’s concern.

bash -n parses without executing, which catches syntax errors in branches your tests never reach — a genuine risk in shell, where an error-handling branch may not run for months.

shellcheck in CI is the single highest-value addition. It is fast enough that there is no argument for running it only locally.

CI/CD and remote operations

Most generated shell ends up in a pipeline or on a remote host, and both contexts add failure modes the script itself cannot see.

In CI, the shell is not the one you tested with. A GitHub Actions run: block uses bash -e on Linux by default but not -u or -o pipefail, and a step that looks like your local script may behave differently. Setting the shell explicitly in the workflow removes the ambiguity.

Secrets in CI are environment variables, and environment variables leak. A generated step that echoes its configuration for debugging will print them into a log that is retained and often publicly visible. Anything that prints env or set -x around a secret is a disclosure.

set -x is a debugging tool, not a logging strategy. It expands and prints every command, including the ones containing credentials.

Remote execution loses one level of quoting. ssh host "rm -rf $DIR" expands $DIR locally, sends the result as text, and the remote shell re-parses it. Two shells means two chances for word splitting. If the value could contain anything unexpected, pass it as an argument to a script on the far side rather than interpolating it into a command string.

Idempotence matters more than in a local script. A pipeline step reruns after a transient failure. A script that appends to a file, creates a resource without checking, or increments a counter will do it twice. Generated automation is written as though it runs once.

Fail loudly and early. A CI script that swallows an error and exits 0 turns a broken deploy into a green build, which is worse than a failure. Every || true in generated automation should have a comment explaining why the failure is acceptable.

Safety rules for generated shell

The commands worth stopping on, every time one appears in a suggestion:

  • rm, especially with -r or -f. Check the path is quoted, is not derived from an unvalidated variable, and cannot be empty.
  • Glob expansion in a destructive command. rm -rf "$dir"/* behaves very differently from rm -rf "$dir/*".
  • sudo. Every elevated command is a separate decision.
  • chmod and chown, especially recursive. chmod -R 777 appears in public code as a fix for permission problems and is never the right answer.
  • Package removal, apt purge, dnf remove, and anything with --autoremove.
  • Disk tools. dd, mkfs, fdisk, parted. A wrong device name is unrecoverable.
  • Firewall commands. iptables -F and ufw reset on a remote host lock you out.
  • Piping a remote script into a shell. curl ... | bash executes whatever the server returns, at that moment, unreviewed. Download, read, then run.
  • Command substitution in an argument list. $(...) producing something that is then word-split.
  • Unquoted variables anywhere near a destructive command.
The review gate for generated shell
  1. Read every commandHuman judgementIncluding the ones you recognise. Especially the one-liners pasted from chat.
  2. Run ShellCheckSeven findings on a plausible first draft, in under a second.
  3. Check every expansion is quotedHuman judgementAnd that anything required uses ${var:?message} rather than defaulting to empty.
  4. Dry-run where the tool supports itrsync --dry-run, terraform plan, kubectl --dry-run=client, apt --simulate, rm replaced with echo.
  5. Run it in a throwaway environmentA container, a VM, or a scratch directory. This is the step that finds the bugs reading cannot.
  6. Only then run it for realHuman judgementOn one host before all of them, if the script touches more than one.

Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.

Debugging and refactoring shell

Shell debugging is unusually mechanical, and Copilot helps most with the reading rather than the running.

bash -x is the primary tool. It prints each command after expansion, which is exactly the information you need — the bug is almost always in what a variable expanded to rather than in what you wrote. Setting PS4='+ ${BASH_SOURCE}:${LINENO}: ' before it adds the file and line to every trace line, which turns an unreadable wall of output into something you can navigate. The caveat from the CI section applies: a trace prints secrets, so it belongs in a terminal, not in a log.

Pasting a trace into chat works well. The output is dense, repetitive and precise — a good match for a reading task. “Here is the bash -x output and here is the script; which expansion is wrong?” is a question that gets answered accurately far more often than a description of the symptom does.

Explaining an unfamiliar script is the other high-value use. Shell accumulates: a deployment script that has been edited by six people over four years contains constructs nobody currently on the team wrote. Asking for a line-by-line explanation before changing it is faster than reconstructing the intent, and safer than assuming.

Refactoring shell is genuinely risky and worth being conservative about. There is no type checker, no test suite by default, and the blast radius is production. Two rules make it survivable: add a test before the refactor, not after — even a single bats case that pins current behaviour is enough to catch a regression — and change one thing at a time, verifying in a container between steps. A generated rewrite that restructures a working script wholesale is the single riskiest suggestion in this cluster, and the right response is usually to take the improvements piecemeal rather than to accept the rewrite.

Bash versus POSIX sh

Worth naming because it is a common source of confusion in generated scripts.

#!/bin/sh on many systems is not Bash. Arrays, [[ ]], local, ${var,,} and process substitution are Bash features that fail under dash — often with a syntax error, sometimes with silently different behaviour.

Say which you want. If the script needs Bash, use #!/usr/bin/env bash and mean it. If it must be portable, say “POSIX sh, no bashisms” in the prompt and run ShellCheck with -s sh, which will then flag them.

Shell-specific risks

Unquoted expansion. The headline risk.

Word splitting on filenames. Spaces, newlines and leading dashes in filenames all break naive loops. find -print0 | xargs -0 or find -exec handles them; for f in $(ls) does not, and appears throughout the corpus.

Exit status ignored. A command that fails mid-script while the script continues. set -e plus explicit checks on anything in a condition or a pipe.

Injection. Any input reaching eval, a $(...), or an unquoted variable in a command position.

Race conditions in temporary files. mktemp rather than a predictable path in /tmp.

Locale and tool differences. GNU and BSD versions of sed, date and grep take different flags. A script that works on Linux fails on macOS, and generated code does not know which you are on.

Best practices

  • Read every generated command before it reaches a shell. This is the whole lesson in one line.
  • Run ShellCheck in CI and in your editor, not occasionally.
  • Quote every expansion; use ${var:?message} for required values.
  • Prefer arrays over space-separated argument strings.
  • Use if rather than && for conditional side effects under set -e.
  • Never pipe a remote URL into a shell.
  • Test in a container before touching a real host.

Common mistakes

  • Trusting a one-liner because it is short.
  • Pasting an install command from chat straight into a terminal.
  • Assuming set -e catches everything.
  • Incrementing a variable inside a pipeline and reading it afterwards.
  • Writing #!/bin/sh and using Bash features.

Where to go next

GitHub Copilot for Python is often the right answer once a shell script exceeds a page. GitHub Copilot for SQL is the other lesson in this cluster about a language where a single wrong statement is irreversible. Both, with this one, are the direct preparation for the DevOps and infrastructure cluster that follows.

Sources

Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.

Primary sources