Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/issue-156-errexit-false-documentation.md
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions experiments/issue-156/01-default-behavior.mjs
Original file line number Diff line number Diff line change
@@ -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');
112 changes: 112 additions & 0 deletions experiments/issue-156/02-errexit-enabled.mjs
Original file line number Diff line number Diff line change
@@ -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 ===');
73 changes: 73 additions & 0 deletions experiments/issue-156/03-bash-comparison.sh
Original file line number Diff line number Diff line change
@@ -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 ==="
105 changes: 105 additions & 0 deletions experiments/issue-156/04-calculator-bug-repro.mjs
Original file line number Diff line number Diff line change
@@ -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');
Loading
Loading