Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ BASHUNIT_REPEAT= # Default: 1 (run each test N times)
BASHUNIT_GHA_ANNOTATIONS= # Default: auto (or always, never)
BASHUNIT_CHANGED= # Default: false (run only test files changed since a git ref)
BASHUNIT_CHANGED_REF= # Default: empty (--changed ref: origin/HEAD, then HEAD)
BASHUNIT_COVERAGE_DIFF= # Default: empty (restrict coverage to lines changed since this ref)
BASHUNIT_EXCLUDE_FILTER= # Default: empty (skip tests whose name matches)
BASHUNIT_LIST_TESTS= # Default: false (print the tests that would run, run none)
BASHUNIT_LIST_FORMAT= # Default: text (--list rendering: text or json)
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## Unreleased

### Added
- `--coverage-diff <ref>` restricts the coverage console report to lines changed since a base ref, and `--coverage-min` then gates on that diff percentage (#1032)
- `--gha-annotations <auto|always|never>` controls GitHub Actions annotations on stdout; `auto` turns them on inside GitHub Actions and stays quiet everywhere else (#1014)
- `--repeat <n>` runs each selected test n times so flakiness can be hunted before it reaches CI. The test is reported once with the aggregate outcome, a failure names the iteration it happened on, and repeat wraps `--retry` rather than the other way round (#1013)
- Flaky is a first-class outcome: a test that only passed after a retry is counted separately, kept inside the pass total so the exit code is unchanged, and carried into JUnit (`<flakyFailure>`), TAP, JSON, HTML and GitHub Actions along with the first attempt's failure message. `--fail-on-flaky` turns such a run red (#1012)
Expand Down
2 changes: 1 addition & 1 deletion adrs/adr-011-source-layout-and-build-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Seventeen, in load order. The order is the dependency layering: leaves first.
| 3 | `util/` | 4 | 474 | computation: strings, arithmetic, time |
| 4 | `api/` | 5 | 205 | the surface a user's test file calls (except assertions) |
| 5 | `config/` | 4 | 961 | `BASHUNIT_*` defaults, scratch dirs, parallel mode, rerun cache |
| 6 | `coverage/` | 13 | 2640 | line/branch tracking and the four report formats |
| 6 | `coverage/` | 14 | 2917 | line/branch tracking, diff coverage and the four report formats |
| 7 | `state/` | 6 | 474 | counters, per-test context, result payload, parallel aggregation |
| 8 | `console/` | 9 | 1278 | everything printed: palette, header, per-test lines, totals |
| 9 | `helper/` | 8 | 948 | naming, discovery, data providers, tags, encoding |
Expand Down
1 change: 1 addition & 0 deletions completions/_bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ _bashunit() {
'--coverage-report[LCOV output path]:file:_files' \
'--coverage-report-html[Generate HTML coverage report]::dir:_files' \
'--coverage-min[Minimum coverage threshold]:percent:' \
'--coverage-diff[Report coverage only for lines changed since ref]:ref:' \
'--no-coverage-report[Console coverage output only]' \
'(-h --help)'{-h,--help}'[Show help message]' \
'*:path:_files' && ret=0
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ _BASHUNIT_COMPLETIONS_SUBCOMMANDS="test bench doc init learn upgrade assert watc
_BASHUNIT_COMPLETIONS_DOC_OPTS="--custom -e --env --boot -h --help"

_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --changed --coverage --coverage-exclude \
--coverage-min --coverage-paths --coverage-report --coverage-report-html \
--coverage-diff --coverage-min --coverage-paths --coverage-report --coverage-report-html \
--debug --detailed --dry-run --env --exclude-filter --exclude-tag --fail-on-flaky --fail-on-risky --failures-only \
--filter --gha-annotations --help --jobs --list --list-format --log-gha --log-junit --login --no-color \
--no-coverage-report --no-output --no-output-on-failure --no-parallel \
Expand Down
60 changes: 60 additions & 0 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ bashunit test tests/ --parallel --simple
| `--coverage-report [file]` | LCOV output path (default: `coverage/lcov.info`) |
| `--coverage-report-html [dir]` | Generate HTML report (default: `coverage/html`) |
| `--coverage-min <percent>` | Minimum coverage threshold |
| `--coverage-diff <ref>` | Report coverage only for lines changed since ref |
| `--no-coverage-report` | Console output only, no LCOV file |

### Standalone Assert
Expand Down Expand Up @@ -1160,12 +1161,71 @@ bashunit test tests/ --coverage --coverage-paths src/,lib/ --coverage-min 80
| `--coverage-report [file]` | LCOV output file path (default: `coverage/lcov.info`) |
| `--coverage-report-html [dir]` | Generate HTML report (default: `coverage/html`) |
| `--coverage-min <percent>` | Minimum coverage percentage; fails if below |
| `--coverage-diff <ref>` | Report only the lines changed since `<ref>` |
| `--no-coverage-report` | Show console report only, don't generate LCOV file |

::: tip
Coverage works with parallel execution (`-p`). Each worker tracks coverage independently, and results are aggregated before reporting.
:::

### Diff coverage

> `bashunit test --coverage --coverage-diff <base-ref>`

Answers the question a pull request actually asks — *are the lines I touched
covered?* — instead of reporting a whole-file percentage that moves for reasons
unrelated to the change under review.

```bash
bashunit test tests/ --coverage --coverage-diff main
```

```
Diff Coverage (vs main)
---------------
src/parser.sh 7/ 9 lines ( 77%)
---------------
Total: 7/9 (77%)
```

Only lines **added or modified** since the ref are counted, from three sources
merged together: commits since the merge base, staged and unstaged edits, and
untracked files (counted in full). A pure deletion contributes nothing — there
is no line left to hold an opinion about — and changed lines that are not
executable (comments, `fi`, blank) are ignored, so a comment-only commit is not
penalised.

The base ref is **required**. It is not defaulted, because an optional value
would make `--coverage-diff tests/` swallow the path as a ref.

**With `--coverage-min`, the gate follows the report:** the threshold applies to
the diff percentage, so a change that fully covers itself passes even inside a
poorly covered file.

```bash
bashunit test tests/ --coverage --coverage-diff origin/main --coverage-min 90
```

A change with no executable lines scores **100%**, not 0% — otherwise a
docs-only commit would fail the gate.

`--coverage-diff` restricts the **console report only**. LCOV and HTML stay
whole-file, because their consumers (`genhtml`, Codecov) do their own diffing
and expect complete records.

::: warning Shallow clones
This needs `git` and a ref that resolves locally. CI checkouts are often shallow
and have no base ref, which would otherwise report "no changed lines" and pass a
threshold while measuring nothing — so an unresolvable ref is a hard error
instead. Fetch it first:

```yaml
- uses: actions/checkout@v4
with:
fetch-depth: 0
```
:::

## bench

> `bashunit bench [path] [options]`
Expand Down
4 changes: 4 additions & 0 deletions src/config/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ _BASHUNIT_DEFAULT_COVERAGE_SHOW_LINE_HITS="false"
# Tracing engine: auto|xtrace|trap. auto takes the xtrace fast path wherever
# BASH_XTRACEFD exists (Bash 4.1+) and the DEBUG trap below it (ADR-009, #860)
_BASHUNIT_DEFAULT_COVERAGE_ENGINE="auto"
# Restrict the coverage text report to lines changed against this ref
_BASHUNIT_DEFAULT_COVERAGE_DIFF=""

: "${BASHUNIT_DEFAULT_PATH:=${DEFAULT_PATH:=$_BASHUNIT_DEFAULT_DEFAULT_PATH}}"
: "${BASHUNIT_DEV_LOG:=${DEV_LOG:=$_BASHUNIT_DEFAULT_DEV_LOG}}"
Expand Down Expand Up @@ -212,6 +214,8 @@ BASHUNIT_WATCH_INTERVAL=$(bashunit::env::positive_int_or_default \
# No bare COVERAGE_ENGINE alias: the unprefixed forms are deprecated, so a new
# setting only ever ships under the BASHUNIT_ prefix.
: "${BASHUNIT_COVERAGE_ENGINE:=$_BASHUNIT_DEFAULT_COVERAGE_ENGINE}"
# No bare COVERAGE_DIFF alias, same reasoning as COVERAGE_ENGINE above.
: "${BASHUNIT_COVERAGE_DIFF:=$_BASHUNIT_DEFAULT_COVERAGE_DIFF}"

# Booleans
_BASHUNIT_DEFAULT_PARALLEL_RUN="false"
Expand Down
1 change: 1 addition & 0 deletions src/console/header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ Coverage:
--coverage-report [file] Output file (default: coverage/lcov.info)
--coverage-report-html [dir] HTML report (default: coverage/html)
--coverage-min <pct> Fail if coverage below percentage
--coverage-diff <ref> Report coverage only for lines changed since ref
--no-coverage-report Disable file output, console only

Examples:
Expand Down
141 changes: 141 additions & 0 deletions src/coverage/diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env bash

# Diff coverage: restrict the report to lines changed against a base ref.
#
# A whole-file percentage cannot answer the question a pull request actually
# asks — "are the lines I touched covered?" — and it moves for reasons unrelated
# to the change under review: adding a well-covered file raises the total while
# saying nothing about the new code (#1032).
#
# Only the text report is restricted. LCOV and HTML stay whole-file, because
# their consumers (genhtml, Codecov) do their own diffing and expect complete
# records.

##
# Whether --coverage-diff was requested.
##
function bashunit::coverage::is_diff_enabled() {
[ -n "${BASHUNIT_COVERAGE_DIFF:-}" ]
}

##
# The base ref the diff is taken against.
##
function bashunit::coverage::diff_base() {
echo "${BASHUNIT_COVERAGE_DIFF:-}"
}

##
# Percentage of changed executable lines that were hit.
# Nothing changed means nothing to answer for, which is 100% rather than 0% —
# otherwise a docs-only commit would fail a diff threshold.
# Arguments: $1 - changed executable lines, $2 - of those, hit
##
function bashunit::coverage::diff_percentage() {
local total="$1"
local hit="$2"
if [ "$total" -le 0 ]; then
echo "100"
return 0
fi
echo $((hit * 100 / total))
}

##
# Counts, for one file, the changed lines that are executable and how many of
# those were hit. Output format: "changed_executable:hit"
#
# The caller must have loaded the file's hit data into
# _BASHUNIT_COVERAGE_HITS_BY_LINE first (same contract as compute_file_coverage).
# Arguments: $1 - base ref, $2 - path to the file
##
function bashunit::coverage::changed_line_stats() {
local base="$1"
local file="$2"

local changed
changed="$(bashunit::helper::git_changed_lines "$base" "$file")"
if [ -z "$changed" ]; then
echo "0:0"
return 0
fi

# One pass over the source into an indexed array: the alternative is a read
# per changed line, and the report path is already the expensive half of a
# coverage run (#1005).
local -a src=()
local _i=0 _l
while IFS= read -r _l || [ -n "$_l" ]; do
src[_i]="$_l"
_i=$((_i + 1))
done <"$file"

local total=0 hit=0 lineno content
for lineno in $changed; do
content="${src[$((lineno - 1))]:-}"
if bashunit::coverage::is_executable_line "$content" "$lineno"; then
total=$((total + 1))
if [ "${_BASHUNIT_COVERAGE_HITS_BY_LINE[lineno]:-0}" -gt 0 ]; then
hit=$((hit + 1))
fi
fi
done

echo "${total}:${hit}"
}

##
# Renders the diff coverage report, replacing the whole-file text report.
# Returns: 0 always; the threshold gate is checked separately.
##
function bashunit::coverage::report_diff() {
local base
base="$(bashunit::coverage::diff_base)"

echo ""
bashunit::coverage::print_engine_notice
printf 'Diff Coverage (vs %s)\n' "$base"
echo "---------------"

local total_changed=0 total_hit=0 has_files=false
local file stats changed hit pct color reset="$_BASHUNIT_COLOR_DEFAULT"
while IFS= read -r file; do
{ [ -z "$file" ] || [ ! -f "$file" ]; } && continue

bashunit::coverage::load_hits_by_line "$file"
stats="$(bashunit::coverage::changed_line_stats "$base" "$file")"
changed="${stats%%:*}"
hit="${stats##*:}"
[ "$changed" -eq 0 ] && continue

has_files=true
total_changed=$((total_changed + changed))
total_hit=$((total_hit + hit))

pct=$(bashunit::coverage::diff_percentage "$changed" "$hit")
color=$(bashunit::coverage::get_color_for_class \
"$(bashunit::coverage::get_coverage_class "$pct")")

local display_file="${file#"$(pwd)"/}"
printf "%s%-40s %3d/%3d lines (%3d%%)%s\n" \
"$color" "$display_file" "$hit" "$changed" "$pct" "$reset"
done < <(bashunit::coverage::get_tracked_files)

if [ "$has_files" = false ]; then
echo "No changed executable lines."
fi

echo "---------------"
local total_pct
total_pct=$(bashunit::coverage::diff_percentage "$total_changed" "$total_hit")
color=$(bashunit::coverage::get_color_for_class \
"$(bashunit::coverage::get_coverage_class "$total_pct")")
printf "%sTotal: %d/%d (%d%%)%s\n" \
"$color" "$total_hit" "$total_changed" "$total_pct" "$reset"

_BASHUNIT_COVERAGE_DIFF_PCT_OUT="$total_pct"
}

# Set by report_diff so check_threshold can gate on the diff percentage rather
# than the whole-file one when --coverage-diff is active.
_BASHUNIT_COVERAGE_DIFF_PCT_OUT=""
1 change: 1 addition & 0 deletions src/coverage/index.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ source "$BASHUNIT_ROOT_DIR/src/coverage/engine.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/stats.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/branches.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/report_text.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/diff.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/report_lcov.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/report_html.sh"
source "$BASHUNIT_ROOT_DIR/src/coverage/html_index.sh"
Expand Down
9 changes: 8 additions & 1 deletion src/coverage/stats.sh
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,14 @@ function bashunit::coverage::check_threshold() {
fi

local pct
pct=$(bashunit::coverage::get_percentage)
# Under --coverage-diff the report is about the changed lines, so the gate
# must be too: keeping the whole-file percentage here would fail a PR for
# untouched code it did not write.
if bashunit::coverage::is_diff_enabled && [ -n "$_BASHUNIT_COVERAGE_DIFF_PCT_OUT" ]; then
pct="$_BASHUNIT_COVERAGE_DIFF_PCT_OUT"
else
pct=$(bashunit::coverage::get_percentage)
fi

if [ "$pct" -lt "$BASHUNIT_COVERAGE_MIN" ]; then
printf "%sCoverage %d%% is below minimum %d%%%s\n" \
Expand Down
42 changes: 42 additions & 0 deletions src/helper/git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,48 @@ function bashunit::helper::git_changed_files() {
!seen[$0]++'
}

##
# Echoes the line numbers added or modified in one file since the ref, one per
# line, ascending and deduplicated.
#
# Merges the same three sources as git_changed_files, for the same reason: the
# commit range misses working-tree edits and neither knows about a file no
# commit has seen. An untracked file counts as changed in full.
#
# Only the "+" side of each hunk is reported: a pure deletion (`+N,0`) leaves no
# line that coverage could hold an opinion about.
# Arguments: $1 - the ref, $2 - path to the file
##
function bashunit::helper::git_changed_lines() {
local ref=$1
local file=$2

if git ls-files --error-unmatch -- "$file" >/dev/null 2>&1; then
{
git diff --unified=0 -M "$ref...HEAD" -- "$file" 2>/dev/null
git diff --unified=0 -M HEAD -- "$file" 2>/dev/null
} | awk '
/^@@ / {
# @@ -old,count +new,count @@
plus = $3
sub(/^\+/, "", plus)
n = index(plus, ",")
if (n == 0) { start = plus + 0; len = 1 }
else { start = substr(plus, 1, n - 1) + 0; len = substr(plus, n + 1) + 0 }
for (i = 0; i < len; i++) { seen[start + i] = 1 }
}
END { for (l in seen) { print l + 0 } }
' | sort -n -u
return 0
fi

# Untracked (and not ignored): every line is new.
if [ -f "$file" ] &&
[ -n "$(git ls-files --others --exclude-standard -- "$file" 2>/dev/null)" ]; then
awk 'END { for (i = 1; i <= NR; i++) print i }' "$file"
fi
}

##
# Echoes the given candidate files that changed since the ref, preserving the
# caller order and path spelling. A leading "./" is ignored on both sides:
Expand Down
6 changes: 5 additions & 1 deletion src/main/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,11 @@ function bashunit::main::exec_tests() {

bashunit::coverage::precompute_file_stats

bashunit::coverage::report_text
if bashunit::coverage::is_diff_enabled; then
bashunit::coverage::report_diff
else
bashunit::coverage::report_text
fi

if [ -n "$BASHUNIT_COVERAGE_REPORT" ]; then
bashunit::coverage::report_lcov "$BASHUNIT_COVERAGE_REPORT"
Expand Down
8 changes: 8 additions & 0 deletions src/main/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ function bashunit::main::cmd_test() {
_bashunit_coverage_opt_set=true
shift
;;
--coverage-diff)
# The base ref is required rather than defaulted: an optional value would
# make `--coverage-diff tests/` swallow the path as a ref.
# shellcheck disable=SC2034
BASHUNIT_COVERAGE_DIFF="$2"
_bashunit_coverage_opt_set=true
shift
;;
--no-coverage-report)
# shellcheck disable=SC2034
BASHUNIT_COVERAGE_REPORT=""
Expand Down
Loading
Loading