diff --git a/.changeset/issue-156-errexit-false-documentation.md b/.changeset/issue-156-errexit-false-documentation.md new file mode 100644 index 0000000..f86b6e4 --- /dev/null +++ b/.changeset/issue-156-errexit-false-documentation.md @@ -0,0 +1,20 @@ +--- +'command-stream': patch +--- + +Document try/catch anti-pattern with errexit=false default (issue #156) + +- Add js/docs/case-studies/issue-156/README.md with comprehensive case study including: + - Reconstructed timeline and sequence of events from calculator#78 silent bug + - Root cause analysis with code evidence from command-stream source + - Bash vs command-stream behavior comparison table + - Full configuration API documentation (shell.errexit(), set(), unset()) + - Recommended patterns for mixed strict/optional error handling + - Comparison with similar libraries (execa, zx, bash, child_process) + - Proposed solutions ranked by impact +- Add Pitfall #7 to js/BEST-PRACTICES.md: try/catch anti-pattern with errexit=false, with examples and correct fix patterns +- Add 4 reproducible experiment scripts in experiments/issue-156/: + - 01-default-behavior.mjs — demonstrates default errexit=false behavior + - 02-errexit-enabled.mjs — demonstrates shell.errexit(true) configuration + - 03-bash-comparison.sh — bash set -e reference comparison + - 04-calculator-bug-repro.mjs — exact reproduction of calculator#78 bug diff --git a/experiments/issue-156/01-default-behavior.mjs b/experiments/issue-156/01-default-behavior.mjs new file mode 100644 index 0000000..6bbeb6c --- /dev/null +++ b/experiments/issue-156/01-default-behavior.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +/** + * Experiment 1: Default errexit behavior in command-stream + * + * Demonstrates that command-stream defaults to errexit=false (like bash without set -e), + * meaning failed commands do NOT throw exceptions by default. + * + * Related: https://github.com/link-foundation/command-stream/issues/156 + */ + +import { $, shell } from '../../js/src/$.mjs'; + +console.log('=== Experiment 1: Default errexit behavior ===\n'); + +// 1. Verify default settings +const settings = shell.settings(); +console.log('Default shell settings:', settings); +console.log('errexit is:', settings.errexit, '(should be false)\n'); + +// 2. Run a failing command — should NOT throw +console.log( + '--- Test: running "false" (exit code 1) with default settings ---' +); +try { + const result = await $`false`; + console.log('✅ No exception thrown (errexit=false default)'); + console.log(' result.code:', result.code); + console.log(' Script continued after failure\n'); +} catch (err) { + console.log('❌ Unexpected exception thrown:', err.message); +} + +// 3. Demonstrate the anti-pattern from calculator issue #78 +console.log('--- Anti-pattern: try/catch for exit code detection ---'); +console.log('This pattern FAILS silently because errexit=false:'); +let catchReached = false; +try { + await $`false`; // exit code 1 — does NOT throw + console.log('❌ "No changes to commit" — always prints (BUG)'); +} catch { + catchReached = true; + console.log('✅ catch block reached (only with errexit=true)'); +} +if (!catchReached) { + console.log(' Catch block was NEVER reached — this is the bug!\n'); +} + +// 4. The CORRECT pattern: explicit exit code check +console.log('--- Correct pattern: explicit exit code check ---'); +const result = await $`false`; +if (result.code !== 0) { + console.log('✅ Correctly detected non-zero exit code:', result.code); +} else { + console.log('No changes to commit (would only print on code 0)'); +} +console.log(); + +// 5. Demonstrate that multiple commands all execute despite failures +console.log('--- Multiple commands with failures (errexit=false) ---'); +const r1 = await $`false`; +const r2 = await $`echo "still running"`; +const r3 = await $`false`; +const r4 = await $`echo "also running"`; +console.log( + 'All 4 commands executed. Exit codes:', + r1.code, + r2.code, + r3.code, + r4.code +); +console.log('(bash without set -e behaves the same way)\n'); diff --git a/experiments/issue-156/02-errexit-enabled.mjs b/experiments/issue-156/02-errexit-enabled.mjs new file mode 100644 index 0000000..01f6061 --- /dev/null +++ b/experiments/issue-156/02-errexit-enabled.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env bun +/** + * Experiment 2: Enabling errexit (set -e equivalent) + * + * Demonstrates how shell.errexit(true) changes behavior to throw exceptions + * on non-zero exit codes — like bash with "set -e". + * + * Related: https://github.com/link-foundation/command-stream/issues/156 + */ + +import { $, shell, set, unset } from '../../js/src/$.mjs'; + +console.log('=== Experiment 2: Enabling errexit ===\n'); + +// Reset to defaults first +shell.errexit(false); + +// 1. Method 1: shell.errexit(true) +console.log('--- Method 1: shell.errexit(true) ---'); +shell.errexit(true); +console.log('Settings after shell.errexit(true):', shell.settings()); + +try { + await $`false`; + console.log('❌ Should not reach here'); +} catch (err) { + console.log('✅ Exception thrown as expected'); + console.log(' err.message:', err.message); + console.log(' err.code:', err.code); + console.log(' err.stdout:', JSON.stringify(err.stdout)); + console.log(' err.stderr:', JSON.stringify(err.stderr)); + console.log(' err.result:', err.result); +} + +// Reset +shell.errexit(false); +console.log(); + +// 2. Method 2: set('e') +console.log('--- Method 2: set("e") ---'); +set('e'); +console.log('Settings after set("e"):', shell.settings()); + +try { + await $`false`; + console.log('❌ Should not reach here'); +} catch (err) { + console.log('✅ Exception thrown via set("e")'); + console.log(' exit code:', err.code); +} + +// Disable +unset('e'); +console.log('Settings after unset("e"):', shell.settings()); +console.log(); + +// 3. Method 3: Full option name +console.log('--- Method 3: set("errexit") ---'); +set('errexit'); +console.log('Settings after set("errexit"):', shell.settings()); +try { + await $`false`; +} catch (err) { + console.log('✅ Exception thrown via set("errexit"):', err.code); +} +unset('errexit'); +console.log(); + +// 4. Demonstrate the try/catch WORKS with errexit=true +console.log('--- try/catch WORKS with errexit=true ---'); +shell.errexit(true); + +try { + // This is the git diff equivalent from calculator issue #78 + await $`false`; // exit code 1 = changes present + console.log('No changes — exit code was 0'); +} catch (err) { + if (err.code === 1) { + console.log( + '✅ Changes detected (exit code 1) — catch block correctly reached' + ); + console.log( + ' This is the CORRECT way to use try/catch with command-stream' + ); + } +} + +shell.errexit(false); +console.log(); + +// 5. Mixed mode: strict for critical ops, relaxed for detection +console.log('--- Mixed mode: strict/relaxed pattern ---'); +shell.errexit(true); // Start strict + +// Temporarily relax for a detection command +shell.errexit(false); +const diff = await $`false`; // Simulates: git diff --cached --quiet +shell.errexit(true); // Back to strict + +if (diff.code !== 0) { + console.log( + '✅ Changes detected via explicit code check (code:', + `${diff.code})` + ); + console.log(' Proceeding with commit...'); + // await $`git commit -m "Release"`; // Would proceed here +} else { + console.log('No changes to commit'); +} + +shell.errexit(false); +console.log('\n=== All experiments completed ==='); diff --git a/experiments/issue-156/03-bash-comparison.sh b/experiments/issue-156/03-bash-comparison.sh new file mode 100644 index 0000000..b5709f6 --- /dev/null +++ b/experiments/issue-156/03-bash-comparison.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Experiment 3: Bash behavior comparison +# +# Shows how bash behaves by default and with set -e, +# for comparison with command-stream's errexit setting. +# +# Related: https://github.com/link-foundation/command-stream/issues/156 + +echo "=== Experiment 3: Bash behavior comparison ===" +echo "" + +# 1. Default bash (no set -e) — continues on error +echo "--- Default bash (no set -e): continues after error ---" +false +echo "✅ Continued after 'false' (exit code was $?)" +echo "" + +# 2. bash with set -e — exits immediately +echo "--- Bash with set -e: exits on first error ---" +bash -c ' + set -e + echo "Before false" + false + echo "❌ After false — should NOT print" +' 2>&1 +echo "Exit code from set -e script: $?" +echo "" + +# 3. try/catch pattern via subshell — always runs +echo "--- The try/catch anti-pattern in bash ---" +bash -c ' + # This is analogous to the command-stream bug: + # "if command fails, take the else branch" + if false; then + echo "❌ No changes to commit (only if exit code 0)" + else + echo "✅ Changes detected (exit code 1)" + fi +' +echo "" + +# 4. git diff --cached --quiet semantics +echo "--- git diff --cached --quiet semantics ---" +echo "Exit code 0 = no staged changes" +echo "Exit code 1 = staged changes exist" +echo "" +echo "Correct pattern (bash):" +echo ' if git diff --cached --quiet; then' +echo ' echo "No changes"' +echo ' else' +echo ' echo "Changes detected — commit"' +echo ' fi' +echo "" +echo "Correct pattern (command-stream):" +echo ' const result = await $`git diff --cached --quiet`;' +echo ' if (result.code !== 0) {' +echo ' // Changes exist — proceed with commit' +echo ' }' +echo "" + +# 5. set -o pipefail behavior +echo "--- set -o pipefail behavior ---" +bash -c ' + set -e + set -o pipefail + false | true # Without pipefail: exits 0 (true succeeds) + # With pipefail: exits 1 (false failed in pipeline) + echo "❌ Should not reach here with pipefail" +' 2>&1 +echo "Exit code from pipefail test: $?" +echo "" + +echo "=== Comparison complete ===" diff --git a/experiments/issue-156/04-calculator-bug-repro.mjs b/experiments/issue-156/04-calculator-bug-repro.mjs new file mode 100644 index 0000000..5a0224a --- /dev/null +++ b/experiments/issue-156/04-calculator-bug-repro.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +/** + * Experiment 4: Reproducing the calculator issue #78 bug + * + * Demonstrates exactly what happened in link-assistant/calculator's + * version-and-commit.mjs script. + * + * The bug: try/catch was used to detect git diff exit code 1, + * but command-stream defaults to errexit=false, so the catch was never reached. + * + * Related: https://github.com/link-foundation/command-stream/issues/156 + * https://github.com/link-assistant/calculator/pull/79 + */ + +import { $, shell } from '../../js/src/$.mjs'; + +console.log('=== Experiment 4: Calculator Bug Reproduction ===\n'); + +// Simulate the state: there are staged changes +// We use `false` to simulate `git diff --cached --quiet` exiting with code 1 +// (exit code 1 = staged changes exist, exit code 0 = no staged changes) + +// ============================================================= +// BUGGY VERSION (what calculator had before PR #79) +// ============================================================= +console.log('--- BUGGY VERSION (from calculator before PR #79) ---'); +console.log('shell.errexit:', shell.settings().errexit, '(false by default)\n'); + +let commitAttempted = false; + +// This is the exact buggy pattern: +try { + await $`false`; // Simulates: git diff --cached --quiet (returns 1 = changes exist) + // NEVER should reach here when exit code is 1 + // BUT with errexit=false, no exception is thrown! + console.log('No changes to commit'); // ← Always executes! BUG! + // return; // ← Would return early here, skipping the commit +} catch { + // Intended: only run when staged changes exist (exit code 1) + // Actual: NEVER reached with errexit=false + commitAttempted = true; + console.log('Caught: changes detected — would commit'); +} + +if (!commitAttempted) { + console.log(''); + console.log('💥 BUG REPRODUCED: catch block never reached!'); + console.log(' Auto-release pipeline exits without committing.'); + console.log(' This is why 37 changelog fragments accumulated.'); + console.log(' CI log showed: "No changes to commit" every single run.'); +} + +console.log(`\n${'='.repeat(60)}\n`); + +// ============================================================= +// FIXED VERSION (what calculator has after PR #79) +// ============================================================= +console.log('--- FIXED VERSION (after PR #79) ---'); + +// Explicit exit code check — no try/catch needed +const diffResult = await $`false`; // Simulates: git diff --cached --quiet +if (diffResult.code === 0) { + console.log('No changes to commit'); + // process.exit(0); +} else { + // diffResult.code === 1: staged changes exist + console.log('✅ Changes detected (exit code:', `${diffResult.code})`); + console.log(' Proceeding with git commit...'); + // await $`git commit -m "Automated version bump"`; + console.log(' Commit would be made here.'); +} + +console.log(`\n${'='.repeat(60)}\n`); + +// ============================================================= +// ALTERNATIVE FIX: Use errexit=true +// ============================================================= +console.log('--- ALTERNATIVE FIX: Enable errexit=true ---'); + +shell.errexit(true); + +try { + await $`false`; // Now throws because errexit=true + console.log('No changes to commit'); // Would NOT reach here +} catch (err) { + if (err.code === 1) { + console.log('✅ Changes detected via exception (code:', `${err.code})`); + console.log(' Proceeding with git commit...'); + // await $`git commit -m "Automated version bump"`; + console.log(' Commit would be made here.'); + } +} + +shell.errexit(false); // Reset + +console.log('\n=== Reproduction complete ==='); +console.log('\nConclusion:'); +console.log( + '- The bug was a try/catch used with the assumption that errexit=true' +); +console.log( + '- command-stream defaults to errexit=false (like bash without set -e)' +); +console.log('- Fix: use explicit result.code check instead of try/catch'); +console.log('- Or: enable shell.errexit(true) before using try/catch pattern'); diff --git a/js/BEST-PRACTICES.md b/js/BEST-PRACTICES.md index 92cdf28..01677de 100644 --- a/js/BEST-PRACTICES.md +++ b/js/BEST-PRACTICES.md @@ -10,6 +10,7 @@ This document covers best practices, common patterns, and pitfalls to avoid when - [Error Handling](#error-handling) - [Performance Tips](#performance-tips) - [Common Pitfalls](#common-pitfalls) + - [7. try/catch for Exit Code Detection (Silent Bug)](#7-trycatch-for-exit-code-detection-silent-bug) --- @@ -346,6 +347,51 @@ if (result.code === 0) { } ``` +### 7. try/catch for Exit Code Detection (Silent Bug) + +**Problem:** Using `try/catch` to detect non-zero exit codes when `errexit` is `false` (the default). Since `command-stream` defaults to `errexit: false`, commands **never throw** on non-zero exit codes unless you explicitly enable it. The `catch` block is silently never reached. + +This is the root cause of the bug in [link-assistant/calculator#78](https://github.com/link-assistant/calculator/issues/78), where a CI auto-release pipeline silently skipped commits for every run. + +```javascript +// WRONG: catch is NEVER reached with errexit=false (the default) +try { + await $`git diff --cached --quiet`; // exits with code 1 when changes exist + console.log('No changes to commit'); // ← ALWAYS runs (silent bug!) + return; +} catch { + // NEVER reached — no exception is thrown with errexit=false + await $`git commit -m "Release"`; +} + +// CORRECT: Always use explicit exit code check +const result = await $`git diff --cached --quiet`; +if (result.code === 0) { + console.log('No changes to commit'); + return; +} +// code === 1: staged changes exist +await $`git commit -m "Release"`; +``` + +If you prefer the `try/catch` pattern, enable `errexit` first: + +```javascript +import { $, shell } from 'command-stream'; + +shell.errexit(true); // Now commands throw on non-zero exit code + +try { + await $`git diff --cached --quiet`; // Now throws when exit code is 1 + console.log('No changes to commit'); +} catch (err) { + // Now correctly reached when exit code !== 0 + await $`git commit -m "Release"`; +} +``` + +See [Case Study: Issue #156](./docs/case-studies/issue-156/README.md) for detailed analysis. + --- ## Quick Reference @@ -366,6 +412,7 @@ if (result.code === 0) { - Don't forget `await` on commands - Don't assume success without checking - Don't ignore stderr output +- Don't use `try/catch` to detect non-zero exit codes with default settings (use `result.code` instead) --- @@ -373,4 +420,5 @@ if (result.code === 0) { - [README.md](../README.md) - Main documentation - [docs/case-studies/issue-153/README.md](./docs/case-studies/issue-153/README.md) - Array.join() pitfall case study +- [docs/case-studies/issue-156/README.md](./docs/case-studies/issue-156/README.md) - try/catch anti-pattern and errexit default behavior - [src/$.quote.mjs](./src/$.quote.mjs) - Quote function implementation diff --git a/js/docs/case-studies/issue-156/README.md b/js/docs/case-studies/issue-156/README.md new file mode 100644 index 0000000..2db7ae2 --- /dev/null +++ b/js/docs/case-studies/issue-156/README.md @@ -0,0 +1,337 @@ +# Case Study: Issue #156 — Default Error Handling Behavior (set -e vs command-stream defaults) + +## Summary + +**Issue:** [#156 — Investigate user's experience](https://github.com/link-foundation/command-stream/issues/156) +**Related PR:** [link-assistant/calculator#79](https://github.com/link-assistant/calculator/pull/79) +**Date:** 2026-02-26 +**Status:** Resolved via documentation and case study + +### Problem Statement + +A user reported unexpected behavior when using `command-stream` in CI/CD scripts (specifically `scripts/version-and-commit.mjs` in the `link-assistant/calculator` repository). The script used a `try/catch` pattern to detect a non-zero exit code from `git diff --cached --quiet`, but the `catch` block was **never reached** — causing silent failures in an auto-release pipeline. + +**Key questions raised by issue #156:** + +1. Does `command-stream` stop executing on the first command failure by default (like `bash -e`)? +2. Can this behavior be configured? +3. How does it compare to standard shell behavior? + +### Root Cause + +`command-stream`'s `$` operator has `errexit: false` by default (equivalent to `bash` without `set -e`). This means: + +- Non-zero exit codes **do not** throw exceptions by default. +- Code written with the assumption that `await $`command`` throws on failure will silently continue past errors. +- The `try/catch` anti-pattern fails silently: the `catch` is never reached, and the `try` completes "successfully" even when the command failed. + +--- + +## Timeline / Sequence of Events + +``` +calculator auto-release pipeline + │ + ├─ [2026-02-25] version-and-commit.mjs runs on GitHub Actions CI + │ │ + │ ├─ Cargo.toml updated to version 0.2.0 ← WORKS + │ ├─ 37 changelog fragments collected ← WORKS + │ ├─ $ `git add ...` stages files ← WORKS + │ │ + │ ├─ try { + │ │ await `git diff --cached --quiet`.run({ capture: true }); + │ │ // INTENDED: only reach here if exit code 0 (no staged changes) + │ │ console.log('No changes to commit'); ← ALWAYS RUNS ← BUG + │ │ return; + │ │ } catch { + │ │ // INTENDED: reach here when exit code 1 (staged changes exist) + │ │ // ACTUAL: NEVER REACHED because errexit=false + │ │ } + │ │ + │ └─ Pipeline exits "successfully" without committing version bump + │ + └─ [Repeated] Every CI run failed silently — no commits, no releases +``` + +### Evidence from CI Logs (calculator issue #78) + +``` +Updated ./Cargo.toml to version 0.2.0 ← file IS written to disk +Collected 7 changelog fragment(s) ← fragments ARE processed +No changes to commit ← BUG: always executes +``` + +The script erroneously detected "no staged changes" every time, because `command-stream`'s default `errexit: false` prevented `git diff --cached --quiet` from throwing when exit code was 1 (indicating staged changes). + +--- + +## Technical Analysis + +### Bash Default Behavior + +In a standard bash script: + +```bash +# Default bash (no set -e): continues on error +false +echo "This WILL print" # Executes after failure +echo "Exit of 'false': $?" # Prints "1" + +# Script exits with 0 (last command succeeded) +``` + +```bash +# With set -e: exits on first error +set -e +false +echo "This will NOT print" # Never reached +``` + +```bash +# With set -o pipefail: pipeline failures propagate +set -e +set -o pipefail +false | true # Normally exits 0, but with pipefail exits 1 +``` + +### command-stream Default Behavior + +```javascript +import { $ } from 'command-stream'; + +// Default (errexit: false) — like bash without set -e +const result = await $`false`; +console.log('Continued after failure'); // ALWAYS prints +console.log('Exit code:', result.code); // Prints "1" +// Does NOT throw — result.code carries the failure + +// With errexit enabled — like bash with set -e +import { shell } from 'command-stream'; +shell.errexit(true); +try { + await $`false`; +} catch (err) { + console.log('Caught:', err.code); // Prints "1" +} +``` + +### Behavior Comparison Table + +| Scenario | bash default | bash with `set -e` | command-stream default | command-stream with `shell.errexit(true)` | +| -------------------------- | ------------------------ | ------------------ | ---------------------- | ----------------------------------------- | +| `false` exits with code 1 | Continues | **Aborts** | Continues | **Throws** | +| Next line after `false` | Executes | Does NOT execute | Executes | Does NOT execute (caught by catch) | +| `try/catch` around `false` | N/A | N/A | catch NOT reached | catch IS reached | +| Script final exit code | 0 (if last cmd succeeds) | 1 | 0 (no throw) | Depends on catch | +| Result object available | N/A | N/A | ✅ `result.code` | ✅ `error.code` | + +### The Anti-Pattern: try/catch for Exit Code Detection + +The broken pattern in `calculator/scripts/version-and-commit.mjs`: + +```javascript +// ❌ WRONG: Relies on exception being thrown, but errexit=false by default +try { + await $`git diff --cached --quiet`; + console.log('No changes to commit'); // Always runs with errexit=false + return; +} catch { + // NEVER reached when errexit=false + // Intended to only run when exit code is 1 (staged changes exist) +} +``` + +The correct pattern: + +```javascript +// ✅ CORRECT: Explicitly check exit code +const result = await $`git diff --cached --quiet`; +if (result.code === 0) { + console.log('No changes to commit'); + return; +} +// Proceed with commit (exit code 1 = staged changes present) +``` + +### Why command-stream Defaults to errexit: false + +The design choice to default `errexit: false` is intentional and follows the convention of most scripting libraries: + +1. **Composability:** Many commands intentionally return non-zero codes as signals (e.g., `grep` returns 1 when no match, `git diff --quiet` returns 1 when changes exist). +2. **Explicit error handling:** Users are expected to check `result.code` when exit code semantics matter. +3. **Interoperability:** Matches the default behavior of `child_process` in Node.js, which also does not throw on non-zero exit codes by default. +4. **Progressive strictness:** Users who need `set -e` semantics can opt in via `shell.errexit(true)` for specific script sections. + +--- + +## Configuration API + +`command-stream` provides full control over error handling behavior: + +```javascript +import { $, shell, set, unset } from 'command-stream'; + +// Method 1: shell object API (recommended) +shell.errexit(true); // Enable — throws on non-zero exit codes +shell.errexit(false); // Disable — only check result.code + +// Method 2: bash-compatible set/unset API +set('e'); // Same as shell.errexit(true) +unset('e'); // Same as shell.errexit(false) + +// Method 3: Full option names +set('errexit'); + +// Combined settings +shell.errexit(true); // Like set -e +shell.pipefail(true); // Like set -o pipefail +shell.nounset(true); // Like set -u + +// Check current settings +const settings = shell.settings(); +// { errexit: true, pipefail: true, nounset: false, verbose: false, xtrace: false } +``` + +### Recommended Patterns + +**Pattern 1: Strict mode (abort on any failure)** + +```javascript +import { $, shell } from 'command-stream'; + +shell.errexit(true); // All failures throw + +await $`git add .`; +await $`git commit -m "Release v1.0.0"`; +await $`git push origin main`; +``` + +**Pattern 2: Mixed strict/optional** + +```javascript +import { $, shell } from 'command-stream'; + +shell.errexit(true); // Strict by default + +// Enable optional section +shell.errexit(false); +const result = await $`git diff --cached --quiet`; // May exit with 1 +shell.errexit(true); + +// Use result.code explicitly +if (result.code !== 0) { + await $`git commit -m "Automated version bump"`; +} +``` + +**Pattern 3: Explicit exit code checks (most readable)** + +```javascript +import { $ } from 'command-stream'; + +// Always check codes explicitly — no exceptions needed +const diff = await $`git diff --cached --quiet`; +if (diff.code !== 0) { + await $`git commit -m "Release"`; + const push = await $`git push origin main`; + if (push.code !== 0) { + console.error('Push failed:', push.stderr); + process.exit(push.code); + } +} +``` + +--- + +## Root Causes + +### Root Cause 1: `try/catch` Anti-Pattern with `errexit: false` Default + +**Where:** `link-assistant/calculator/scripts/version-and-commit.mjs` +**What:** Code used `try/catch` expecting an exception from a failed command, but `command-stream` defaults to `errexit: false`. +**Impact:** Auto-release pipeline silently skipped version commits for every CI run. +**Fix:** Replace `try/catch` with explicit `result.code` check. +**Status:** Fixed in [link-assistant/calculator#79](https://github.com/link-assistant/calculator/pull/79). + +### Root Cause 2: Missing Documentation of Default Behavior + +**Where:** `command-stream` documentation +**What:** The default `errexit: false` behavior was not prominently documented as a potential gotcha. +**Impact:** Users coming from bash `set -e` mindset or from `execa` (which throws by default) encounter surprising behavior. +**Fix:** Add this case study; update BEST-PRACTICES.md with the try/catch anti-pattern. +**Status:** Addressed in this case study. + +--- + +## Proposed Solutions + +### Solution 1: Document the Default Behavior (Implemented) + +Add prominent documentation explaining `errexit: false` default and the correct patterns for exit code handling. This case study serves as that documentation. + +**Pros:** No breaking changes, educates users. +**Cons:** Users must read documentation. + +### Solution 2: Add Warning for try/catch Pattern (Possible Enhancement) + +`command-stream` could optionally log a warning when a command exits with non-zero but `errexit: false` is set. This would be a debug-mode feature. + +```javascript +// Hypothetical warning mode +shell.warnOnFailure(true); // Log warning when non-zero exit code ignored +``` + +**Pros:** Visible signal that something failed. +**Cons:** Breaking change in output, potentially noisy. + +### Solution 3: Consider Defaulting to errexit: true (Breaking Change) + +Some libraries like `execa` throw on non-zero exit codes by default. A `command-stream` v2 could change the default. + +**Pros:** Catches bugs like the calculator issue automatically. +**Cons:** Massive breaking change. Many scripts rely on `errexit: false` behavior. Would require a major version bump. + +### Solution 4: Per-Command errexit Option (Possible Enhancement) + +Allow `errexit` to be specified per-command rather than globally: + +```javascript +// Hypothetical per-command option +const result = await $`git diff --cached --quiet`.run({ errexit: false }); +``` + +**Pros:** Fine-grained control without global state. +**Cons:** API change needed. + +--- + +## Comparison with Similar Libraries + +| Library | Default on non-zero exit | Configuration | +| --------------------- | ---------------------------- | ----------------------------------- | +| `bash` (no flags) | Continue silently | `set -e` to throw | +| `bash -e` | Exit immediately | Default when using `bash -e` | +| `child_process.spawn` | Continue (emit 'exit') | No built-in throw | +| `child_process.exec` | Callback with error | Always errors on non-zero | +| `execa` | **Throw** | `{ reject: false }` to suppress | +| `zx` | **Throw** | `$.verbose`, `nothrow()`, `quiet()` | +| `command-stream` | **Continue** (errexit=false) | `shell.errexit(true)` | + +--- + +## Related Issues and PRs + +- **[link-assistant/calculator#78](https://github.com/link-assistant/calculator/issues/78)** — Original bug report for auto-release pipeline failure +- **[link-assistant/calculator#79](https://github.com/link-assistant/calculator/pull/79)** — Fix: replace try/catch with explicit code check + case study +- **[command-stream#153](https://github.com/link-foundation/command-stream/issues/153)** — Similar documentation issue: Array.join() pitfall +- **[command-stream#156](https://github.com/link-foundation/command-stream/issues/156)** — This issue + +--- + +## Key Takeaways + +1. **`command-stream` defaults to `errexit: false`** — non-zero exit codes do NOT throw by default. +2. **Never use `try/catch` to detect non-zero exit codes** with `errexit: false`. Always use `result.code`. +3. **Enable `shell.errexit(true)`** for scripts that should abort on any failure (like `bash -e`). +4. **Use the mixed pattern** for commands that legitimately return non-zero as a signal (like `grep`, `git diff --quiet`). +5. **`result.code`, `result.stdout`, `result.stderr`** are always available on the result object, regardless of `errexit` setting.