From b87d349da51d23c14b9981d5a37ac522a7f413dc Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Tue, 28 Jul 2026 15:35:50 +0200 Subject: [PATCH 1/7] fix(runner): bypass external diff tools in word-diff rendering Users with diff.external configured (e.g. difftastic) get broken diff output because git delegates to the external tool, which ignores --word-diff and produces a different format. --- src/console_results.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/console_results.sh b/src/console_results.sh index 882baf09..4f959053 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -313,7 +313,7 @@ function bashunit::console_results::render_diff() { # `git diff` exits non-zero when the files differ; the `|| true` keeps that # from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's # header lines; `sed` indents the body. - git diff --no-index --word-diff "$color_flag" \ + git diff --no-index --no-ext-diff --word-diff "$color_flag" \ "$expected_file" "$actual_file" 2>/dev/null | tail -n +6 | sed "s/^/ /" || true } From b7c92c2b506f6f4cb546066437c08532cef17e6c Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Tue, 28 Jul 2026 18:42:17 +0200 Subject: [PATCH 2/7] fix(runner): avoid SIGSEGV in shell_time on Bash 5.3 macOS Bash 5.3 on macOS segfaults (exit 139) when LC_ALL is set inside a command substitution that reads the magic EPOCHREALTIME variable. Besides, force the console_results_test to use the perl clock path on Bash 5+. --- src/clock.sh | 2 +- tests/unit/console_results_test.sh | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/clock.sh b/src/clock.sh index 035cb66e..afe0fb99 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -182,7 +182,7 @@ function bashunit::clock::now() { function bashunit::clock::shell_time() { # Get time directly from the shell variable EPOCHREALTIME (Bash 5+) - [ -n "${EPOCHREALTIME+x}" ] && [ -n "$EPOCHREALTIME" ] && LC_ALL=C echo "$EPOCHREALTIME" + [ -n "${EPOCHREALTIME+x}" ] && [ -n "$EPOCHREALTIME" ] && echo "$EPOCHREALTIME" } function bashunit::clock::total_runtime_in_milliseconds() { diff --git a/tests/unit/console_results_test.sh b/tests/unit/console_results_test.sh index 4bc7e7d1..5e69e24c 100644 --- a/tests/unit/console_results_test.sh +++ b/tests/unit/console_results_test.sh @@ -363,6 +363,7 @@ function test_render_execution_time_on_osx_with_perl() { local render_result mock_macos + _BASHUNIT_CLOCK_NOW_IMPL="perl" bashunit::mock bashunit::dependencies::has_perl mock_true _BASHUNIT_START_TIME="1726393394574382186" bashunit::mock perl <<<"1726393394574372186" From de201c771fc26144539a3ab1a7c91568205ea2d7 Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Tue, 28 Jul 2026 19:59:22 +0200 Subject: [PATCH 3/7] fix(test): remove LC_ALL=C from syntax error test subshells Bash 5.3 on macOS segfaults when LC_ALL=C is used as a temporary env prefix inside $() command substitutions. The LC_ALL=C LANG=C was unnecessary here: the asserted strings ('failed', 'Error') come from bashunit's own output, not locale-dependent messages. --- tests/acceptance/bashunit_syntax_error_test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/acceptance/bashunit_syntax_error_test.sh b/tests/acceptance/bashunit_syntax_error_test.sh index 2f0a1f1e..7cc5476a 100644 --- a/tests/acceptance/bashunit_syntax_error_test.sh +++ b/tests/acceptance/bashunit_syntax_error_test.sh @@ -10,7 +10,7 @@ function test_bashunit_when_test_file_has_syntax_error() { local actual_raw set +e - actual_raw="$(LC_ALL=C LANG=C ./bashunit \ + actual_raw="$(./bashunit \ --no-parallel --detailed --env "$TEST_ENV_FILE" "$test_file" 2>&1)" set -e @@ -19,6 +19,6 @@ function test_bashunit_when_test_file_has_syntax_error() { assert_contains "failed" "$actual" assert_contains "Error" "$actual" - assert_general_error "$(LC_ALL=C LANG=C ./bashunit \ + assert_general_error "$(./bashunit \ --no-parallel --env "$TEST_ENV_FILE" "$test_file" 2>&1)" } From 4d9e2722339f98cddd873d06e1e34fa57cc6b711 Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Tue, 28 Jul 2026 20:15:58 +0200 Subject: [PATCH 4/7] docs: add changelog entries for diff and LC_ALL fixes --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33fffd7e..98063fec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ - An unreadable or truncated parallel `.result` file counts as a failed test instead of aborting aggregation - `release.sh` reports a failed rollback as failed - Docs: the quickstart's duration format, and the real `BASHUNIT_SHOW_EXECUTION_TIME` default (`auto`) +- Word-diff output was broken for users with `diff.external` configured (e.g. difftastic); `render_diff` now passes `--no-ext-diff` to `git diff` +- `shell_time()` crashed (SIGSEGV) on Bash 5.3 macOS due to `LC_ALL=C` inside a command substitution; removed since callers already handle both decimal separators ### Removed - `bin/create-pr`, an unreferenced vendored copy of [Chemaclass/create-pr](https://github.com/Chemaclass/create-pr); use the upstream tool (#867) From 01b62b52a15f480d4f310601b7c6c9c64fb9f66a Mon Sep 17 00:00:00 2001 From: Thibault Gagnaux Date: Tue, 28 Jul 2026 20:34:50 +0200 Subject: [PATCH 5/7] fix(test): use $TMP_DIR for init test log instead of /tmp /tmp is not writable inside the nix sandbox on darwin, causing all init tests to fail with 'Permission denied'. --- tests/acceptance/bashunit_init_test.sh | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/acceptance/bashunit_init_test.sh b/tests/acceptance/bashunit_init_test.sh index 927486b5..b792737a 100644 --- a/tests/acceptance/bashunit_init_test.sh +++ b/tests/acceptance/bashunit_init_test.sh @@ -15,7 +15,7 @@ function tear_down() { function test_bashunit_init_creates_structure() { pushd "$TMP_DIR" >/dev/null - "$BASHUNIT_PATH" init >/tmp/init.log + "$BASHUNIT_PATH" init >"$TMP_DIR/init.log" assert_file_exists "tests/example_test.sh" assert_file_exists "tests/bootstrap.sh" popd >/dev/null @@ -23,7 +23,7 @@ function test_bashunit_init_creates_structure() { function test_bashunit_init_custom_directory() { pushd "$TMP_DIR" >/dev/null - "$BASHUNIT_PATH" init custom >/tmp/init.log + "$BASHUNIT_PATH" init custom >"$TMP_DIR/init.log" assert_file_exists "custom/example_test.sh" assert_file_exists "custom/bootstrap.sh" popd >/dev/null @@ -31,7 +31,7 @@ function test_bashunit_init_custom_directory() { function test_bashunit_init_creates_github_workflow() { pushd "$TMP_DIR" >/dev/null - "$BASHUNIT_PATH" init >/tmp/init.log + "$BASHUNIT_PATH" init >"$TMP_DIR/init.log" assert_file_exists ".github/workflows/tests.yml" assert_file_contains ".github/workflows/tests.yml" "TypedDevs/bashunit@" popd >/dev/null @@ -41,7 +41,7 @@ function test_bashunit_init_does_not_overwrite_existing_workflow() { pushd "$TMP_DIR" >/dev/null mkdir -p ".github/workflows" echo "custom-workflow" >".github/workflows/tests.yml" - "$BASHUNIT_PATH" init >/tmp/init.log + "$BASHUNIT_PATH" init >"$TMP_DIR/init.log" assert_file_contains ".github/workflows/tests.yml" "custom-workflow" popd >/dev/null } @@ -51,7 +51,7 @@ function test_bashunit_init_updates_env() { pushd "$TMP_DIR" >/dev/null echo "BASHUNIT_BOOTSTRAP=old/bootstrap.sh" >.env - "$BASHUNIT_PATH" init custom >/tmp/init.log + "$BASHUNIT_PATH" init custom >"$TMP_DIR/init.log" assert_file_exists "custom/example_test.sh" assert_file_exists "custom/bootstrap.sh" assert_file_contains .env "#BASHUNIT_BOOTSTRAP=old/bootstrap.sh" From b35a527786d858f56d19dbfeeaa95166f2174a54 Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Tue, 28 Jul 2026 21:06:18 +0200 Subject: [PATCH 6/7] fix(reports): keep the JUnit locale prefix out of a command substitution The JUnit time reads still used a bare LC_ALL=C awk prefix, the same form that segfaults inside $() on Bash 5.3.9 macOS. C is required here (it keeps awk's radix a dot in the XML), so pass it through env instead of removing it. --- CHANGELOG.md | 4 ++-- src/clock.sh | 3 +++ src/console_results.sh | 3 ++- src/reports.sh | 6 ++++-- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98063fec..c501dee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ - The `--parallel` unsupported-OS warning no longer claims Alpine is excluded ### Fixed +- A configured external git differ (`diff.external` / `GIT_EXTERNAL_DIFF`, e.g. difftastic) no longer blanks the multiline and snapshot failure diffs, which now render with `--no-ext-diff` (#912) +- Time reads and the JUnit report no longer set the locale with a temporary-environment prefix (`LC_ALL=C cmd`), which segfaults inside a command substitution on Bash 5.3.9 macOS (#912) - Call assertions (`assert_not_called`, `assert_have_been_called*`) fail with `was never registered as a spy` instead of reporting zero calls when the name was never spied — a typo used to pass silently (#895) - The per-argument form a spy records was written with a literal `$'\x1f'` separator instead of the byte, so it could not be compared against (#894) - `--parallel` no longer discards worker stderr written outside a test body; it renders as a `Stderr from ` block (#864) @@ -48,8 +50,6 @@ - An unreadable or truncated parallel `.result` file counts as a failed test instead of aborting aggregation - `release.sh` reports a failed rollback as failed - Docs: the quickstart's duration format, and the real `BASHUNIT_SHOW_EXECUTION_TIME` default (`auto`) -- Word-diff output was broken for users with `diff.external` configured (e.g. difftastic); `render_diff` now passes `--no-ext-diff` to `git diff` -- `shell_time()` crashed (SIGSEGV) on Bash 5.3 macOS due to `LC_ALL=C` inside a command substitution; removed since callers already handle both decimal separators ### Removed - `bin/create-pr`, an unreferenced vendored copy of [Chemaclass/create-pr](https://github.com/Chemaclass/create-pr); use the upstream tool (#867) diff --git a/src/clock.sh b/src/clock.sh index afe0fb99..124c31a1 100644 --- a/src/clock.sh +++ b/src/clock.sh @@ -182,6 +182,9 @@ function bashunit::clock::now() { function bashunit::clock::shell_time() { # Get time directly from the shell variable EPOCHREALTIME (Bash 5+) + # No `LC_ALL=C` prefix: the value is expanded before the temporary environment + # applies, so it cannot normalize the decimal separator (callers accept both), + # and that form segfaults inside `$()` on Bash 5.3 macOS (#912). [ -n "${EPOCHREALTIME+x}" ] && [ -n "$EPOCHREALTIME" ] && echo "$EPOCHREALTIME" } diff --git a/src/console_results.sh b/src/console_results.sh index 4f959053..a17a6bf3 100644 --- a/src/console_results.sh +++ b/src/console_results.sh @@ -312,7 +312,8 @@ function bashunit::console_results::render_diff() { # `git diff` exits non-zero when the files differ; the `|| true` keeps that # from tripping `set -e`/`pipefail` under --strict. `tail -n +6` drops git's - # header lines; `sed` indents the body. + # header lines; `sed` indents the body. `--no-ext-diff` ignores a user's + # `diff.external`/`GIT_EXTERNAL_DIFF`, which would replace this word-diff. git diff --no-index --no-ext-diff --word-diff "$color_flag" \ "$expected_file" "$actual_file" 2>/dev/null | tail -n +6 | sed "s/^/ /" || true diff --git a/src/reports.sh b/src/reports.sh index dcbddc40..b3e1df45 100755 --- a/src/reports.sh +++ b/src/reports.sh @@ -102,7 +102,9 @@ function bashunit::reports::generate_junit_xml() { local tests_failed=$(bashunit::state::get_tests_failed) local time_ms=$(bashunit::clock::total_runtime_in_milliseconds) local time - time=$(LC_ALL=C awk -v ms="$time_ms" 'BEGIN {printf "%.3f", ms/1000}') + # `env` rather than a bare `LC_ALL=C` prefix: C keeps awk's radix a dot for the + # XML, and that prefix form segfaults inside `$()` on Bash 5.3 macOS (#912). + time=$(env LC_ALL=C awk -v ms="$time_ms" 'BEGIN {printf "%.3f", ms/1000}') { echo "" @@ -120,7 +122,7 @@ function bashunit::reports::generate_junit_xml() { local test_time_ms="${_BASHUNIT_REPORTS_TEST_DURATIONS[$i]:-}" local failure_message="${_BASHUNIT_REPORTS_TEST_FAILURES[$i]:-}" local test_time - test_time=$(LC_ALL=C awk -v ms="$test_time_ms" 'BEGIN {printf "%.3f", ms/1000}') + test_time=$(env LC_ALL=C awk -v ms="$test_time_ms" 'BEGIN {printf "%.3f", ms/1000}') echo " Date: Tue, 28 Jul 2026 21:06:19 +0200 Subject: [PATCH 7/7] test(compat): cover the external differ and the locale prefix Nothing guarded either fix: no CI job runs Bash 5.3 macOS or sets diff.external, so both regressions could return unnoticed. --- tests/unit/bash_compatibility_test.sh | 11 +++++++++++ tests/unit/console_results_diff_test.sh | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/unit/bash_compatibility_test.sh b/tests/unit/bash_compatibility_test.sh index 1faeee99..454cf4c1 100644 --- a/tests/unit/bash_compatibility_test.sh +++ b/tests/unit/bash_compatibility_test.sh @@ -102,3 +102,14 @@ function test_src_has_no_coproc() { function test_src_has_no_parameter_transformations() { assert_empty "$(bashunit::compat::offenders '\$\{[A-Za-z_][A-Za-z0-9_]*@[QEPAKa]\}')" } + +# A temporary-environment locale prefix (`LC_ALL=C cmd`) makes bash change its +# own locale for that command. Bash 5.3.9 on macOS segfaults on that form inside +# a command substitution -- `x=$(LC_ALL=C echo hi)` exits 139 (#912) -- and no CI +# job runs that build. Use `env LC_ALL=C cmd` instead, which passes the locale +# straight to the child and never touches bash's own. +function test_src_has_no_temporary_locale_assignment_prefix() { + local pattern='(^|[;&|(])[[:space:]]*((LC_[A-Z_]+|LANG)=[^[:space:]]*[[:space:]]+)+[^[:space:]=]' + + assert_empty "$(bashunit::compat::offenders "$pattern")" +} diff --git a/tests/unit/console_results_diff_test.sh b/tests/unit/console_results_diff_test.sh index b8500cd4..c4110bca 100644 --- a/tests/unit/console_results_diff_test.sh +++ b/tests/unit/console_results_diff_test.sh @@ -62,3 +62,26 @@ function test_render_diff_shows_changed_tokens_without_color() { rm -f "$a" "$b" unset BASHUNIT_NO_COLOR } + +function test_render_diff_ignores_a_configured_external_diff() { + if ! bashunit::dependencies::has_git; then + bashunit::skip "git not available" && return + fi + export BASHUNIT_NO_COLOR=true + # An external differ (difftastic in #912) replaces git's own output, so without + # --no-ext-diff the rendered diff is whatever it prints -- here nothing at all. + export GIT_EXTERNAL_DIFF=true + local a b + a=$(bashunit::temp_file diff_a) + b=$(bashunit::temp_file diff_b) + printf 'alpha\nbeta\ngamma\n' >"$a" + printf 'alpha\nDELTA\ngamma\n' >"$b" + + local output + output=$(bashunit::console_results::render_diff "$a" "$b") + + assert_contains "beta" "$output" + assert_contains "DELTA" "$output" + rm -f "$a" "$b" + unset BASHUNIT_NO_COLOR GIT_EXTERNAL_DIFF +}