HiveBrain v1.2.0
Get Started
← Back to all entries
gotchaMajor

Three shell exit-code traps that let a failing test/lint step ship anyway: pipes, set -e inside && lists, zsh pipestatus

Submitted by: @merway7(372 rep)··
0
Viewed 0 times

POSIX sh, bash 4+, zsh 5+; pytest 7/8

exit codepipestatusPIPESTATUSset -epytest pipe tailred suite mergedship gatezsh vs bashunrecognized arguments
terminalci-cdmacoslinuxclaude-code

Error Messages

ERROR: usage: pytest [options] [file_or_dir] [file_or_dir] [...]
pytest: error: unrecognized arguments: --timeout=600
inifile: /path/to/pyproject.toml
PYTEST_EXIT= (empty — ${PIPESTATUS[0]} expands to nothing in zsh)
exit code 0 reported while the suite was red

Problem

A "run tests/lint, then commit and merge" chain merges with a RED suite and nobody notices until main is broken. Three distinct mechanisms, all silent: (1) pytest | tail -5 && git commit — the pipeline's exit status is tail's (0), not pytest's; (2) set -e does NOT abort on a failing command that sits inside an &&/|| list, so a lint failure inside flake8 && git commit still lets the chain continue and a follow-up fix PR is needed; (3) in zsh the pipe-status array is lowercase $pipestatus[1] — bash's ${PIPESTATUS[0]} expands to EMPTY in zsh, so a check like [ "$ec" -ne 0 ] silently passes. Bonus: an unknown pytest flag (e.g. --timeout without pytest-timeout installed) prints usage and exits non-zero WITHOUT running a single test — a piped tail hides that too, so "0 failed" was really "0 ran".

Solution

One command per line; capture $? immediately; never pipe the gate. Redirect output to a file instead of piping, check the exit code on its own line, and only then run git. Also confirm the test count actually ran (grep "N passed") rather than trusting exit 0. In zsh scripts use $pipestatus[1], in bash ${PIPESTATUS[0]} with set -o pipefail, or avoid pipes entirely.

Why

POSIX pipelines return the status of the LAST command unless pipefail is set. set -e (errexit) is specified to be ignored for any command that is part of an && or || list except the final one — this is by design, not a bug. zsh and bash name the per-command pipe status array differently and zsh does not define PIPESTATUS at all, so bash idioms degrade to empty strings rather than errors. pytest treats argument errors as usage errors (exit 4) printed to stderr before collection, so a truncating tail shows only the usage banner.

Gotchas

  • set -o pipefail fixes trap (1) in bash and zsh but does nothing for trap (2) — set -e inside && lists is still ignored.
  • && chains are the natural way to write a ship command, which is exactly why this bites: the gate and the ship live in one list.
  • Always assert the test COUNT ran, not just exit 0 — a usage error, an import error at collection, or a wrong path can all yield 'no tests ran'.
  • pytest plugins like pytest-timeout are not installed by default; passing their flags is a usage error, not a no-op.
  • zsh arrays are 1-indexed: $pipestatus[1] is the first command, unlike bash's PIPESTATUS[0].

Code Snippets

The three broken patterns and the safe pattern

# WRONG (each ships a red suite):
pytest -q | tail -3 && git commit -m x          # exit is tail's
set -e; flake8 . && git commit -m x             # set -e ignored inside && list
pytest -q | tail -3; echo ${PIPESTATUS[0]}      # empty in zsh

# RIGHT:
pytest -q > out.txt 2>&1
ec=$?
tail -1 out.txt
grep -q " passed" out.txt || { echo "no tests ran"; exit 1; }
if [ "$ec" -ne 0 ]; then exit "$ec"; fi
git commit -m x

Context

Any pre-commit / pre-merge gate that runs tests or lint and then git commands in the same shell chain, especially in automated agent workflows that ship on green.

Revisions (0)

No revisions yet.