← All posts

The Glob That Ate the Rest of My Shell Init

A bare zsh glob over credential files aborted the entire sourced file on any box with zero matches — silently unloading every function defined after it.

  • zsh
  • shell
  • claude-code
  • debugging
  • automation

I was refactoring how our Claude Code fleet finds its accounts. Instead of a hardcoded list, each machine would discover its own credential files off disk — a loop over credentials-*.json in a shell-init.sh that every box sources at login. That file is where all the useful stuff lives: the functions that rotate between accounts when one hits its rate limit (the per-account cap on how much you can run in a window), the aliases that launch agent sessions, the helpers that check who’s still got budget left.

The change looked obviously correct. It passed shellcheck — a linter that reads your script and flags mistakes. It worked on my machine. So I opened the PR.

The auto-review pass caught what I didn’t: on some freshly-provisioned boxes, there were zero credential files yet. And on those boxes, my loop didn’t just skip — it detonated the whole file.

Here’s the part that got me. In zsh, if you write for f in "$BASE"/credentials-*.json, and nothing matches that pattern, zsh doesn’t hand you an empty list. It errors right there, at the moment it tries to expand the glob — before the loop body ever runs. Its default is NOMATCH: no match is a failure, full stop. And because shell-init.sh is sourced (run inline into your shell, not as a separate program), that error aborts the source. Every function, every alias defined after that line just… never loads. No crash. No message. You log into a box and half your tooling is quietly gone.

I had even written the defensive check people always reach for:

for f in "$BASE"/credentials-*.json; do
  [[ -f "$f" ]] || continue
  ...
done

Useless here. That continue guards the body. But zsh never reaches the body — it dies at the glob one step earlier. The guard was locking a door the intruder walks past.

Why did it work on my laptop? Because I had credential files, so the glob matched. I’d only ever tested the happy path. And why does the same code look fine in bash? Because bash, when a glob matches nothing, leaves the literal string credentials-*.json sitting there untouched — so the -f check catches it and quietly moves on. Bash hides the bug. Test only in bash and you’ll never see it.

The fix is one line: setopt local_options null_glob in zsh (shopt -s nullglob in bash), which makes a zero-match glob expand to nothing instead of exploding.

Testing the zero-match case, not just syntax give me the detail

The real lesson isn’t the setopt — it’s that shellcheck proves syntax, not behavior. A lint pass would never catch this. You have to source the file into a real subshell against an empty fixture:

tmpdir=$(mktemp -d)              # zero credential files
BASE="$tmpdir" zsh -c '
  source ./shell-init.sh
  typeset -f rotate_account >/dev/null || { echo "FAIL: functions did not load"; exit 1 }
  echo OK
'

Run that in CI and the empty-directory case fails loudly instead of shipping silent. Point it at both zsh -c and bash -c — the whole trap is that one interpreter forgives what the other punishes.

The generalizable move: whenever a loop’s input can legitimately be empty, that empty case is a real code path, and it deserves a real test — not the version where you happened to have data lying around. Provision an empty box on purpose. The bugs that only appear on brand-new machines are the ones nobody’s awake to see.