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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
- `--changed [<ref>]` runs only the test files git reports as touched since `<ref>` (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 <name>` 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)
Expand Down
1 change: 1 addition & 0 deletions completions/_bashunit
Original file line number Diff line number Diff line change
Expand Up @@ -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]' \
Expand Down
2 changes: 1 addition & 1 deletion completions/bashunit.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
48 changes: 45 additions & 3 deletions docs/command-line.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ bashunit test tests/ --parallel --simple
| `--seed <n>` | Seed for `--random-order` (reproducible shuffle) |
| `--shard <i>/<n>` | Run shard i of n (split suite across runners) |
| `--rerun-failed` | Replay only the tests that failed on the last run |
| `--changed [<ref>]` | Run only the test files changed since `<ref>` (default: `origin/HEAD`, then `HEAD`) |
| `--list`, `--dry-run` | Print the tests that would run, then exit |
| `--list-format <fmt>` | Rendering for `--list`: `text` (default) or `json` |
| `--snapshot-update` | Rewrite existing snapshots from the actual value |
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -820,6 +821,47 @@ BASHUNIT_RERUN_FAILED=true bashunit test tests/
```
:::

### Changed

> `bashunit test --changed [<ref>]`

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 `<ref>`, which
merges three sources: the commit range `<ref>...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`
Expand Down
13 changes: 13 additions & 0 deletions src/config/env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:-}"
}
Expand Down
1 change: 1 addition & 0 deletions src/console/header.sh
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Options:
--seed <n> Seed for --random-order (reproducible shuffle)
--shard <i>/<n> 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 [<ref>] Run only the test files changed since <ref> (default: origin/HEAD, then HEAD)
--list, --dry-run Print the tests that would run, then exit without running them
--list-format <fmt> Rendering for --list: text (default) or json
--snapshot-update Rewrite existing snapshots from the actual value (combine with --filter)
Expand Down
96 changes: 95 additions & 1 deletion src/helper/git.sh
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/main/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/main/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/main/validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading