From 97877183df2747e95d8c0539e443dfb71ed64a6b Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 10 Aug 2026 20:55:09 +0200 Subject: [PATCH] feat(coverage): --coverage-diff to report only lines changed since a ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A whole-file percentage cannot answer the question a pull request asks — are the lines I touched covered — and it moves for reasons unrelated to the change: adding a well-covered file raises the total while saying nothing about the new code. --coverage-diff restricts the console report to lines added or modified since the ref, merging the three sources git_changed_files already merges (commit range, working tree, untracked-in-full). A pure deletion contributes nothing, and changed lines that are not executable are ignored, so a comment-only commit is not penalised. With --coverage-min the gate follows the report, so a change that fully covers itself passes inside a poorly covered file. A change with no executable lines scores 100%, not 0%. The base ref is required rather than defaulted: an optional value would make `--coverage-diff tests/` swallow the path as a ref. A missing repository, missing git, or unresolvable ref is a hard error — on a shallow clone the base is simply absent, which would otherwise report "no changed lines" and pass a threshold while measuring nothing. LCOV and HTML stay whole-file; their consumers do their own diffing. Closes #1032 --- .env.example | 1 + CHANGELOG.md | 1 + ...dr-011-source-layout-and-build-pipeline.md | 2 +- completions/_bashunit | 1 + completions/bashunit.bash | 2 +- docs/command-line.md | 60 +++++++ src/config/env.sh | 4 + src/console/header.sh | 1 + src/coverage/diff.sh | 141 +++++++++++++++ src/coverage/index.sh | 1 + src/coverage/stats.sh | 9 +- src/helper/git.sh | 42 +++++ src/main/run.sh | 6 +- src/main/test.sh | 8 + src/main/validate.sh | 24 +++ .../acceptance/bashunit_coverage_diff_test.sh | 164 ++++++++++++++++++ tests/unit/coverage/diff_test.sh | 121 +++++++++++++ tests/unit/helper/git_changed_lines_test.sh | 103 +++++++++++ 18 files changed, 687 insertions(+), 4 deletions(-) create mode 100644 src/coverage/diff.sh create mode 100644 tests/acceptance/bashunit_coverage_diff_test.sh create mode 100644 tests/unit/coverage/diff_test.sh create mode 100644 tests/unit/helper/git_changed_lines_test.sh diff --git a/.env.example b/.env.example index ef2125e2..d2ea2eea 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4540b7e5..d027617e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- `--coverage-diff ` restricts the coverage console report to lines changed since a base ref, and `--coverage-min` then gates on that diff percentage (#1032) - `--gha-annotations ` controls GitHub Actions annotations on stdout; `auto` turns them on inside GitHub Actions and stays quiet everywhere else (#1014) - `--repeat ` 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 (``), TAP, JSON, HTML and GitHub Actions along with the first attempt's failure message. `--fail-on-flaky` turns such a run red (#1012) diff --git a/adrs/adr-011-source-layout-and-build-pipeline.md b/adrs/adr-011-source-layout-and-build-pipeline.md index 7cdfdad5..9fb34ee5 100644 --- a/adrs/adr-011-source-layout-and-build-pipeline.md +++ b/adrs/adr-011-source-layout-and-build-pipeline.md @@ -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 | diff --git a/completions/_bashunit b/completions/_bashunit index 785a74b8..cfb8519d 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -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 diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 653a0ace..3e000baa 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -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 \ diff --git a/docs/command-line.md b/docs/command-line.md index ff750910..6b378a28 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -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 ` | Minimum coverage threshold | +| `--coverage-diff ` | Report coverage only for lines changed since ref | | `--no-coverage-report` | Console output only, no LCOV file | ### Standalone Assert @@ -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 ` | Minimum coverage percentage; fails if below | +| `--coverage-diff ` | Report only the lines changed since `` | | `--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 ` + +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]` diff --git a/src/config/env.sh b/src/config/env.sh index b0841b53..030a02d2 100644 --- a/src/config/env.sh +++ b/src/config/env.sh @@ -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}}" @@ -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" diff --git a/src/console/header.sh b/src/console/header.sh index e00076bc..d7f5b060 100644 --- a/src/console/header.sh +++ b/src/console/header.sh @@ -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 Fail if coverage below percentage + --coverage-diff Report coverage only for lines changed since ref --no-coverage-report Disable file output, console only Examples: diff --git a/src/coverage/diff.sh b/src/coverage/diff.sh new file mode 100644 index 00000000..68c8c40f --- /dev/null +++ b/src/coverage/diff.sh @@ -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="" diff --git a/src/coverage/index.sh b/src/coverage/index.sh index 3181f942..92fe284f 100644 --- a/src/coverage/index.sh +++ b/src/coverage/index.sh @@ -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" diff --git a/src/coverage/stats.sh b/src/coverage/stats.sh index 7091d36b..b4c94db6 100644 --- a/src/coverage/stats.sh +++ b/src/coverage/stats.sh @@ -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" \ diff --git a/src/helper/git.sh b/src/helper/git.sh index 9e2cb680..1a15e079 100644 --- a/src/helper/git.sh +++ b/src/helper/git.sh @@ -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: diff --git a/src/main/run.sh b/src/main/run.sh index d465a3cb..a12ada9b 100644 --- a/src/main/run.sh +++ b/src/main/run.sh @@ -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" diff --git a/src/main/test.sh b/src/main/test.sh index b4b1abd8..38c15e4a 100644 --- a/src/main/test.sh +++ b/src/main/test.sh @@ -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="" diff --git a/src/main/validate.sh b/src/main/validate.sh index ace5a09c..4dc3845a 100644 --- a/src/main/validate.sh +++ b/src/main/validate.sh @@ -175,6 +175,30 @@ function bashunit::main::validate_config_or_exit() { ;; esac + # --coverage-diff needs a repository and a resolvable ref. Left unchecked, a + # shallow CI clone (where the base ref is simply absent) would report every + # file as "no changed lines" and pass a diff threshold while measuring + # nothing — a silent false pass, not a missing feature. + if [ -n "${BASHUNIT_COVERAGE_DIFF:-}" ]; then + if ! bashunit::dependencies::has_git; then + printf "%sError: --coverage-diff needs git, which was not found.%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 + exit 1 + fi + if ! bashunit::helper::git_is_repo; then + printf "%sError: --coverage-diff needs a git repository; '%s' is not inside one.%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "$(pwd)" "${_BASHUNIT_COLOR_DEFAULT}" >&2 + exit 1 + fi + if ! bashunit::helper::git_ref_exists "${BASHUNIT_COVERAGE_DIFF}"; then + printf "%sError: --coverage-diff base '%s' does not resolve to a commit. \ +On a shallow clone, fetch it first (git fetch --depth=... origin %s).%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "${BASHUNIT_COVERAGE_DIFF}" \ + "${BASHUNIT_COVERAGE_DIFF}" "${_BASHUNIT_COLOR_DEFAULT}" >&2 + exit 1 + fi + fi + # Same shape as --output above: an unrecognised name would otherwise fall # through to the default renderer and look like it worked. case "${BASHUNIT_LIST_FORMAT:-}" in diff --git a/tests/acceptance/bashunit_coverage_diff_test.sh b/tests/acceptance/bashunit_coverage_diff_test.sh new file mode 100644 index 00000000..cdc2842b --- /dev/null +++ b/tests/acceptance/bashunit_coverage_diff_test.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --coverage-diff restricts the coverage text report to lines changed against a +# base ref (#1032). + +# Builds a repo containing a source file, a test that covers part of it, and a +# `base` branch on the initial commit. +function _diff_project() { + local repo + repo="$(cd "$(bashunit::temp_dir diff_project)" && pwd)" + ( + cd "$repo" || exit 1 + git init -q + git symbolic-ref HEAD refs/heads/main + git config user.email "test@bashunit.dev" + git config user.name "bashunit test" + git config commit.gpgsign false + { + echo '#!/usr/bin/env bash' + echo 'function covered() { COVERED_OUT="covered"; }' + echo 'function uncovered() { UNCOVERED_OUT="uncovered"; }' + } >lib.sh + { + echo "source \"$repo/lib.sh\"" + echo 'function test_covered() { covered; assert_not_empty "$COVERED_OUT"; }' + } >suite_test.sh + git add . + git commit -q -m "initial" + git branch base + ) >/dev/null 2>&1 + echo "$repo" +} + +function _run_diff_coverage() { # $1 repo, $2.. extra args + local repo="$1" + shift + ( + cd "$repo" || exit 1 + BASHUNIT_COVERAGE_PATHS="$repo" \ + BASHUNIT_COVERAGE_REPORT="" \ + "$OLDPWD/bashunit" --no-parallel --coverage "$@" ./suite_test.sh 2>&1 + ) | sed 's/\x1B\[[0-9;]*m//g' +} + +function test_diff_coverage_reports_only_changed_lines() { + local repo + repo="$(_diff_project)" + # Touch only the covered function's body line. + (cd "$repo" && sed 's/COVERED_OUT="covered"/COVERED_OUT="covered now"/' lib.sh >tmp && mv tmp lib.sh) + + local output + output="$(_run_diff_coverage "$repo" --coverage-diff base)" + + assert_contains "Diff Coverage (vs base)" "$output" + assert_contains "Total: 1/1 (100%)" "$output" +} + +function test_diff_coverage_reports_an_uncovered_changed_line() { + local repo + repo="$(_diff_project)" + (cd "$repo" && sed 's/UNCOVERED_OUT="uncovered"/UNCOVERED_OUT="uncovered now"/' lib.sh >tmp && mv tmp lib.sh) + + local output + output="$(_run_diff_coverage "$repo" --coverage-diff base)" + + assert_contains "Total: 0/1 (0%)" "$output" +} + +# A docs-only or comment-only change has nothing executable to answer for, so +# the diff percentage is 100 rather than 0 — otherwise it would fail a gate. +function test_diff_coverage_is_one_hundred_when_nothing_executable_changed() { + local repo + repo="$(_diff_project)" + printf '\n# just a comment\n' >>"$repo/lib.sh" + + local output + output="$(_run_diff_coverage "$repo" --coverage-diff base)" + + assert_contains "No changed executable lines." "$output" + assert_contains "Total: 0/0 (100%)" "$output" +} + +function test_without_the_flag_the_whole_file_report_is_unchanged() { + local repo + repo="$(_diff_project)" + + local output + output="$(_run_diff_coverage "$repo")" + + assert_contains "Coverage Report" "$output" + assert_not_contains "Diff Coverage" "$output" +} + +# The gate must follow the report: a PR that fully covers its own change passes +# even when the file as a whole is poorly covered. +# Echoes the exit code; the reporting helper pipes through sed, which would +# otherwise report sed's status instead of bashunit's. +function _diff_coverage_code() { # $1 repo, $2.. extra args + local repo="$1" + shift + local code=0 + ( + cd "$repo" || exit 1 + BASHUNIT_COVERAGE_PATHS="$repo" \ + BASHUNIT_COVERAGE_REPORT="" \ + "$OLDPWD/bashunit" --no-parallel --coverage "$@" ./suite_test.sh + ) >/dev/null 2>&1 || code=$? + echo "$code" +} + +function test_the_threshold_gates_on_the_diff_percentage() { + local repo + repo="$(_diff_project)" + (cd "$repo" && sed 's/COVERED_OUT="covered"/COVERED_OUT="covered now"/' lib.sh >tmp && mv tmp lib.sh) + + assert_equals 0 "$(_diff_coverage_code "$repo" --coverage-diff base --coverage-min 100)" +} + +function test_the_threshold_still_fails_on_an_uncovered_change() { + local repo + repo="$(_diff_project)" + (cd "$repo" && sed 's/UNCOVERED_OUT="uncovered"/UNCOVERED_OUT="uncovered now"/' lib.sh >tmp && mv tmp lib.sh) + + assert_equals 1 "$(_diff_coverage_code "$repo" --coverage-diff base --coverage-min 100)" +} + +function test_an_unresolvable_base_ref_fails_loudly() { + local repo + repo="$(_diff_project)" + + local output + output="$(_run_diff_coverage "$repo" --coverage-diff no-such-ref || true)" + + assert_contains "does not resolve to a commit" "$output" +} + +function test_an_unresolvable_base_ref_exits_non_zero() { + local repo + repo="$(_diff_project)" + + assert_equals 1 "$(_diff_coverage_code "$repo" --coverage-diff no-such-ref)" +} + +function test_running_outside_a_repository_fails_loudly() { + local dir + dir="$(cd "$(bashunit::temp_dir not_a_repo)" && pwd)" + { + echo '#!/usr/bin/env bash' + echo 'function covered() { echo "covered"; }' + } >"$dir/lib.sh" + { + echo "source \"$dir/lib.sh\"" + echo 'function test_covered() { assert_not_empty "$(covered)"; }' + } >"$dir/suite_test.sh" + + # Captured before the cd: inside the subshell $PWD is already the temp dir. + local root="$PWD" + local output + output="$( (cd "$dir" && "$root/bashunit" --no-parallel --coverage \ + --coverage-diff base ./suite_test.sh 2>&1) | sed 's/\x1B\[[0-9;]*m//g' || true)" + + assert_contains "needs a git repository" "$output" +} diff --git a/tests/unit/coverage/diff_test.sh b/tests/unit/coverage/diff_test.sh new file mode 100644 index 00000000..eba7db08 --- /dev/null +++ b/tests/unit/coverage/diff_test.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2317 + +# Diff coverage: restrict the report to lines changed against a base ref +# (#1032). The question a PR actually asks is "are the lines I touched +# covered", which a whole-file percentage cannot answer. + +function _diff_repo() { + local repo + repo="$(bashunit::temp_dir diff_cov_repo)" + ( + cd "$repo" || exit 1 + git init -q + git symbolic-ref HEAD refs/heads/main + git config user.email "test@bashunit.dev" + git config user.name "bashunit test" + git config commit.gpgsign false + printf 'echo a\necho b\necho c\n' >lib.sh + git add . + git commit -q -m "initial" + git branch base + ) >/dev/null 2>&1 + echo "$repo" +} + +function test_changed_line_stats_counts_only_changed_executable_lines() { + local repo + repo="$(_diff_repo)" + printf 'echo a\necho B\necho C\n' >"$repo/lib.sh" + + # Line 2 changed and was hit; line 3 changed and was not. + _BASHUNIT_COVERAGE_HITS_BY_LINE=() + _BASHUNIT_COVERAGE_HITS_BY_LINE[2]=1 + + local result + result=$(cd "$repo" && bashunit::coverage::changed_line_stats "base" "lib.sh") + + assert_same "2:1" "$result" +} + +function test_changed_line_stats_is_zero_when_nothing_changed() { + local repo + repo="$(_diff_repo)" + + _BASHUNIT_COVERAGE_HITS_BY_LINE=() + + local result + result=$(cd "$repo" && bashunit::coverage::changed_line_stats "base" "lib.sh") + + assert_same "0:0" "$result" +} + +# A changed comment is not executable, so it must not drag the percentage down. +function test_changed_line_stats_ignores_non_executable_changed_lines() { + local repo + repo="$(_diff_repo)" + printf 'echo a\n# a new comment\necho c\n' >"$repo/lib.sh" + + _BASHUNIT_COVERAGE_HITS_BY_LINE=() + + local result + result=$(cd "$repo" && bashunit::coverage::changed_line_stats "base" "lib.sh") + + assert_same "0:0" "$result" +} + +function test_changed_line_stats_counts_every_changed_executable_line_as_hit() { + local repo + repo="$(_diff_repo)" + printf 'echo a\necho B\necho C\n' >"$repo/lib.sh" + + _BASHUNIT_COVERAGE_HITS_BY_LINE=() + _BASHUNIT_COVERAGE_HITS_BY_LINE[2]=3 + _BASHUNIT_COVERAGE_HITS_BY_LINE[3]=1 + + local result + result=$(cd "$repo" && bashunit::coverage::changed_line_stats "base" "lib.sh") + + assert_same "2:2" "$result" +} + +function test_diff_percentage_is_one_hundred_when_nothing_changed() { + assert_same "100" "$(bashunit::coverage::diff_percentage 0 0)" +} + +function test_diff_percentage_rounds_down() { + assert_same "66" "$(bashunit::coverage::diff_percentage 3 2)" +} + +function test_diff_base_defaults_to_the_changed_ref_helper() { + local previous="${BASHUNIT_COVERAGE_DIFF:-}" + BASHUNIT_COVERAGE_DIFF="my-base" + + local actual + actual="$(bashunit::coverage::diff_base)" + BASHUNIT_COVERAGE_DIFF="$previous" + + assert_same "my-base" "$actual" +} + +function test_diff_is_disabled_by_default() { + local previous="${BASHUNIT_COVERAGE_DIFF:-}" + BASHUNIT_COVERAGE_DIFF="" + + local exit_code=0 + bashunit::coverage::is_diff_enabled || exit_code=$? + BASHUNIT_COVERAGE_DIFF="$previous" + + assert_equals 1 "$exit_code" +} + +function test_diff_is_enabled_when_a_base_is_set() { + local previous="${BASHUNIT_COVERAGE_DIFF:-}" + BASHUNIT_COVERAGE_DIFF="main" + + local exit_code=0 + bashunit::coverage::is_diff_enabled || exit_code=$? + BASHUNIT_COVERAGE_DIFF="$previous" + + assert_equals 0 "$exit_code" +} diff --git a/tests/unit/helper/git_changed_lines_test.sh b/tests/unit/helper/git_changed_lines_test.sh new file mode 100644 index 00000000..387329bc --- /dev/null +++ b/tests/unit/helper/git_changed_lines_test.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +# Changed-line extraction behind --coverage-diff (#1032). Mirrors the three +# sources git_changed_files merges (commit range, working tree, untracked), but +# reports line numbers rather than paths. + +function _lines_repo() { + local repo + repo="$(bashunit::temp_dir changed_lines_repo)" + ( + cd "$repo" || exit 1 + git init -q + git symbolic-ref HEAD refs/heads/main + git config user.email "test@bashunit.dev" + git config user.name "bashunit test" + git config commit.gpgsign false + printf 'one\ntwo\nthree\nfour\nfive\n' >lib.sh + git add . + git commit -q -m "initial" + git branch base + ) >/dev/null 2>&1 + echo "$repo" +} + +function test_changed_lines_reports_a_modified_line() { + local repo + repo="$(_lines_repo)" + printf 'one\nTWO\nthree\nfour\nfive\n' >"$repo/lib.sh" + + assert_same "2" "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +} + +function test_changed_lines_reports_a_contiguous_block() { + local repo + repo="$(_lines_repo)" + printf 'one\nTWO\nTHREE\nfour\nfive\n' >"$repo/lib.sh" + + assert_same "\ +2 +3" "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +} + +function test_changed_lines_reports_added_lines_at_the_end() { + local repo + repo="$(_lines_repo)" + printf 'one\ntwo\nthree\nfour\nfive\nsix\n' >"$repo/lib.sh" + + assert_same "6" "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +} + +# A pure deletion has no added lines, so there is nothing to hold coverage +# against — the hunk header reads "+N,0". +function test_changed_lines_ignores_a_pure_deletion() { + local repo + repo="$(_lines_repo)" + printf 'one\nthree\nfour\nfive\n' >"$repo/lib.sh" + + assert_empty "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +} + +function test_changed_lines_covers_a_committed_change() { + local repo + repo="$(_lines_repo)" + ( + cd "$repo" || exit 1 + printf 'one\ntwo\nthree\nFOUR\nfive\n' >lib.sh + git commit -q -am "change four" + ) >/dev/null 2>&1 + + assert_same "4" "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +} + +# A brand-new file is entirely new, so every line counts as changed. +function test_changed_lines_treats_an_untracked_file_as_fully_changed() { + local repo + repo="$(_lines_repo)" + printf 'alpha\nbeta\n' >"$repo/new.sh" + + assert_same "\ +1 +2" "$(cd "$repo" && bashunit::helper::git_changed_lines base new.sh)" +} + +function test_changed_lines_is_empty_for_an_untouched_file() { + local repo + repo="$(_lines_repo)" + + assert_empty "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +} + +function test_changed_lines_deduplicates_across_sources() { + local repo + repo="$(_lines_repo)" + ( + cd "$repo" || exit 1 + printf 'one\nTWO\nthree\nfour\nfive\n' >lib.sh + git commit -q -am "change two" + ) >/dev/null 2>&1 + # Same line touched again, now uncommitted: it must be reported once. + printf 'one\nTWO_AGAIN\nthree\nfour\nfive\n' >"$repo/lib.sh" + + assert_same "2" "$(cd "$repo" && bashunit::helper::git_changed_lines base lib.sh)" +}