From 46bb856578f7ae958bf218cda505ba03462842b3 Mon Sep 17 00:00:00 2001 From: "Nadine Roth (COO)" Date: Mon, 24 Aug 2026 10:31:02 +0200 Subject: [PATCH] fix(core): show stdout tail in exec() failure message when stderr is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error tail used `stderr.trim().split('\n').pop() ?? stdout…pop()`, but pop() on the single-element array [''] returns '' — not undefined — so the ?? fallback never fired and commands that fail while writing their diagnostics to stdout (very common for CLIs) produced errors like: git … failed (exit 128): Now the last non-empty stderr line is preferred, with the last non-empty stdout line as the actual fallback. --- packages/core/src/exec.test.ts | 14 ++++++++++++++ packages/core/src/exec.ts | 6 +++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/core/src/exec.test.ts b/packages/core/src/exec.test.ts index e647aaea..049fb9d8 100644 --- a/packages/core/src/exec.test.ts +++ b/packages/core/src/exec.test.ts @@ -41,6 +41,20 @@ describe('exec', () => { 'quoted "value"', ]); }); + + it('falls back to the last stdout line in the failure message when stderr is empty', async () => { + await expect(exec('sh', ['-c', 'echo useful-diagnostic-on-stdout; exit 3'], { + log: () => {}, + throwOnNonZero: true, + })).rejects.toThrow('failed (exit 3): useful-diagnostic-on-stdout'); + }); + + it('prefers the last non-empty stderr line in the failure message', async () => { + await expect(exec('sh', ['-c', 'echo noisy >&2; echo real-error >&2; exit 2'], { + log: () => {}, + throwOnNonZero: true, + })).rejects.toThrow('failed (exit 2): real-error'); + }); }); describe('ensureCli', () => { diff --git a/packages/core/src/exec.ts b/packages/core/src/exec.ts index 4042abd8..2b335a09 100644 --- a/packages/core/src/exec.ts +++ b/packages/core/src/exec.ts @@ -72,7 +72,11 @@ export async function exec(cmd: string, args: string[], opts: ExecOptions): Prom child.on('close', (exitCode) => { const result: ExecResult = { exitCode: exitCode ?? -1, stdout, stderr }; if (throwOnNonZero && result.exitCode !== 0) { - const tail = stderr.trim().split('\n').pop() ?? stdout.trim().split('\n').pop() ?? ''; + // Prefer the last non-empty stderr line; fall back to stdout. Note + // `''.trim().split('\n').pop()` returns '' (not undefined), so a plain + // ??-chain never reaches the stdout fallback when stderr is empty. + const lastLine = (s: string) => s.trim().split('\n').filter(Boolean).pop() ?? ''; + const tail = lastLine(stderr) || lastLine(stdout); reject(new Error(`${cmd} ${args.join(' ')} failed (exit ${result.exitCode}): ${tail}`)); } else { resolve(result);