From 338dcaafd8fec085f3368078de0856b822e7107c Mon Sep 17 00:00:00 2001 From: Chemaclass Date: Mon, 10 Aug 2026 14:55:12 +0200 Subject: [PATCH] feat(cli): --changed to run only the tests touched since a git ref Selects test files from git instead of from a previous red run, so the fast loop works on the first run of a fresh branch. The selection merges three sources, because none of them sees the others: the commit range ...HEAD, the staged and unstaged edits on top of HEAD, and untracked new files. Deletions are dropped so a removed test file never reaches discovery, and rename detection selects the new path alone. Narrowing happens in exec_tests next to the shard split, after the "at least one path" guard: reaching zero files there is a real answer, so the run reports "No tests found" and exits 1 rather than dumping the help text. Outside a work tree, or with a ref that does not resolve, validate_config_or_exit fails the run instead of silently selecting nothing. Closes #1010 --- .env.example | 2 + CHANGELOG.md | 1 + completions/_bashunit | 1 + completions/bashunit.bash | 2 +- docs/command-line.md | 48 +++++- src/config/env.sh | 13 ++ src/console/header.sh | 1 + src/helper/git.sh | 96 +++++++++++- src/main/run.sh | 18 +++ src/main/test.sh | 14 ++ src/main/validate.sh | 18 +++ tests/acceptance/bashunit_changed_test.sh | 173 ++++++++++++++++++++++ tests/unit/helper/git_changed_test.sh | 173 ++++++++++++++++++++++ 13 files changed, 555 insertions(+), 5 deletions(-) create mode 100644 tests/acceptance/bashunit_changed_test.sh create mode 100644 tests/unit/helper/git_changed_test.sh diff --git a/.env.example b/.env.example index 59c4c2b2..7be7d05f 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,8 @@ BASHUNIT_NO_DIFF= # Default: false (disable unified diff on mu BASHUNIT_PARALLEL_RUN= # Default: false BASHUNIT_STOP_ON_FAILURE= # Default: false (stop suite on first failure) BASHUNIT_RERUN_FAILED= # Default: false (replay only last run's failing tests) +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_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 3616ed3a..857af8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- `--changed []` runs only the test files git reports as touched since `` (default `origin/HEAD`, then `HEAD`), covering committed, staged, unstaged and untracked changes. Deletions are dropped, a rename selects its new path, and a missing work tree or unresolvable ref fails the run instead of selecting nothing (#1010) - `--list` (alias `--dry-run`) prints the tests a run would execute, without running them; `--list-format json` emits file, function, name, line and tags. Honours every selection flag, including `--shard` and `--random-order --seed` ordering (#1007) - `--exclude-filter ` skips tests by name, the counterpart of `--exclude-tag`. Repeatable, OR'd, and wins over `--filter` (#1009) - `# @tags a b` above any top-level line applies those tags to every test in the file, unioned with per-function `# @tag` (#1008) diff --git a/completions/_bashunit b/completions/_bashunit index 7ee9a0fe..cf792846 100644 --- a/completions/_bashunit +++ b/completions/_bashunit @@ -88,6 +88,7 @@ _bashunit() { '--seed[Seed for random order]:seed:' \ '--shard[Run shard i of n]:shard:' \ '--rerun-failed[Replay only the tests that failed on the last run]' \ + '--changed[Run only the test files changed since a git ref]::ref:' \ '(--list --dry-run)'{--list,--dry-run}'[Print the tests that would run, then exit]' \ '--list-format[Rendering for --list]:format:(text json)' \ '--snapshot-update[Rewrite existing snapshots from the actual value]' \ diff --git a/completions/bashunit.bash b/completions/bashunit.bash index 13f81a97..0fc2e0fe 100644 --- a/completions/bashunit.bash +++ b/completions/bashunit.bash @@ -14,7 +14,7 @@ _BASHUNIT_COMPLETIONS_SUBCOMMANDS="test bench doc init learn upgrade assert watc # Flags accepted by the doc subcommand. _BASHUNIT_COMPLETIONS_DOC_OPTS="--custom -e --env --boot -h --help" -_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --coverage --coverage-exclude \ +_BASHUNIT_COMPLETIONS_TEST_OPTS="--assert --boot --changed --coverage --coverage-exclude \ --coverage-min --coverage-paths --coverage-report --coverage-report-html \ --debug --detailed --dry-run --env --exclude-filter --exclude-tag --fail-on-risky --failures-only \ --filter --help --jobs --list --list-format --log-gha --log-junit --login --no-color \ diff --git a/docs/command-line.md b/docs/command-line.md index 53598784..6ed3cc75 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -84,6 +84,7 @@ bashunit test tests/ --parallel --simple | `--seed ` | Seed for `--random-order` (reproducible shuffle) | | `--shard /` | Run shard i of n (split suite across runners) | | `--rerun-failed` | Replay only the tests that failed on the last run | +| `--changed []` | Run only the test files changed since `` (default: `origin/HEAD`, then `HEAD`) | | `--list`, `--dry-run` | Print the tests that would run, then exit | | `--list-format ` | Rendering for `--list`: `text` (default) or `json` | | `--snapshot-update` | Rewrite existing snapshots from the actual value | @@ -689,8 +690,8 @@ The report only considers snapshots belonging to the test files the run discovered, so running a single file or directory reports only that scope instead of everything else in the same `snapshots/` directory. A run that executes a *subset of the tests* in those files would still be misleading, so -the flag is refused alongside `--filter`, `--tag`, `--exclude-tag`, `--shard` -and `--rerun-failed`. +the flag is refused alongside `--filter`, `--tag`, `--exclude-tag`, `--shard`, +`--rerun-failed` and `--changed`. ### No snapshot create @@ -733,7 +734,7 @@ Test ids go to **stdout**, one `path::function` per line; the count goes to **stderr**, so the list pipes cleanly into `grep`, `fzf` or a CI matrix. Every selection mechanism applies exactly as it would in a real run — -`--filter`, `--tag`, `--exclude-tag`, `--shard`, `--rerun-failed`, +`--filter`, `--tag`, `--exclude-tag`, `--shard`, `--rerun-failed`, `--changed`, `--random-order --seed`, and `file::fn` / `file:LINE`. That makes it the way to answer questions that previously needed a full run per answer: @@ -820,6 +821,47 @@ BASHUNIT_RERUN_FAILED=true bashunit test tests/ ``` ::: +### Changed + +> `bashunit test --changed []` + +Run only the test files your branch touched. Where +[`--rerun-failed`](#rerun-failed) needs a previous red run, `--changed` needs +only git, so it works on the first run of a fresh branch. + +```bash +bashunit test tests/ --changed # against origin/HEAD, then HEAD +bashunit test tests/ --changed main # against main +bashunit test tests/ --changed HEAD~3 # against three commits ago +``` + +The selection is every test file git reports as touched since ``, which +merges three sources: the commit range `...HEAD`, the staged and unstaged +edits on top of `HEAD`, and untracked new files. Deleted test files are dropped, +and a rename selects its new path only. + +Notes: + +- Composes with `--filter`/`--tag` — both apply (intersection). +- The ref argument is optional, so a value that is also an existing path is read + as the run's path, not as a ref. Write `--changed ./main` or set + `BASHUNIT_CHANGED_REF` when you mean the ref. +- Outside a git work tree, or with a ref that does not resolve, the run exits + non-zero with a message rather than quietly running everything. +- No changed test file is not an error in itself: the run reports `No tests + found` and exits `1`, the same as any other empty selection. +- Source changes are not mapped to the tests that cover them. Only test files + are selected. + +::: code-group +```bash [Fastest branch loop] +bashunit test --changed main +``` +```bash [Env variables] +BASHUNIT_CHANGED=true BASHUNIT_CHANGED_REF=main bashunit test tests/ +``` +::: + ### No Progress > `bashunit test --no-progress` diff --git a/src/config/env.sh b/src/config/env.sh index 6df279b9..fbb7d529 100644 --- a/src/config/env.sh +++ b/src/config/env.sh @@ -255,6 +255,10 @@ _BASHUNIT_DEFAULT_SHARD_INDEX="" _BASHUNIT_DEFAULT_SHARD_TOTAL="" # Replay only the tests recorded as failing by the previous run _BASHUNIT_DEFAULT_RERUN_FAILED="false" +# Run only the test files git reports as changed since a ref +_BASHUNIT_DEFAULT_CHANGED="false" +# The ref --changed diffs against (empty = origin/HEAD, then HEAD) +_BASHUNIT_DEFAULT_CHANGED_REF="" # Skip tests whose name matches (comma-separated; the counterpart of --filter) _BASHUNIT_DEFAULT_EXCLUDE_FILTER="" # Print the tests that would run and exit, without running any of them @@ -307,6 +311,11 @@ _BASHUNIT_DEFAULT_SNAPSHOT_REPORT_UNUSED="false" # lives here rather than inline in rerun.sh so every BASHUNIT_* default has one # home; bashunit::rerun::is_enabled keeps its :- guard for callers that unset it. : "${BASHUNIT_RERUN_FAILED:=$_BASHUNIT_DEFAULT_RERUN_FAILED}" +# No bare CHANGED/CHANGED_REF aliases, same reasoning: `CHANGED` in the +# environment silently cutting a full run down to a handful of files is the +# surprise the unprefixed forms caused (#866). +: "${BASHUNIT_CHANGED:=$_BASHUNIT_DEFAULT_CHANGED}" +: "${BASHUNIT_CHANGED_REF:=$_BASHUNIT_DEFAULT_CHANGED_REF}" # No bare LIST/LIST_FORMAT aliases: `LIST` is far too generic a name to let the # environment turn a real run into a no-op query. # No bare EXCLUDE_FILTER alias: a generic name silently dropping tests from a @@ -383,6 +392,10 @@ function bashunit::env::is_shard_enabled() { [ -n "${BASHUNIT_SHARD_INDEX:-}" ] && [ -n "${BASHUNIT_SHARD_TOTAL:-}" ] } +function bashunit::env::is_changed_enabled() { + [ "${BASHUNIT_CHANGED:-false}" = "true" ] +} + function bashunit::env::shard_index() { printf '%s' "${BASHUNIT_SHARD_INDEX:-}" } diff --git a/src/console/header.sh b/src/console/header.sh index 99d021ab..08b3716d 100644 --- a/src/console/header.sh +++ b/src/console/header.sh @@ -142,6 +142,7 @@ Options: --seed Seed for --random-order (reproducible shuffle) --shard / Run shard i of n (split the suite across runners) --rerun-failed Replay only the tests that failed on the last run (.bashunit/last-failed) + --changed [] Run only the test files changed since (default: origin/HEAD, then HEAD) --list, --dry-run Print the tests that would run, then exit without running them --list-format Rendering for --list: text (default) or json --snapshot-update Rewrite existing snapshots from the actual value (combine with --filter) diff --git a/src/helper/git.sh b/src/helper/git.sh index 4d252c7a..9e2cb680 100644 --- a/src/helper/git.sh +++ b/src/helper/git.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Remote tag lookup, used by the upgrade subcommand. +# Remote tag lookup for the upgrade subcommand, and the working-tree queries +# behind --changed. declare -r BASHUNIT_GIT_REPO="https://github.com/TypedDevs/bashunit" @@ -19,6 +20,99 @@ function bashunit::helper::get_latest_tag() { head -n 1 } +## +# Returns 0 when the working directory sits inside a git work tree. +## +function bashunit::helper::git_is_repo() { + if ! bashunit::dependencies::has_git; then + return 1 + fi + + git rev-parse --is-inside-work-tree >/dev/null 2>&1 +} + +## +# Returns 0 when the ref resolves to a commit in the current repository. +# Arguments: $1 - the ref +## +function bashunit::helper::git_ref_exists() { + git rev-parse --verify --quiet "$1^{commit}" >/dev/null 2>&1 +} + +## +# Echoes the ref --changed diffs against: BASHUNIT_CHANGED_REF when set, then +# origin/HEAD, then HEAD. HEAD is the last resort rather than an error because a +# shallow CI checkout has no remote-tracking branch, and diffing HEAD still +# reports the working-tree edits. +## +function bashunit::helper::git_changed_ref() { + if [ -n "${BASHUNIT_CHANGED_REF:-}" ]; then + echo "$BASHUNIT_CHANGED_REF" + elif bashunit::helper::git_ref_exists "origin/HEAD"; then + echo "origin/HEAD" + else + echo "HEAD" + fi +} + +## +# Echoes every file git reports as changed since the ref, one per line, relative +# to the working directory. +# +# Three sources are merged because none of them sees the others: the commit +# range covers what is committed, the diff against HEAD covers staged and +# unstaged edits, and ls-files covers a brand-new file no commit knows about. +# Deletions are dropped (--diff-filter=d) so a removed test file never reaches +# discovery, and -M turns a rename into its new path alone. +# Arguments: $1 - the ref +## +function bashunit::helper::git_changed_files() { + local ref=$1 + local prefix + prefix="$(git rev-parse --show-prefix 2>/dev/null)" + + # quotePath would octal-escape non-ASCII names, which no longer match the + # paths discovery produced. + { + git -c core.quotePath=false diff -M --name-only --diff-filter=d "$ref...HEAD" 2>/dev/null + git -c core.quotePath=false diff -M --name-only --diff-filter=d HEAD 2>/dev/null + git -c core.quotePath=false ls-files --others --exclude-standard 2>/dev/null + } | awk -v prefix="$prefix" ' + NF == 0 { next } + prefix != "" { + if (index($0, prefix) != 1) next + $0 = substr($0, length(prefix) + 1) + } + !seen[$0]++' +} + +## +# Echoes the given candidate files that changed since the ref, preserving the +# caller order and path spelling. A leading "./" is ignored on both sides: +# discovery emits the paths the user typed, git always emits repo-relative ones. +# Arguments: $1 - the ref, $@ - candidate files +## +function bashunit::helper::git_filter_changed() { + local ref=$1 + shift + + local changed + changed="$(bashunit::helper::git_changed_files "$ref")" + [ -n "$changed" ] || return 0 + + local file normalized + for file in "$@"; do + normalized="${file#./}" + case " +$changed +" in + *" +$normalized +"*) printf '%s\n' "$file" ;; + esac + done +} + # Also written by find_total_tests so a main-shell caller can read the count # without a $() capture (which would discard the provider-map cache built here). _BASHUNIT_HELPER_TOTAL_TESTS_OUT=0 diff --git a/src/main/run.sh b/src/main/run.sh index a7469164..68f747de 100644 --- a/src/main/run.sh +++ b/src/main/run.sh @@ -26,6 +26,24 @@ function bashunit::main::exec_tests() { exit 1 fi + # Keep only the test files git reports as touched since the ref. This narrows + # after the "at least one path" guard on purpose: reaching zero files here is + # a real answer, so it renders "No tests found" and exits 1, the same shape an + # empty shard has, instead of the guard's "path is required" help dump. + if bashunit::env::is_changed_enabled; then + local _changed_ref + _changed_ref=$(bashunit::helper::git_changed_ref) + local -a _changed_files=() + local _changed_file + while IFS= read -r _changed_file; do + [ -z "$_changed_file" ] && continue + _changed_files[${#_changed_files[@]}]="$_changed_file" + done < <(bashunit::helper::git_filter_changed "$_changed_ref" "${test_files[@]}") + test_files=("${_changed_files[@]+"${_changed_files[@]}"}") + test_files_count=${#test_files[@]} + bashunit::internal_log "changed" "ref:$_changed_ref" "files:$test_files_count" + fi + # Split the suite across runners: keep the files whose position matches this # shard (round-robin), so all shards together cover the whole suite with no # overlap. An empty shard (more shards than files) is valid and runs nothing. diff --git a/src/main/test.sh b/src/main/test.sh index 129dfe4f..8c70cfce 100644 --- a/src/main/test.sh +++ b/src/main/test.sh @@ -153,6 +153,19 @@ function bashunit::main::cmd_test() { BASHUNIT_RERUN_FAILED=true export -n BASHUNIT_RERUN_FAILED ;; + --changed) + BASHUNIT_CHANGED=true + export -n BASHUNIT_CHANGED + # The ref is optional, so $2 is only taken when it cannot be the run's path + # argument: an existing path there is a path, never a ref. A ref that also + # names a file on disk has to be written as `--changed ./main` or set + # through BASHUNIT_CHANGED_REF. + if [ -n "${2:-}" ] && [ "${2#-}" = "${2:-}" ] && [ ! -e "$2" ]; then + BASHUNIT_CHANGED_REF="$2" + export -n BASHUNIT_CHANGED_REF + shift + fi + ;; --list | --dry-run) BASHUNIT_LIST_TESTS=true export -n BASHUNIT_LIST_TESTS @@ -437,6 +450,7 @@ function bashunit::main::cmd_test() { [ -n "$exclude_tag_filter" ] && _partial_flag="--exclude-tag" [ -n "${BASHUNIT_SHARD_INDEX:-}" ] && _partial_flag="--shard" bashunit::rerun::is_enabled && _partial_flag="--rerun-failed" + bashunit::env::is_changed_enabled && _partial_flag="--changed" if [ -n "$_partial_flag" ]; then printf "%sError: --snapshot-report-unused needs a full run; %s only runs a subset.%s\n" \ "${_BASHUNIT_COLOR_FAILED}" "$_partial_flag" "${_BASHUNIT_COLOR_DEFAULT}" >&2 diff --git a/src/main/validate.sh b/src/main/validate.sh index 0ca9326e..2d1e9543 100644 --- a/src/main/validate.sh +++ b/src/main/validate.sh @@ -106,6 +106,24 @@ function bashunit::main::validate_config_or_exit() { fi fi + # --changed asks git a question, and both ways of asking it wrongly return + # "nothing changed": outside a work tree, and with a ref that does not exist. + # Left unchecked that is a green run of zero tests, the #871 failure shape. + if bashunit::env::is_changed_enabled; then + if ! bashunit::helper::git_is_repo; then + printf "%sError: --changed needs a git work tree; '%s' is not inside one.%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "$PWD" "${_BASHUNIT_COLOR_DEFAULT}" >&2 + exit 1 + fi + local _changed_ref + _changed_ref="$(bashunit::helper::git_changed_ref)" + if ! bashunit::helper::git_ref_exists "$_changed_ref"; then + printf "%sError: --changed cannot resolve the git ref '%s'.%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "$_changed_ref" "${_BASHUNIT_COLOR_DEFAULT}" >&2 + exit 1 + fi + fi + local _report_var _report_path for _report_var in BASHUNIT_LOG_JUNIT BASHUNIT_LOG_GHA BASHUNIT_REPORT_HTML \ BASHUNIT_REPORT_TAP BASHUNIT_REPORT_JSON; do diff --git a/tests/acceptance/bashunit_changed_test.sh b/tests/acceptance/bashunit_changed_test.sh new file mode 100644 index 00000000..5d0b3f9c --- /dev/null +++ b/tests/acceptance/bashunit_changed_test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --changed selects test files from git, so every case runs against a throwaway +# repo in a temp dir rather than the bashunit checkout: the checkout's own diff +# would make these results depend on whatever the contributor is editing. + +function set_up_before_script() { + BASHUNIT_BIN="$(pwd)/bashunit" +} + +# Two committed test files and a `base` branch on that first commit. A branch +# rather than a tag: a global tag.forceSignAnnotated or tag.annotate makes a +# bare `git tag` abort with "no tag message?". The default branch is set +# explicitly because init.defaultBranch varies across the git versions CI runs. +function _changed_repo() { + local repo + repo="$(bashunit::temp_dir changed_cli)" + ( + 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 '#!/usr/bin/env bash\nfunction test_alpha() { assert_same 1 1; }\n' >alpha_test.sh + printf '#!/usr/bin/env bash\nfunction test_beta() { assert_same 2 2; }\n' >beta_test.sh + git add . + git commit -q -m "initial" + git branch base + ) >/dev/null 2>&1 + echo "$repo" +} + +# Rewrites alpha_test.sh with a second test, so a change is visible both as a +# file path and as an extra selected test. +function _touch_alpha() { + { + printf '#!/usr/bin/env bash\n' + printf 'function test_alpha() { assert_same 1 1; }\n' + printf 'function test_gamma() { assert_same 3 3; }\n' + } >"$1/alpha_test.sh" +} + +function test_changed_lists_only_the_files_committed_since_the_ref() { + local repo + repo="$(_changed_repo)" + _touch_alpha "$repo" + (cd "$repo" && git commit -q -am "touch alpha") >/dev/null 2>&1 + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed base . 2>/dev/null) + + assert_same "./alpha_test.sh::test_alpha +./alpha_test.sh::test_gamma" "$output" +} + +function test_changed_includes_an_uncommitted_modification() { + local repo + repo="$(_changed_repo)" + _touch_alpha "$repo" + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed base . 2>/dev/null) + + assert_contains "alpha_test.sh" "$output" + assert_not_contains "beta_test.sh" "$output" +} + +function test_changed_without_a_ref_still_sees_working_tree_edits() { + local repo + repo="$(_changed_repo)" + _touch_alpha "$repo" + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed . 2>/dev/null) + + assert_contains "alpha_test.sh" "$output" + assert_not_contains "beta_test.sh" "$output" +} + +function test_changed_excludes_a_deleted_test_file() { + local repo + repo="$(_changed_repo)" + _touch_alpha "$repo" + ( + cd "$repo" || exit 1 + git rm -q beta_test.sh + git commit -q -am "drop beta, touch alpha" + ) >/dev/null 2>&1 + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed base . 2>/dev/null) + + assert_contains "alpha_test.sh" "$output" + assert_not_contains "beta_test.sh" "$output" +} + +function test_changed_selects_only_the_new_path_of_a_rename() { + local repo + repo="$(_changed_repo)" + ( + cd "$repo" || exit 1 + git mv beta_test.sh renamed_test.sh + git commit -q -m "rename beta" + ) >/dev/null 2>&1 + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed base . 2>/dev/null) + + assert_same "./renamed_test.sh::test_beta" "$output" +} + +function test_changed_intersects_with_the_filter() { + local repo + repo="$(_changed_repo)" + _touch_alpha "$repo" + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed base --filter gamma . 2>/dev/null) + + assert_same "./alpha_test.sh::test_gamma" "$output" +} + +function test_changed_runs_only_the_changed_file() { + local repo + repo="$(_changed_repo)" + _touch_alpha "$repo" + + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --no-color --no-parallel --changed base . 2>&1) + + # The summary pads with spaces, hence the squeeze. + assert_contains "Tests: 2 passed, 2 total" "$(printf '%s' "$output" | tr -s ' ')" + assert_not_contains "Beta" "$output" +} + +function test_changed_reports_no_tests_found_when_nothing_changed() { + local repo + repo="$(_changed_repo)" + + local ec=0 + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --no-parallel --changed base . 2>&1) || ec=$? + + assert_general_error "" "" "$ec" + assert_contains "No tests found" "$output" +} + +function test_changed_fails_outside_a_git_work_tree() { + local outside + outside="$(bashunit::temp_dir changed_outside)" + printf '#!/usr/bin/env bash\nfunction test_alpha() { assert_same 1 1; }\n' >"$outside/alpha_test.sh" + + local ec=0 + local output + output=$(cd "$outside" && "$BASHUNIT_BIN" --skip-env-file --list --changed . 2>&1) || ec=$? + + assert_general_error "" "" "$ec" + assert_contains "git work tree" "$output" +} + +function test_changed_fails_when_the_ref_does_not_resolve() { + local repo + repo="$(_changed_repo)" + + local ec=0 + local output + output=$(cd "$repo" && "$BASHUNIT_BIN" --skip-env-file --list --changed no_such_ref . 2>&1) || ec=$? + + assert_general_error "" "" "$ec" + assert_contains "no_such_ref" "$output" +} diff --git a/tests/unit/helper/git_changed_test.sh b/tests/unit/helper/git_changed_test.sh new file mode 100644 index 00000000..f08630ef --- /dev/null +++ b/tests/unit/helper/git_changed_test.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash + +# Builds a throwaway repo with two committed test files and a `base` branch on +# that first commit, so a diff against `base` has something to report. A branch +# rather than a tag: a global tag.forceSignAnnotated or tag.annotate makes a +# bare `git tag` abort with "no tag message?" on the contributor's machine. +# The default branch is set explicitly because init.defaultBranch varies across +# the git versions CI runs. +function _changed_repo() { + local repo + repo="$(bashunit::temp_dir changed_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 'function test_base() { :; }\n' >base_test.sh + printf 'function test_other() { :; }\n' >other_test.sh + git add . + git commit -q -m "initial" + git branch base + ) >/dev/null 2>&1 + echo "$repo" +} + +function test_git_changed_files_lists_a_file_committed_after_the_ref() { + local repo + repo="$(_changed_repo)" + ( + cd "$repo" || exit 1 + printf 'function test_base() { :; }\nfunction test_extra() { :; }\n' >base_test.sh + git commit -q -am "touch base" + ) >/dev/null 2>&1 + + assert_same "base_test.sh" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_changed_files_includes_an_uncommitted_modification() { + local repo + repo="$(_changed_repo)" + printf 'function test_base() { :; }\nfunction test_dirty() { :; }\n' >"$repo/base_test.sh" + + assert_same "base_test.sh" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_changed_files_includes_a_staged_modification() { + local repo + repo="$(_changed_repo)" + ( + cd "$repo" || exit 1 + printf 'function test_base() { :; }\nfunction test_staged() { :; }\n' >base_test.sh + git add base_test.sh + ) >/dev/null 2>&1 + + assert_same "base_test.sh" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_changed_files_includes_an_untracked_file() { + local repo + repo="$(_changed_repo)" + printf 'function test_new() { :; }\n' >"$repo/new_test.sh" + + assert_same "new_test.sh" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_changed_files_excludes_a_deleted_file() { + local repo + repo="$(_changed_repo)" + ( + cd "$repo" || exit 1 + git rm -q other_test.sh + git commit -q -m "drop other" + ) >/dev/null 2>&1 + + assert_same "" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_changed_files_reports_only_the_new_path_of_a_rename() { + local repo + repo="$(_changed_repo)" + ( + cd "$repo" || exit 1 + git mv other_test.sh renamed_test.sh + git commit -q -m "rename other" + ) >/dev/null 2>&1 + + assert_same "renamed_test.sh" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_changed_files_is_empty_when_nothing_changed() { + local repo + repo="$(_changed_repo)" + + assert_same "" "$(cd "$repo" && bashunit::helper::git_changed_files base)" +} + +function test_git_filter_changed_keeps_only_the_changed_candidates() { + local repo + repo="$(_changed_repo)" + printf 'function test_base() { :; }\nfunction test_dirty() { :; }\n' >"$repo/base_test.sh" + + assert_same "./base_test.sh" \ + "$(cd "$repo" && bashunit::helper::git_filter_changed base ./base_test.sh ./other_test.sh)" +} + +function test_git_filter_changed_keeps_the_caller_path_spelling() { + local repo + repo="$(_changed_repo)" + printf 'function test_base() { :; }\nfunction test_dirty() { :; }\n' >"$repo/base_test.sh" + + assert_same "base_test.sh" \ + "$(cd "$repo" && bashunit::helper::git_filter_changed base base_test.sh other_test.sh)" +} + +function test_git_filter_changed_is_empty_when_no_candidate_changed() { + local repo + repo="$(_changed_repo)" + + assert_same "" \ + "$(cd "$repo" && bashunit::helper::git_filter_changed base ./base_test.sh ./other_test.sh)" +} + +# `cmd; echo $?` would abort the command substitution under --strict before the +# echo ran, so the outcome is turned into text by an explicit if. +function _outcome_of() { + if "$@"; then echo "yes"; else echo "no"; fi +} + +function test_git_is_repo_is_false_outside_a_work_tree() { + local outside + outside="$(bashunit::temp_dir outside_repo)" + + assert_same "no" "$(cd "$outside" && _outcome_of bashunit::helper::git_is_repo)" +} + +function test_git_is_repo_is_true_inside_a_work_tree() { + local repo + repo="$(_changed_repo)" + + assert_same "yes" "$(cd "$repo" && _outcome_of bashunit::helper::git_is_repo)" +} + +function test_git_ref_exists_resolves_a_known_ref() { + local repo + repo="$(_changed_repo)" + + assert_same "yes" "$(cd "$repo" && _outcome_of bashunit::helper::git_ref_exists base)" +} + +function test_git_ref_exists_rejects_an_unknown_ref() { + local repo + repo="$(_changed_repo)" + + assert_same "no" "$(cd "$repo" && _outcome_of bashunit::helper::git_ref_exists no_such_ref)" +} + +function test_git_changed_ref_prefers_the_explicit_ref() { + local repo + repo="$(_changed_repo)" + + # shellcheck disable=SC2030 # confining the override to the subshell is the point + assert_same "base" "$(cd "$repo" && export BASHUNIT_CHANGED_REF=base && bashunit::helper::git_changed_ref)" +} + +function test_git_changed_ref_falls_back_to_head_without_an_origin() { + local repo + repo="$(_changed_repo)" + + # shellcheck disable=SC2031 # ditto: the empty override must not outlive the subshell + assert_same "HEAD" "$(cd "$repo" && export BASHUNIT_CHANGED_REF='' && bashunit::helper::git_changed_ref)" +}