From 3c008d12b14db1ee3ceb365f668f5dadcc0d50a9 Mon Sep 17 00:00:00 2001 From: SauronBot Date: Sat, 28 Feb 2026 21:00:05 +0000 Subject: [PATCH 1/4] feat: add watch mode subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `bashunit watch [path] [options]` — a new subcommand that watches .sh files for changes and automatically re-runs tests, enabling a fast TDD feedback loop without manual re-runs. Implementation: - src/watch.sh: core watch loop using inotifywait (Linux) or fswatch (macOS) - src/main.sh: cmd_watch() subcommand handler - bashunit: source watch.sh, register 'watch' in subcommand detection and router - src/console_header.sh: help text for 'watch', added to main help listing Features: - Runs tests immediately on start, then watches for changes - Supports inotifywait (Linux/inotify-tools) and fswatch (macOS) - Forwards all standard 'bashunit test' options (--filter, --simple, etc.) - Clear error message with install instructions when watcher tool is missing - Watches only .sh files to avoid spurious re-runs Usage: bashunit watch # watch current directory bashunit watch tests/ # watch specific path bashunit watch tests/ --filter user # watch + filter bashunit watch tests/ --simple # watch with simple output --- bashunit | 4 ++- src/console_header.sh | 26 +++++++++++++++ src/main.sh | 16 +++++++++ src/watch.sh | 78 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/watch.sh diff --git a/bashunit b/bashunit index 8df147a9..72a04262 100755 --- a/bashunit +++ b/bashunit @@ -72,6 +72,7 @@ source "$BASHUNIT_ROOT_DIR/src/console_results.sh" source "$BASHUNIT_ROOT_DIR/src/helpers.sh" source "$BASHUNIT_ROOT_DIR/src/test_title.sh" source "$BASHUNIT_ROOT_DIR/src/upgrade.sh" +source "$BASHUNIT_ROOT_DIR/src/watch.sh" source "$BASHUNIT_ROOT_DIR/src/assertions.sh" source "$BASHUNIT_ROOT_DIR/src/doc.sh" source "$BASHUNIT_ROOT_DIR/src/reports.sh" @@ -88,7 +89,7 @@ bashunit::clock::init _SUBCOMMAND="" case "${1:-}" in - test | bench | doc | init | learn | upgrade | assert) + test | bench | doc | init | learn | upgrade | assert | watch) _SUBCOMMAND="$1" shift ;; @@ -123,4 +124,5 @@ case "$_SUBCOMMAND" in learn) bashunit::main::cmd_learn "$@" ;; upgrade) bashunit::main::cmd_upgrade "$@" ;; assert) bashunit::main::cmd_assert "$@" ;; + watch) bashunit::main::cmd_watch "$@" ;; esac diff --git a/src/console_header.sh b/src/console_header.sh index f7dea3cf..03502abb 100644 --- a/src/console_header.sh +++ b/src/console_header.sh @@ -69,6 +69,7 @@ Commands: doc [filter] Display assertion documentation init [dir] Initialize a new test directory learn Start interactive tutorial + watch [path] Watch files and re-run tests on change upgrade Upgrade bashunit to latest version Global Options: @@ -271,3 +272,28 @@ Note: You can also use 'bashunit test --assert ' (deprecated). More info: https://bashunit.typeddevs.com/standalone EOF } + +function bashunit::console_header::print_watch_help() { + cat << 'ENDOFHELP' +Usage: bashunit watch [path] [test-options] + +Watch .sh files for changes and automatically re-run tests. + +Arguments: + [path] Directory or file to watch and test (default: .) + +Options: + -h, --help Show this help message + Any option accepted by 'bashunit test' is also accepted here. + +Requirements: + Linux: inotifywait (sudo apt install inotify-tools) + macOS: fswatch (brew install fswatch) + +Examples: + bashunit watch Watch current directory + bashunit watch tests/ Watch the tests/ directory + bashunit watch tests/ --filter user Watch and filter by name + bashunit watch tests/ --simple Watch with simple output +ENDOFHELP +} diff --git a/src/main.sh b/src/main.sh index c9d8c119..685e9568 100644 --- a/src/main.sh +++ b/src/main.sh @@ -405,6 +405,22 @@ function bashunit::main::cmd_learn() { exit 0 } +############################# +# Subcommand: watch +############################# +function bashunit::main::cmd_watch() { + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + bashunit::console_header::print_watch_help + exit 0 + fi + + local path="${1:-.}" + shift || true + local -a extra_args=("$@") + + bashunit::watch::run "$path" "${extra_args[@]+\"${extra_args[@]}\"}" +} + ############################# # Subcommand: upgrade ############################# diff --git a/src/watch.sh b/src/watch.sh new file mode 100644 index 00000000..f855f0bc --- /dev/null +++ b/src/watch.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# bashunit watch mode +# Watches test and source files for changes and re-runs tests automatically. +# Requires: inotifywait (inotify-tools) on Linux, or fswatch on macOS. + +function bashunit::watch::is_available() { + if command -v inotifywait &>/dev/null; then + echo "inotifywait" + elif command -v fswatch &>/dev/null; then + echo "fswatch" + else + echo "" + fi +} + +function bashunit::watch::run() { + local path="${1:-.}" + shift + local extra_args=("$@") + + local tool + tool=$(bashunit::watch::is_available) + + if [[ -z "$tool" ]]; then + printf "%sError: watch mode requires 'inotifywait' (Linux) or 'fswatch' (macOS).%s\n" \ + "${_BASHUNIT_COLOR_FAILED}" "${_BASHUNIT_COLOR_DEFAULT}" + printf " Linux: sudo apt install inotify-tools\n" + printf " macOS: brew install fswatch\n" + exit 1 + fi + + printf "%sbashunit --watch%s watching: %s\n\n" \ + "${_BASHUNIT_COLOR_PASSED}" "${_BASHUNIT_COLOR_DEFAULT}" "$path" + + # Run once immediately before entering the watch loop + bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}" + + while true; do + bashunit::watch::wait_for_change "$tool" "$path" + printf "\n%s[change detected — re-running tests]%s\n\n" \ + "${_BASHUNIT_COLOR_SKIPPED}" "${_BASHUNIT_COLOR_DEFAULT}" + bashunit::watch::run_tests "$path" "${extra_args[@]+"${extra_args[@]}"}" + done +} + +function bashunit::watch::run_tests() { + local path="$1" + shift + # Re-invoke bashunit test in a subshell so state resets cleanly each run + "$BASHUNIT_ROOT_DIR/bashunit" test "$path" "$@" + return $? +} + +function bashunit::watch::wait_for_change() { + local tool="$1" + local path="$2" + + case "$tool" in + inotifywait) + inotifywait \ + --quiet \ + --recursive \ + --event modify,create,delete,move \ + --include '.*\.sh$' \ + "$path" 2>/dev/null + ;; + fswatch) + # fswatch outputs one line per event; we only need the first one + fswatch \ + --recursive \ + --include='.*\.sh$' \ + --exclude='.*' \ + --one-event \ + "$path" 2>/dev/null + ;; + esac +} From 7deaf86f3ff56a99f72cee7f55a20ca03a84f9e1 Mon Sep 17 00:00:00 2001 From: SauronBot Date: Sat, 28 Feb 2026 21:07:23 +0000 Subject: [PATCH 2/4] test: add unit tests for watch mode Tests cover: - bashunit::watch::is_available: inotifywait, fswatch, and no-tool cases - bashunit::watch::run: error exit, error message content, install hints - bashunit::watch::wait_for_change: correct tool dispatch for linux/macos/unknown Uses mocks and spies to avoid requiring inotifywait or fswatch on CI. --- src/watch.sh | 8 +++- tests/unit/watch_test.sh | 91 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/unit/watch_test.sh diff --git a/src/watch.sh b/src/watch.sh index f855f0bc..763af028 100644 --- a/src/watch.sh +++ b/src/watch.sh @@ -4,10 +4,14 @@ # Watches test and source files for changes and re-runs tests automatically. # Requires: inotifywait (inotify-tools) on Linux, or fswatch on macOS. +function bashunit::watch::_command_exists() { + command -v "$1" &>/dev/null +} + function bashunit::watch::is_available() { - if command -v inotifywait &>/dev/null; then + if bashunit::watch::_command_exists inotifywait; then echo "inotifywait" - elif command -v fswatch &>/dev/null; then + elif bashunit::watch::_command_exists fswatch; then echo "fswatch" else echo "" diff --git a/tests/unit/watch_test.sh b/tests/unit/watch_test.sh new file mode 100644 index 00000000..862a1550 --- /dev/null +++ b/tests/unit/watch_test.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash + +############################ +# bashunit::watch::is_available +############################ + +function test_is_available_returns_inotifywait_when_present() { + bashunit::mock bashunit::watch::_command_exists mock_true + + assert_equals "inotifywait" "$(bashunit::watch::is_available)" +} + +function test_is_available_returns_fswatch_when_inotifywait_missing() { + local call_count=0 + function bashunit::watch::_command_exists() { + call_count=$((call_count + 1)) + [[ $call_count -eq 2 ]] + } + + assert_equals "fswatch" "$(bashunit::watch::is_available)" +} + +function test_is_available_returns_empty_when_no_tool_found() { + bashunit::mock bashunit::watch::_command_exists mock_false + + assert_empty "$(bashunit::watch::is_available)" +} + +############################ +# bashunit::watch::run — error path (no tool) +# run() calls exit 1, so we must capture it in a subshell +############################ + +function test_run_exits_nonzero_when_no_tool_available() { + bashunit::mock bashunit::watch::is_available echo "" + + local exit_code=0 + (bashunit::watch::run "tests/" >/dev/null 2>&1) || exit_code=$? + + assert_greater_than "0" "$exit_code" +} + +function test_run_error_message_mentions_required_tools() { + bashunit::mock bashunit::watch::is_available echo "" + + local output + output=$(bashunit::watch::run "tests/" 2>&1) || true + + assert_contains "inotifywait" "$output" + assert_contains "fswatch" "$output" +} + +function test_run_error_message_includes_install_hints() { + bashunit::mock bashunit::watch::is_available echo "" + + local output + output=$(bashunit::watch::run "tests/" 2>&1) || true + + assert_contains "apt install inotify-tools" "$output" + assert_contains "brew install fswatch" "$output" +} + +############################ +# bashunit::watch::wait_for_change — tool dispatch +############################ + +function test_wait_for_change_calls_inotifywait_on_linux() { + bashunit::spy inotifywait + + bashunit::watch::wait_for_change "inotifywait" "tests/" 2>/dev/null || true + + assert_have_been_called inotifywait +} + +function test_wait_for_change_calls_fswatch_on_macos() { + bashunit::spy fswatch + + bashunit::watch::wait_for_change "fswatch" "tests/" 2>/dev/null || true + + assert_have_been_called fswatch +} + +function test_wait_for_change_does_nothing_for_unknown_tool() { + bashunit::spy inotifywait + bashunit::spy fswatch + + bashunit::watch::wait_for_change "unknown-tool" "tests/" 2>/dev/null || true + + assert_not_called inotifywait + assert_not_called fswatch +} From b30268ad2162b26ad760fb27407c3c5ee677e951 Mon Sep 17 00:00:00 2001 From: SauronBot Date: Sun, 1 Mar 2026 03:46:13 +0100 Subject: [PATCH 3/4] fix(watch): trailing whitespace in main.sh, SC2329 shellcheck disable, update help snapshots --- src/main.sh | 2 +- ...test_sh.test_bashunit_without_path_env_nor_argument.snapshot | 1 + .../bashunit_test_sh.test_bashunit_should_display_help.snapshot | 1 + tests/unit/watch_test.sh | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.sh b/src/main.sh index 685e9568..99b5e47a 100644 --- a/src/main.sh +++ b/src/main.sh @@ -418,7 +418,7 @@ function bashunit::main::cmd_watch() { shift || true local -a extra_args=("$@") - bashunit::watch::run "$path" "${extra_args[@]+\"${extra_args[@]}\"}" + bashunit::watch::run "$path" "${extra_args[@]+\"${extra_args[@]}\"}" } ############################# diff --git a/tests/acceptance/snapshots/bashunit_path_test_sh.test_bashunit_without_path_env_nor_argument.snapshot b/tests/acceptance/snapshots/bashunit_path_test_sh.test_bashunit_without_path_env_nor_argument.snapshot index 7e7f313c..9593ed38 100644 --- a/tests/acceptance/snapshots/bashunit_path_test_sh.test_bashunit_without_path_env_nor_argument.snapshot +++ b/tests/acceptance/snapshots/bashunit_path_test_sh.test_bashunit_without_path_env_nor_argument.snapshot @@ -8,6 +8,7 @@ Commands: doc [filter] Display assertion documentation init [dir] Initialize a new test directory learn Start interactive tutorial + watch [path] Watch files and re-run tests on change upgrade Upgrade bashunit to latest version Global Options: diff --git a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_help.snapshot b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_help.snapshot index 84f2b228..d1d3b1e7 100644 --- a/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_help.snapshot +++ b/tests/acceptance/snapshots/bashunit_test_sh.test_bashunit_should_display_help.snapshot @@ -7,6 +7,7 @@ Commands: doc [filter] Display assertion documentation init [dir] Initialize a new test directory learn Start interactive tutorial + watch [path] Watch files and re-run tests on change upgrade Upgrade bashunit to latest version Global Options: diff --git a/tests/unit/watch_test.sh b/tests/unit/watch_test.sh index 862a1550..295d3caa 100644 --- a/tests/unit/watch_test.sh +++ b/tests/unit/watch_test.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash +# shellcheck disable=SC2329 # Test functions are invoked indirectly by bashunit ############################ # bashunit::watch::is_available ############################ From fdcdd99763e68b598c10f1c08adc015ccc321cb1 Mon Sep 17 00:00:00 2001 From: SauronBot Date: Sun, 1 Mar 2026 03:47:47 +0100 Subject: [PATCH 4/4] docs: update changelog with watch subcommand entry --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08256c0a..ab6f7ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ ## Unreleased ### Added +- Add `watch [path]` subcommand to re-run tests automatically on file changes + - Uses `inotifywait` on Linux (via `inotify-tools`) or `fswatch` on macOS + - Falls back with a clear install hint if neither tool is available + - Accepts optional path argument (defaults to current directory) + - Add date comparison assertions: `assert_date_equals`, `assert_date_before`, `assert_date_after`, `assert_date_within_range`, `assert_date_within_delta` - Auto-detects epoch seconds, ISO 8601, space-separated datetime, and timezone offsets - Mixed formats supported in the same assertion call