From cbe0ec4dca61cb701475a151f7df987a518fefd4 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Thu, 22 Oct 2026 04:12:36 +1100 Subject: [PATCH 1/2] Exit with code 1 when a file gives an error to close #728 --- src/output.ts | 2 ++ test/cli/run.ai.sh | 4 +-- test/js/basics.test.js | 2 +- test/js/fix/683.test.js | 2 +- test/js/fix/705.test.js | 2 +- test/js/fix/708.test.js | 2 +- test/js/fix/728.test.js | 50 ++++++++++++++++++++++++++++++++ test/js/halt.test.js | 24 ++++++++++----- test/js/helper.js | 4 +++ test/js/replacement-pipe.test.js | 2 +- 10 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 test/js/fix/728.test.js diff --git a/src/output.ts b/src/output.ts index 73096afe..f1061261 100644 --- a/src/output.ts +++ b/src/output.ts @@ -105,6 +105,8 @@ export const error = function (msg, data = '') { if (config.halt) { kill(); } + // The run goes on, but the exit code tells that something went wrong + process.exitCode = 1; return false; }; diff --git a/test/cli/run.ai.sh b/test/cli/run.ai.sh index 65979f30..01b69976 100644 --- a/test/cli/run.ai.sh +++ b/test/cli/run.ai.sh @@ -95,7 +95,7 @@ reset echo assert "rexreplace foo xxx my.file -q" "" # Error run (file not found) should still print error to stderr (tested indirectly via assert_raises failure message) # We can't easily assert stderr content with aserta, but we can check it doesn't suppress errors needed for failure -echo assert_raises "rexreplace foo xxx non_existent_file.txt -q" 1 # Exit code should still be non-zero +assert_raises "rexreplace foo xxx non_existent_file.txt -q" 1 # Exit code should still be non-zero # -Q, --quiet-total reset @@ -103,7 +103,7 @@ reset echo assert "rexreplace foo xxx my.file -Q" "" # Error run should have NO output at all (stdout or stderr) # Check exit code is still non-zero, but no output should be printed by aserta on failure if rexreplace is truly quiet -echo assert_raises "rexreplace foo xxx non_existent_file.txt -Q" 1 +assert_raises "rexreplace foo xxx non_existent_file.txt -Q" 1 # -H, --halt reset diff --git a/test/js/basics.test.js b/test/js/basics.test.js index a02a2efc..496895fe 100644 --- a/test/js/basics.test.js +++ b/test/js/basics.test.js @@ -229,6 +229,6 @@ describe('piped data', () => { }); test('leaves nothing to do if neither data nor files are given', async () => { - expect(await rr('o', 'x')).toEqual({out: '', err: '0 files found\n', exit: null}); + expect(await rr('o', 'x')).toEqual({out: '', err: '0 files found\n', exit: 1}); }); }); diff --git a/test/js/fix/683.test.js b/test/js/fix/683.test.js index 4e462c32..3888b738 100644 --- a/test/js/fix/683.test.js +++ b/test/js/fix/683.test.js @@ -15,7 +15,7 @@ describe('#683 --exclude-re follows the regex flags of the main search', () => { test('the example from the issue', async () => { fs.writeFileSync('a.file', 'x\n'); const config = {includeGlob: ['a.file'], excludeRe: ['A'], output: true}; - expect(await rr('x', 'y', config)).toEqual({out: '', err: '0 files found\n', exit: null}); + expect(await rr('x', 'y', config)).toEqual({out: '', err: '0 files found\n', exit: 1}); expect((await rr('x', 'y', {...config, voidIgnoreCase: true})).out).toBe('y\n'); }); diff --git a/test/js/fix/705.test.js b/test/js/fix/705.test.js index 069f24c7..5dd24e52 100644 --- a/test/js/fix/705.test.js +++ b/test/js/fix/705.test.js @@ -81,7 +81,7 @@ describe('#705 the engine returns a promise that resolves when all files are don const result = await rr('o', 'x', {includeGlob: ['*.file'], ...flags}); expect(result.err).toContain('ENAMETOOLONG'); - expect(result.exit).toBe(null); + expect(result.exit).toBe(1); expect(content(['a.file', tooLong, 'c.file'])).toEqual(['fxx', 'foo', 'fxx']); expect(fs.readdirSync('.')).toHaveLength(3); }); diff --git a/test/js/fix/708.test.js b/test/js/fix/708.test.js index 648e8896..f2b4dd59 100644 --- a/test/js/fix/708.test.js +++ b/test/js/fix/708.test.js @@ -83,7 +83,7 @@ describe('#708 the number of files in work at the same time is limited', () => { expect(result.out).toBe('my.file\n'); expect(result.err).toContain('EACCES'); - expect(result.exit).toBe(null); + expect(result.exit).toBe(1); expect(await Bun.file('my.file').text()).toBe('fxx'); }); }); diff --git a/test/js/fix/728.test.js b/test/js/fix/728.test.js new file mode 100644 index 00000000..ef4c1e8c --- /dev/null +++ b/test/js/fix/728.test.js @@ -0,0 +1,50 @@ +// https://github.com/mathiasrw/rexreplace/issues/728 +// An error from a file was printed, but the exit code was 0, so a script could not see that something went wrong. +import {describe, test, expect} from 'bun:test'; +import fs from 'fs'; +import {rr} from '../helper.js'; + +// Note that rr() from the helper sets voidAsync per default, so the async flow is `voidAsync: false` +const flows = [ + ['the async flow', {voidAsync: false}], + ['--void-async', {voidAsync: true}], +]; + +// Make a.file and b.file with the content `foo`, where b.file can not be read +function oneUnreadable() { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('b.file', 0); + return {includeGlob: ['a.file', 'b.file']}; +} + +describe('#728 the exit code is 1 when a file gives an error', () => { + test.each(flows)('and the other files are still changed with %s', async (name, flags) => { + const result = await rr('o', 'x', {...oneUnreadable(), ...flags}); + expect(result.out).toBe('a.file\n'); + expect(result.err).toContain('EACCES'); + expect(result.exit).toBe(1); + expect(fs.readFileSync('a.file', 'utf8')).toBe('fxx'); + }); + + test('when the backup can not be made', async () => { + fs.mkdirSync('locked'); + fs.writeFileSync('locked/my.file', 'foo'); + fs.chmodSync('locked', 0o555); + + try { + const result = await rr('o', 'x', {includeGlob: ['locked/my.file']}); + expect(result.out).toBe(''); + expect(result.err).toContain('EACCES'); + expect(result.exit).toBe(1); + expect(fs.readFileSync('locked/my.file', 'utf8')).toBe('foo'); + } finally { + // The folder must be writable again so the test folder can be removed + fs.chmodSync('locked', 0o755); + } + }); + + test.each(['quiet', 'quietTotal'])('also with --%s', async (flag) => { + expect((await rr('o', 'x', {...oneUnreadable(), [flag]: true})).exit).toBe(1); + }); +}); diff --git a/test/js/halt.test.js b/test/js/halt.test.js index 0738388a..455faf34 100644 --- a/test/js/halt.test.js +++ b/test/js/halt.test.js @@ -16,16 +16,26 @@ function runCli(...args) { } describe('--halt', () => { + // a.file can not be read, and the files are handled in the order of the list + function lockedFirst() { + fs.writeFileSync('a.file', 'foo'); + fs.writeFileSync('b.file', 'foo'); + fs.chmodSync('a.file', 0); + return {includeGlob: ['a.file', 'b.file']}; + } + test('stops the process on the first error', async () => { - const result = await rr('o', 'x', {includeGlob: ['nope.file'], halt: true}); - expect(result.err).toContain('0 files found'); - expect(result.exit).not.toBe(null); + const result = await rr('o', 'x', {...lockedFirst(), halt: true}); + expect(result.err).toContain('EACCES'); + expect(result.exit).toBe(1); + expect(fs.readFileSync('b.file', 'utf8')).toBe('foo'); }); - test('is not the default', async () => { - const result = await rr('o', 'x', {includeGlob: ['nope.file']}); - expect(result.err).toContain('0 files found'); - expect(result.exit).toBe(null); + test('is not the default, so the other files are still handled', async () => { + const result = await rr('o', 'x', lockedFirst()); + expect(result.err).toContain('EACCES'); + expect(result.exit).toBe(1); + expect(fs.readFileSync('b.file', 'utf8')).toBe('fxx'); }); test('changes nothing when all goes well', async () => { diff --git a/test/js/helper.js b/test/js/helper.js index 45a896ab..cca853a9 100644 --- a/test/js/helper.js +++ b/test/js/helper.js @@ -27,6 +27,9 @@ export async function rr(pattern, replacement, config = {}) { const result = {out: '', err: '', exit: null}; const halt = {}; + // An error lets the run go on, but sets the exit code for when the process ends. Start from no error. + process.exitCode = 0; + const spies = [ spyOn(process.stdout, 'write').mockImplementation((data) => { result.out += data; @@ -66,6 +69,7 @@ export async function rr(pattern, replacement, config = {}) { } } finally { spies.forEach((spy) => spy.mockRestore()); + result.exit ??= process.exitCode || null; } return result; diff --git a/test/js/replacement-pipe.test.js b/test/js/replacement-pipe.test.js index 64fdfd09..b440232d 100644 --- a/test/js/replacement-pipe.test.js +++ b/test/js/replacement-pipe.test.js @@ -66,7 +66,7 @@ describe('--replacement-pipe', () => { test('does not treat the piped data as content to search in', async () => { const result = await rr('o', '_', {replacementPipe: true, pipedData: 'foo'}); - expect(result).toEqual({out: '', err: '0 files found\n', exit: null}); + expect(result).toEqual({out: '', err: '0 files found\n', exit: 1}); }); describe('combined', () => { From 160db433592de2582a0285c335b7ecfa37faf248 Mon Sep 17 00:00:00 2001 From: Mathias Wulff Date: Thu, 22 Oct 2026 13:22:51 +1100 Subject: [PATCH 2/2] Let the CLI set the exit code from the number of errors the engine resolves with --- src/cli.ts | 12 ++++++++++-- src/engine.ts | 8 +++++++- src/output.ts | 9 +++++++-- test/js/helper.js | 9 ++++----- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 52d7e1cb..53a82936 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -506,11 +506,19 @@ function unescapeString(str = '') { config.output = true; }*/ + // The engine tells how many errors there were, and the CLI turns that into the exit code + const run = () => + rexreplace.engine(config).then((errors) => { + if (errors) { + process.exitCode = 1; + } + }); + // Like grep, stdin is only read when piped data is expected. An open pipe that nobody writes to would make the run wait forever. const pipeDataExpected = !config.includeGlob.length || config.replacementPipe || config.pipe; if (!pipeDataExpected || Boolean(process.stdin.isTTY)) { - return rexreplace.engine(config); + return run(); } process.stdin.setEncoding(config.encoding); @@ -534,6 +542,6 @@ function unescapeString(str = '') { } config.pipedData = pipeData; } - rexreplace.engine(config); + run(); }); })(); diff --git a/src/engine.ts b/src/engine.ts index 92673458..04233151 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -10,6 +10,7 @@ const now = new Date(); import { outputConfig, + errorCount, step, debug, chat, @@ -43,8 +44,13 @@ function validateEncoding(encoding = 'utf8') { } } -// Returns a promise that resolves when all files are done +// Returns a promise that resolves with the number of errors when all files are done export async function engine(config: any = {engine: 'default'}) { + await run(config); + return errorCount(); +} + +async function run(config) { outputConfig(config); validateEncoding(config.encoding); diff --git a/src/output.ts b/src/output.ts index f1061261..34ec16aa 100644 --- a/src/output.ts +++ b/src/output.ts @@ -11,12 +11,18 @@ const lastItemChar = new Map(); // The char code of the last character printed to each stream const lastPrintedChar = new Map(); +// The number of errors in this run, so the CLI can give an exit code +let errors = 0; + export const outputConfig = function (_config) { config = _config; lastItemChar.clear(); lastPrintedChar.clear(); + errors = 0; }; +export const errorCount = () => errors; + // Everything is printed from here, so the last character of each stream is known const write = function (text, stream) { stream.write(text); @@ -105,8 +111,7 @@ export const error = function (msg, data = '') { if (config.halt) { kill(); } - // The run goes on, but the exit code tells that something went wrong - process.exitCode = 1; + errors++; return false; }; diff --git a/test/js/helper.js b/test/js/helper.js index cca853a9..0528a5c7 100644 --- a/test/js/helper.js +++ b/test/js/helper.js @@ -27,9 +27,6 @@ export async function rr(pattern, replacement, config = {}) { const result = {out: '', err: '', exit: null}; const halt = {}; - // An error lets the run go on, but sets the exit code for when the process ends. Start from no error. - process.exitCode = 0; - const spies = [ spyOn(process.stdout, 'write').mockImplementation((data) => { result.out += data; @@ -62,14 +59,16 @@ export async function rr(pattern, replacement, config = {}) { ...config, }; alias(options); - await engine(options); + // Like the CLI, an error gives the exit code 1 when the run is done + if (await engine(options)) { + result.exit = 1; + } } catch (e) { if (e !== halt) { throw e; } } finally { spies.forEach((spy) => spy.mockRestore()); - result.exit ??= process.exitCode || null; } return result;