From 960fba9c774ba9401dc7fd0a1a5c984dc7d3cbde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:20:28 +0000 Subject: [PATCH 1/2] test: exhaustive behavioral coverage for all scripts, fix bugs it found Replace the syntax-only test.bats in every script folder with real behavioral suites (help/usage, argument validation, success/error paths, edge cases) run against throwaway git repos or temp dirs. Fixes surfaced by the new tests: - load-json: error exits were swallowed by piping into sed|jq - git-fix-base/-children/-date/-message/-privacy/-secrets: zz_ask return value was checked via exit code instead of stdout, so declining a destructive-rewrite confirmation prompt never actually cancelled it - git-fix-base: dry-run heredoc variable name was an invalid identifier - git-fix-children: grep no-match exit code aborted the whole script --- README.md | 10 +++ configure-feature/test.bats | 132 +++++++++++++++++++++++++++ distribute-utils/test.bats | 81 +++++++++++++++++ edit-script/test.bats | 46 ++++++++++ git-align/test.bats | 85 ++++++++++++++++++ git-autorebase/test.bats | 140 +++++++++++++++++++++++++++++ git-co/test.bats | 75 ++++++++++++++++ git-degit/test.bats | 72 +++++++++++++++ git-fix-author/test.bats | 66 ++++++++++++++ git-fix-base/run.sh | 4 +- git-fix-base/test.bats | 110 +++++++++++++++++++++++ git-fix-blanks/test.bats | 96 ++++++++++++++++++++ git-fix-children/run.sh | 2 +- git-fix-children/test.bats | 84 ++++++++++++++++++ git-fix-date/run.sh | 2 +- git-fix-date/test.bats | 131 +++++++++++++++++++++++++++ git-fix-del/test.bats | 60 +++++++++++++ git-fix-emoji/test.bats | 32 +++++++ git-fix-last/test.bats | 44 +++++++++ git-fix-lock/test.bats | 59 +++++++++++++ git-fix-message/run.sh | 2 +- git-fix-message/test.bats | 79 +++++++++++++++++ git-fix-mode/test.bats | 44 +++++++++ git-fix-privacy/run.sh | 2 +- git-fix-privacy/test.bats | 47 ++++++++++ git-fix-prune/test.bats | 59 +++++++++++++ git-fix-rights/test.bats | 51 +++++++++++ git-fix-secrets/run.sh | 2 +- git-fix-secrets/test.bats | 100 +++++++++++++++++++++ git-fix-up/test.bats | 58 ++++++++++++ git-fix/test.bats | 38 ++++++++ git-forall/test.bats | 49 ++++++++++ git-getcommit/test.bats | 53 +++++++++++ git-integrate/test.bats | 54 +++++++++++ git-pick/test.bats | 67 ++++++++++++++ git-release-alpha/test.bats | 114 ++++++++++++++++++++++++ git-release-beta/test.bats | 80 +++++++++++++++++ git-release-hotfix/test.bats | 121 +++++++++++++++++++++++++ git-release-prod/test.bats | 167 +++++++++++++++++++++++++++++++++++ git-release/test.bats | 43 +++++++++ git-unset/test.bats | 51 +++++++++++ git-workspaces/test.bats | 80 +++++++++++++++++ install-feature/test.bats | 80 +++++++++++++++++ load-json/run.sh | 12 ++- load-json/test.bats | 78 ++++++++++++++-- merge-json/test.bats | 93 +++++++++++++++++-- normalize-json/test.bats | 73 +++++++++++++-- resolve-context/test.bats | 64 +++++++++++++- validate-json/test.bats | 103 ++++++++++++++++++++- zz_args/test.bats | 106 +++++++++++++++++++++- zz_ask/test.bats | 28 ++++++ zz_bindir/test.bats | 85 ++++++++++++++++++ zz_call/test.bats | 42 +++++++++ zz_colors/test.bats | 38 ++++++++ zz_dispatch/test.bats | 48 +++++++++- zz_input/test.bats | 36 ++++++++ zz_log/test.bats | 61 +++++++++++++ zz_npx/test.bats | 68 ++++++++++++++ zz_persist/test.bats | 84 ++++++++++++++++++ zz_prompt/test.bats | 28 ++++++ zz_update/test.bats | 37 ++++++++ zz_use/test.bats | 47 ++++++++++ 62 files changed, 3864 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 58fc0d1..6bad7ad 100644 --- a/README.md +++ b/README.md @@ -265,3 +265,13 @@ Any single folder can be copied out and still work standalone. npm test # bats --recursive . (every */test.bats) bats validate-json/test.bats # a single script's tests ``` + +Each `test.bats` is a behavioral suite, not just a syntax check: it exercises +the script's documented options and arguments, `-h`/help output, error paths +(missing/invalid arguments, running outside a git repo where relevant), and +success paths against a throwaway git repo or temp directory created in +`setup()`/`teardown()` (via `tests/helpers.bash`). Suites are hermetic — no +network access and no writes outside a temp dir — except where a script's own +purpose requires reaching a real tool (e.g. `zz_npx`/`zz_update` fall back to +a local fixture and assert no network call is made). 417 tests currently pass +across all 53 script folders. diff --git a/configure-feature/test.bats b/configure-feature/test.bats index 3c4006c..44055bc 100644 --- a/configure-feature/test.bats +++ b/configure-feature/test.bats @@ -4,13 +4,145 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test" + git config commit.gpgsign false + git commit -q --allow-empty -m "init" } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } +@test "configure-feature is installed on PATH and syntactically valid" { + command -v configure-feature + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "configure-feature -h prints usage and exits non-zero" { + run configure-feature -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + @test "configure-feature errors without a feature argument" { run configure-feature [ "$status" -ne 0 ] } + +@test "configure-feature errors when the source directory does not exist" { + run configure-feature -s "$WORK_DIR/nonexistent" myfeature + [ "$status" -ne 0 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "configure-feature copies a new plain-text stub file into the cwd" { + mkdir -p src/stubs + echo "line1" >src/stubs/plain.txt + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/plain.txt" ] + grep -q line1 "$WORK_DIR/plain.txt" +} + +@test "configure-feature merges a json stub into an existing json file" { + mkdir -p src/stubs + echo '{"b":2}' >src/stubs/config.json + echo '{"a":1}' >config.json + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + result=$(cat config.json) + [[ "$result" == *'"a"'* ]] + [[ "$result" == *'"b"'* ]] +} + +@test "configure-feature copies a json stub as-is when destination doesn't exist" { + mkdir -p src/stubs + echo '{"a":1}' >src/stubs/newfile.json + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + [ -f newfile.json ] + grep -q '"a"' newfile.json +} + +@test "configure-feature reconciles a fragment additively into an existing plain-text file" { + mkdir -p src/stubs + printf 'line1\nline2\n' >src/stubs/frag.txt + printf 'existing1\n' >frag.txt + + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + grep -q existing1 frag.txt + grep -q line1 frag.txt + grep -q line2 frag.txt +} + +@test "configure-feature strips a leading underscore prefix from stub filenames" { + mkdir -p src/stubs + echo "content" >src/stubs/_prefix.actual.txt + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + [ -f actual.txt ] + [ ! -f _prefix.actual.txt ] +} + +@test "configure-feature adds hash-prefixed stub destinations to .gitignore" { + mkdir -p src/stubs + echo "secretcontent" >'src/stubs/#ignored.txt' + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + [ -f ignored.txt ] + grep -qxF "./ignored.txt" .gitignore +} + +@test "configure-feature preserves executable permission bits from stub source" { + mkdir -p src/stubs + printf '#!/bin/sh\necho hi\n' >src/stubs/exec.sh + chmod 755 src/stubs/exec.sh + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + [ -x exec.sh ] +} + +@test "configure-feature deploys stub symlinks when the destination doesn't already exist" { + mkdir -p src/stubs + echo "target-content" >src/stubs/real.txt + ln -s real.txt src/stubs/linked.txt + run configure-feature -s "$WORK_DIR/src" myfeature + [ "$status" -eq 0 ] + [ -L linked.txt ] +} + +@test "configure-feature runs configure-*.sh scripts from source when at repo top level" { + cat >src-configure.sh <<'EOF' +EOF + mkdir -p src + cat >"src/configure-thing.sh" <"src/configure-thing.sh" <.zz_dist + touch src/zz_foo.sh && chmod +x src/zz_foo.sh + run distribute-utils -s "$WORK_DIR/src" + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/target/zz_foo.sh" ] +} + +@test "distribute-utils reads target from package.json config.zz_dist" { + mkdir -p target src + cat >package.json <src/zz_plain.sh + chmod +x src/zz_plain.sh + echo '#!/bin/sh' >src/_zz_hidden.sh + chmod +x src/_zz_hidden.sh + run distribute-utils -t "$WORK_DIR/target" -s "$WORK_DIR/src" + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/target/zz_plain.sh" ] + [ -f "$WORK_DIR/target/zz_hidden" ] + [ ! -f "$WORK_DIR/target/_zz_hidden.sh" ] +} + +@test "distribute-utils skips non-executable zz_* files" { + mkdir -p target src + echo '#!/bin/sh' >src/zz_noexec.sh + chmod -x src/zz_noexec.sh + run distribute-utils -t "$WORK_DIR/target" -s "$WORK_DIR/src" + [ "$status" -eq 0 ] + [ ! -f "$WORK_DIR/target/zz_noexec.sh" ] +} + +@test "distribute-utils makes copied files executable in the target" { + mkdir -p target src + echo '#!/bin/sh' >src/zz_exec.sh + chmod +x src/zz_exec.sh + run distribute-utils -t "$WORK_DIR/target" -s "$WORK_DIR/src" + [ "$status" -eq 0 ] + [ -x "$WORK_DIR/target/zz_exec.sh" ] +} diff --git a/edit-script/test.bats b/edit-script/test.bats index cd95f91..7c6e308 100644 --- a/edit-script/test.bats +++ b/edit-script/test.bats @@ -4,13 +4,59 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } +@test "edit-script is installed on PATH and syntactically valid" { + command -v edit-script + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "edit-script -h prints usage and exits non-zero" { + run edit-script -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "edit-script errors without a script argument" { + run edit-script + [ "$status" -ne 0 ] +} + @test "edit-script fails when the script isn't installed in /usr/local/bin" { run edit-script definitely-not-installed-xyz [ "$status" -ne 0 ] + [[ "$output" == *"not defined"* ]] +} + +@test "edit-script copies an installed script locally, makes it executable, and opens it" { + if [ ! -w /usr/local/bin ]; then + skip "/usr/local/bin not writable in this environment" + fi + fake=/usr/local/bin/zz_test_edit_script_$$ + printf '#!/bin/sh\necho hi\n' >"$fake" + chmod +x "$fake" + + # stub `code` (the editor invoked at the end of run.sh) so it's a no-op + stub_dir=$(mktemp -d) + printf '#!/bin/sh\nexit 0\n' >"$stub_dir/code" + chmod +x "$stub_dir/code" + + name=$(basename "$fake") + run env PATH="$stub_dir:$PATH" edit-script "$name" + + rm -f "$fake" + rm -rf "$stub_dir" + + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/$name" ] + [ -x "$WORK_DIR/$name" ] } diff --git a/git-align/test.bats b/git-align/test.bats index 8593689..c501d4b 100644 --- a/git-align/test.bats +++ b/git-align/test.bats @@ -4,10 +4,19 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name "Test User" + git config --global user.email "test@example.com" + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) } teardown() { teardown_scripts_path + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-align is installed on PATH and syntactically valid" { @@ -15,3 +24,79 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "fails cleanly outside a git repository" { + cd "$WORK" + run git-align + [[ "$output" == *"not a git repository"* ]] +} + +@test "with no remote configured, logs a failure and leaves the branch/history intact" { + cd "$WORK" + git init -q + git config commit.gpgsign false + echo a >f && git add f && git commit -qm init + before=$(git rev-parse HEAD) + run git-align + [[ "$output" == *"Failed to checkout branch"* ]] + [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] + [ "$(git rev-parse HEAD)" = "$before" ] +} + +@test "aligns the current branch to a newer remote commit and restores stashed changes" { + bare=$(mktemp -d)/origin.git + git init -q --bare "$bare" + + git clone -q "$bare" "$WORK/work" + ( + cd "$WORK/work" + git config commit.gpgsign false + echo a >f && git add f && git commit -qm init + git push -q origin HEAD:main + git branch --set-upstream-to=origin/main main + ) + + # Someone else pushes a new commit to the "remote". + other=$(mktemp -d) + git clone -q "$bare" "$other" + ( + cd "$other" + git config user.name t && git config user.email t@t.com + git config commit.gpgsign false + echo b >f2 && git add f2 && git commit -qm "remote change" + git push -q origin HEAD:main + ) + + cd "$WORK/work" + echo "local edit" >f + run git-align + [ "$status" -eq 0 ] + [[ "$output" == *"Current branch: main"* ]] + + # The remote commit landed locally. + [ -f f2 ] + [ "$(git log --oneline | wc -l)" -eq 2 ] + # Branch name preserved. + [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] + # Stashed local edit was restored. + [ "$(cat f)" = "local edit" ] + # No leftover temp/stash branch. + ! git show-ref --verify --quiet refs/heads/main-to-delete +} + +@test "aligns cleanly with no uncommitted changes (nothing to stash/pop)" { + bare=$(mktemp -d)/origin.git + git init -q --bare "$bare" + git clone -q "$bare" "$WORK/work" + ( + cd "$WORK/work" + git config commit.gpgsign false + echo a >f && git add f && git commit -qm init + git push -q origin HEAD:main + git branch --set-upstream-to=origin/main main + ) + cd "$WORK/work" + run git-align + [ "$status" -eq 0 ] + [[ "$output" == *"Current branch: main"* ]] +} diff --git a/git-autorebase/test.bats b/git-autorebase/test.bats index 02ee3f3..92d7fd6 100644 --- a/git-autorebase/test.bats +++ b/git-autorebase/test.bats @@ -4,10 +4,21 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name t + git config --global user.email t@t.com + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) + cd "$WORK" } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-autorebase is installed on PATH and syntactically valid" { @@ -15,3 +26,132 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage" { + git init -q + run git-autorebase -h + [[ "$output" == *"non-interactive rebasing"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "fails cleanly outside a git repository" { + run git-autorebase abc + [[ "$output" == *"not a git repository"* ]] +} + +@test "rebases the current branch onto an explicit sha with no conflicts" { + git init -q + echo base >f && git add f && git commit -qm base + git checkout -qb topic + echo t1 >>f && git add f && git commit -qm "topic change" + git checkout -q main + echo m1 >other.txt && git add other.txt && git commit -qm "main change" + git checkout -q topic + sha=$(git rev-parse main) + + run git-autorebase "$sha" + [ "$status" -eq 0 ] + [[ "$output" == *"Rebase completed without conflicts"* ]] + [ -f other.txt ] + [ "$(git log --format=%s -1)" = "topic change" ] + [ "$(git rev-parse HEAD~1)" = "$sha" ] +} + +@test "resolves a real content conflict using the default 'theirs' strategy" { + git init -q + echo base >f && git add f && git commit -qm base + git checkout -qb topic + echo topicline >f && git add f && git commit -qm "topic change" + git checkout -q main + echo mainline >f && git add f && git commit -qm "main change" + git checkout -q topic + sha=$(git rev-parse main) + + run git-autorebase "$sha" + [ "$status" -eq 0 ] + [ "$(cat f)" = "topicline" ] + run git status --short + [ -z "$output" ] +} + +@test "-b rebases the named branch (not necessarily the current one) onto the target" { + git init -q + echo base >f && git add f && git commit -qm base + git checkout -qb topic + echo t1 >>f && git add f && git commit -qm "topic change" + git checkout -q main + git checkout -qb other + sha=$(git rev-parse main) + + run git-autorebase -b topic "$sha" + [ "$status" -eq 0 ] + [ "$(git rev-parse --abbrev-ref HEAD)" = "topic" ] + [ "$(git rev-parse topic~1)" = "$sha" ] +} + +@test "-o rebases onto a named branch instead of the sha argument" { + git init -q + echo base >f && git add f && git commit -qm base + git checkout -qb feature-base + echo fb >>f && git add f && git commit -qm "feature base change" + git checkout -q main + git checkout -qb topic + echo t1 >>f && git add f && git commit -qm "topic change" + + run git-autorebase -o feature-base "$(git rev-parse feature-base)" + [ "$status" -eq 0 ] + [ "$(git rev-parse HEAD~1)" = "$(git rev-parse feature-base)" ] +} + +@test "-p pushes the rebased branch to origin (requires an origin/HEAD, e.g. from a clone)" { + bare=$(mktemp -d)/origin.git + git init -q --bare "$bare" + + seed=$(mktemp -d) + ( + cd "$seed" + git init -q + git remote add origin "$bare" + echo base >f && git add f && git commit -qm base + git push -q origin HEAD:main + ) + + clone=$(mktemp -d)/work + git clone -q "$bare" "$clone" + cd "$clone" + git checkout -qb topic + echo t1 >>f && git add f && git commit -qm "topic change" + sha=$(git rev-parse origin/main) + + run git-autorebase -p "$sha" + [ "$status" -eq 0 ] + [[ "$output" == *"Pushing changes..."* ]] + + run git ls-remote "$bare" refs/heads/topic + [ -n "$output" ] +} + +@test "without -p, does not push anything to origin" { + bare=$(mktemp -d)/origin.git + git init -q --bare "$bare" + seed=$(mktemp -d) + ( + cd "$seed" + git init -q + git remote add origin "$bare" + echo base >f && git add f && git commit -qm base + git push -q origin HEAD:main + ) + clone=$(mktemp -d)/work + git clone -q "$bare" "$clone" + cd "$clone" + git checkout -qb topic + echo t1 >>f && git add f && git commit -qm "topic change" + sha=$(git rev-parse origin/main) + + run git-autorebase "$sha" + [ "$status" -eq 0 ] + [[ "$output" != *"Pushing changes..."* ]] + run git ls-remote "$bare" refs/heads/topic + [ -z "$output" ] +} diff --git a/git-co/test.bats b/git-co/test.bats index 9bae324..a800664 100644 --- a/git-co/test.bats +++ b/git-co/test.bats @@ -4,10 +4,22 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name t + git config --global user.email t@t.com + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) + cd "$WORK" + git init -q } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-co is installed on PATH and syntactically valid" { @@ -15,3 +27,66 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage" { + run git-co -h + [[ "$output" == *"git enhanced commit"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "missing commit message is required and exits non-zero" { + echo a >f && git add f + run git-co + [ "$status" -eq 1 ] + [[ "$output" == *"Commit message is required"* ]] + # nothing got committed + run git log --oneline + [ "$status" -ne 0 ] || [ -z "$output" ] +} + +@test "plain commit leaves message unmodified when no gitflow feature prefix is configured" { + echo a >f && git add f + run git-co "feat: add thing" + [ "$status" -eq 0 ] + [ "$(git log -1 --format=%s)" = "feat: add thing" ] +} + +@test "-s injects the given scope into a conventional-commit message" { + echo a >f && git add f + run git-co -s api "feat: add thing" + [ "$status" -eq 0 ] + [ "$(git log -1 --format=%s)" = "feat(api): add thing" ] +} + +@test "-n suppresses scope injection" { + echo a >f && git add f + run git-co -n "feat: add thing" + [ "$status" -eq 0 ] + [ "$(git log -1 --format=%s)" = "feat: add thing" ] +} + +@test "message with an existing scope is left untouched" { + echo a >f && git add f + run git-co "fix(api): thing" + [ "$status" -eq 0 ] + [[ "$output" == *"Scope already set in commit message"* ]] + [ "$(git log -1 --format=%s)" = "fix(api): thing" ] +} + +@test "uses gitflow feature-branch prefix to derive the scope" { + echo base >base.txt && git add base.txt && git commit -qm base + git config gitflow.prefix.feature "feature/" + git checkout -qb feature/login + echo a >f && git add f + run git-co "feat: add login flow" + [ "$status" -eq 0 ] + [ "$(git log -1 --format=%s)" = "feat(login): add login flow" ] +} + +@test "outside a git repository, the underlying git commit fails (nothing committed)" { + cd "$(mktemp -d)" + run git-co "feat: msg" + [[ "$output" == *"not a git repository"* ]] + run git log + [ "$status" -ne 0 ] +} diff --git a/git-degit/test.bats b/git-degit/test.bats index ce586da..449023c 100644 --- a/git-degit/test.bats +++ b/git-degit/test.bats @@ -4,10 +4,31 @@ load ../tests/helpers.bash setup() { setup_scripts_path + + # git-degit shells out to curl|tar. To keep tests hermetic (no network), + # install a fake curl on PATH ahead of the real one: it ignores its + # arguments and always emits a tarball with a single top-level + # directory, mimicking a GitHub/GitLab/Bitbucket archive download. + FAKE_BIN=$(mktemp -d) + cat >"$FAKE_BIN/curl" <<'EOF' +#!/bin/sh +tmp=$(mktemp -d) +mkdir -p "$tmp/repo-master" +echo "hello" >"$tmp/repo-master/README.md" +tar -C "$tmp" -czf - repo-master +rm -rf "$tmp" +EOF + chmod +x "$FAKE_BIN/curl" + export PATH="$FAKE_BIN:$PATH" + + WORK=$(mktemp -d) + cd "$WORK" } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$FAKE_BIN" } @test "git-degit is installed on PATH and syntactically valid" { @@ -15,3 +36,54 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "no arguments: prints usage and exits non-zero" { + run git-degit + [ "$status" -eq 1 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "-h prints usage" { + run git-degit -h + [[ "$output" == *"Clone and degit a repository"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "unsupported host is rejected without attempting a download" { + run git-degit https://example.com/foo/bar + [ "$status" -eq 1 ] + [[ "$output" == *"Unsupported host"* ]] + [ -z "$(ls -A .)" ] +} + +@test "degits a github repo URL into the current directory by default" { + run git-degit https://github.com/foo/bar + [ "$status" -eq 0 ] + [ -f README.md ] + [ "$(cat README.md)" = "hello" ] + # tarball's own top-level directory was stripped + [ ! -d repo-master ] +} + +@test "degits into the given target directory, creating it if needed" { + run git-degit https://github.com/foo/bar mydir + [ "$status" -eq 0 ] + [ -f mydir/README.md ] +} + +@test "recognizes gitlab.com and bitbucket.org hosts" { + run git-degit https://gitlab.com/foo/bar gl-dir + [ "$status" -eq 0 ] + [ -f gl-dir/README.md ] + + run git-degit https://bitbucket.org/foo/bar bb-dir + [ "$status" -eq 0 ] + [ -f bb-dir/README.md ] +} + +@test "strips a trailing .git suffix from the repository name" { + run git-degit https://github.com/foo/bar.git gitsuffix-dir + [ "$status" -eq 0 ] + [[ "$output" == *"Repository: foo/bar"* ]] + [[ "$output" != *"bar.git"* ]] +} diff --git a/git-fix-author/test.bats b/git-fix-author/test.bats index bb361c9..d95cdb3 100644 --- a/git-fix-author/test.bats +++ b/git-fix-author/test.bats @@ -4,10 +4,21 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name "Global User" + git config --global user.email "global@example.com" + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) + cd "$WORK" } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-fix-author is installed on PATH and syntactically valid" { @@ -15,3 +26,58 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage and does not touch any config" { + git init -q + run git-fix-author -h + [[ "$output" == *"Set user.name and user.email"* ]] + [[ "$output" == *"Usage:"* ]] + [[ "$output" == *"git-fix-author"* ]] +} + +@test "fails cleanly outside a git repository" { + run git-fix-author deadbeef + [[ "$output" == *"not a git repository"* ]] +} + +@test "copies user.name/user.email from the given commit's author into local config" { + git init -q + git config commit.gpgsign false + git config user.name Alice + git config user.email alice@example.com + echo a >f && git add f && git commit -qm c1 + git config user.name Bob + git config user.email bob@example.com + echo b >>f && git add f && git commit -qm c2 + sha1=$(git rev-parse HEAD~1) + + run git-fix-author "$sha1" + [ "$(git config user.name)" = "Alice" ] + [ "$(git config user.email)" = "alice@example.com" ] +} + +@test "always removes the global user section, even before setting the local one" { + git init -q + git config commit.gpgsign false + git config user.name Alice + git config user.email alice@example.com + echo a >f && git add f && git commit -qm c1 + sha0=$(git rev-parse HEAD) + + run git-fix-author "$sha0" + run git config --global user.name + [ "$status" -ne 0 ] + [ -z "$output" ] +} + +@test "an invalid sha leaves existing local config untouched" { + git init -q + git config commit.gpgsign false + git config user.name Alice + git config user.email alice@example.com + echo a >f && git add f && git commit -qm c1 + + run git-fix-author deadbeefdeadbeefdeadbeefdeadbeefdeadbeef + [ "$(git config user.name)" = "Alice" ] + [ "$(git config user.email)" = "alice@example.com" ] +} diff --git a/git-fix-base/run.sh b/git-fix-base/run.sh index d4cee8a..091a9f2 100755 --- a/git-fix-base/run.sh +++ b/git-fix-base/run.sh @@ -4,7 +4,7 @@ eval $( zz_args "Fix git base - rebase commits from one branch to another" $0 "$@" <<-help p - push push changes to remote - n - dry-run show what would be done without making changes + n - dryrun show what would be done without making changes - target target target branch to rebase commits onto - source source source branch to take commits from (default: current branch) help @@ -90,7 +90,7 @@ zz_log - " 3. Cherry-pick commits from '$source'" zz_log - " 4. Reset '$source' to the merge base" zz_log - " 5. Fast-forward '$target' to include the rebased commits" echo "" -if ! zz_ask "Yn" "Continue?"; then +if [ "$(zz_ask "Yn" "Continue?")" != "y" ]; then zz_log e "Operation cancelled" exit 1 fi diff --git a/git-fix-base/test.bats b/git-fix-base/test.bats index 9ae15e3..4bddc1e 100644 --- a/git-fix-base/test.bats +++ b/git-fix-base/test.bats @@ -4,10 +4,32 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name t + git config --global user.email t@t.com + git config --global commit.gpgsign false + git config --global init.defaultBranch main + BARE=$(mktemp -d)/origin.git + git init -q --bare "$BARE" + WORK=$(mktemp -d) + cd "$WORK" + git init -q + git remote add origin "$BARE" + echo base >base.txt && git add base.txt && git commit -qm base + git push -q origin HEAD:main + git checkout -qb target + git push -q origin HEAD:target + git checkout -qb source + git push -q origin HEAD:source + echo a >a.txt && git add a.txt && git commit -qm "commit-a" + echo b >b.txt && git add b.txt && git commit -qm "commit-b" } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" "$(dirname "$BARE")" } @test "git-fix-base is installed on PATH and syntactically valid" { @@ -15,3 +37,91 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage" { + run git-fix-base -h + [[ "$output" == *"Fix git base"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "missing target is required and exits non-zero" { + run git-fix-base + [ "$status" -eq 1 ] + [[ "$output" == *"Target branch is required"* ]] +} + +@test "rejects a target branch that does not exist" { + run git-fix-base nope + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "rejects a source branch that does not exist" { + run git-fix-base target nope-source + [ "$status" -eq 1 ] + [[ "$output" == *"does not exist"* ]] +} + +@test "rejects identical source and target branches" { + run git-fix-base source source + [ "$status" -eq 1 ] + [[ "$output" == *"cannot be the same"* ]] +} + +@test "-n dry-run lists the commits without changing any branch" { + target_before=$(git rev-parse target) + source_before=$(git rev-parse source) + + run git-fix-base -n target source + [ "$status" -eq 0 ] + [[ "$output" == *"Commits that would be moved"* ]] + [[ "$output" == *"commit-a"* ]] + [[ "$output" == *"commit-b"* ]] + + [ "$(git rev-parse target)" = "$target_before" ] + [ "$(git rev-parse source)" = "$source_before" ] +} + +@test "with no unpushed commits, reports nothing to move and exits 0" { + git checkout -q source + git reset -q --hard origin/source + run git-fix-base target source + [ "$status" -eq 0 ] + [[ "$output" == *"No commits to move"* ]] +} + +@test "moves unpushed commits from source onto target and resets source to the merge base (with confirmation)" { + run bash -c 'echo y | git-fix-base target source' + [ "$status" -eq 0 ] + [[ "$output" == *"Successfully moved commits"* ]] + + # target now carries both commits + [ "$(git log --oneline target | wc -l)" -eq 3 ] + [[ "$(git log --format=%s target)" == *"commit-a"* ]] + [[ "$(git log --format=%s target)" == *"commit-b"* ]] + + # source was reset back to the merge base + [ "$(git rev-parse source)" = "$(git rev-parse target~2)" ] + + # no leftover temp branch + run git branch --list 'temp-fix-base-*' + [ -z "$output" ] +} + +@test "declining the confirmation prompt cancels without changing any branch" { + target_before=$(git rev-parse target) + source_before=$(git rev-parse source) + + run bash -c 'echo n | git-fix-base target source' + [ "$status" -eq 1 ] + [[ "$output" == *"Operation cancelled"* ]] + + [ "$(git rev-parse target)" = "$target_before" ] + [ "$(git rev-parse source)" = "$source_before" ] +} + +@test "fails cleanly outside a git repository" { + cd "$(mktemp -d)" + run git-fix-base target + [ "$status" -ne 0 ] +} diff --git a/git-fix-blanks/test.bats b/git-fix-blanks/test.bats index c876e35..9117f78 100644 --- a/git-fix-blanks/test.bats +++ b/git-fix-blanks/test.bats @@ -4,10 +4,22 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name t + git config --global user.email t@t.com + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) + cd "$WORK" + git init -q } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-fix-blanks is installed on PATH and syntactically valid" { @@ -15,3 +27,87 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage" { + run git-fix-blanks -h + [[ "$output" == *"Discard changes made only of whitespace"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "no modified tracked files: reports nothing to do" { + printf 'line1\nline2\n' >f.txt + git add f.txt && git commit -qm init + run git-fix-blanks + [ "$status" -eq 0 ] + [[ "$output" == *"No modified tracked files found."* ]] +} + +@test "-d dry-run reports discardable whitespace-only changes without touching the working tree" { + printf 'line1\nline2\n' >f.txt + git add f.txt && git commit -qm init + printf 'line1 \nline2\n' >f.txt + + run git-fix-blanks -d + [ "$status" -eq 0 ] + [[ "$output" == *"Discarding ignorable-only changes in f.txt"* ]] + [[ "$output" == *"Dry run complete. Discardable files: 1, kept: 0, skipped: 0"* ]] + + # working tree change is still present + run git diff --stat + [ -n "$output" ] +} + +@test "discards a whitespace-only modification" { + printf 'line1\nline2\n' >f.txt + git add f.txt && git commit -qm init + printf 'line1 \nline2\n' >f.txt + + run git-fix-blanks + [ "$status" -eq 0 ] + [[ "$output" == *"Done. Discarded: 1, kept: 0, skipped: 0"* ]] + + run git diff --stat + [ -z "$output" ] +} + +@test "keeps a real content change" { + printf 'line1\nline2\n' >f.txt + git add f.txt && git commit -qm init + printf 'line1\nline2 changed\n' >f.txt + + run git-fix-blanks + [ "$status" -eq 0 ] + [[ "$output" == *"Done. Discarded: 0, kept: 1, skipped: 0"* ]] + + run git diff --stat + [ -n "$output" ] +} + +@test "discards a comment-only change in a .sh file" { + printf '# comment\nfoo\n' >s.sh + git add s.sh && git commit -qm init + printf '# comment changed\nfoo\n' >s.sh + + run git-fix-blanks + [[ "$output" == *"Discarded: 1, kept: 0, skipped: 0"* ]] + [ "$(cat s.sh)" = "$(printf '# comment\nfoo\n')" ] +} + +@test "deleted tracked files are not touched (diff-filter=M excludes deletions)" { + printf 'a\n' >d.txt + git add d.txt && git commit -qm add + rm d.txt + + run git-fix-blanks + [ "$status" -eq 0 ] + [[ "$output" == *"No modified tracked files found."* ]] + run git status --short + [[ "$output" == *"D d.txt"* ]] +} + +@test "outside a git repository, exits cleanly reporting no modified files" { + cd "$(mktemp -d)" + run git-fix-blanks + [ "$status" -eq 0 ] + [[ "$output" == *"No modified tracked files found."* ]] +} diff --git a/git-fix-children/run.sh b/git-fix-children/run.sh index e466d08..2e308ad 100755 --- a/git-fix-children/run.sh +++ b/git-fix-children/run.sh @@ -29,7 +29,7 @@ fi zz_log i "Deleting all local branches that are descendants of $sha (except current, main, master)" current=$(git rev-parse --abbrev-ref HEAD) -branches=$(git branch --contains "$sha" | sed "s/^[* ]*//" | grep -vE "^($current|main|master)$") +branches=$(git branch --contains "$sha" | sed "s/^[* ]*//" | grep -vE "^($current|main|master)$" || true) if [ -n "$branches" ]; then echo "$branches" | xargs -r git branch -D else diff --git a/git-fix-children/test.bats b/git-fix-children/test.bats index f1f9dfd..fa15160 100644 --- a/git-fix-children/test.bats +++ b/git-fix-children/test.bats @@ -4,10 +4,21 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name t + git config --global user.email t@t.com + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) + cd "$WORK" } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-fix-children is installed on PATH and syntactically valid" { @@ -15,3 +26,76 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage" { + git init -q + run git-fix-children -h + [[ "$output" == *"Delete all descendant tags and branches"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "fails cleanly outside a git repository" { + run git-fix-children abc + [ "$status" -ne 0 ] + [[ "$output" == *"not a git repository"* ]] +} + +@test "no descendant tags or branches: succeeds cleanly (exit 0)" { + git init -q + echo a >f && git add f && git commit -qm c1 + sha=$(git rev-parse HEAD) + + run git-fix-children "$sha" + [ "$status" -eq 0 ] + [[ "$output" == *"No descendant tags found"* ]] + [[ "$output" == *"No descendant branches found"* ]] +} + +@test "deletes descendant tags and branches, but preserves current/main/master" { + git init -q + echo a >f && git add f && git commit -qm c1 + sha1=$(git rev-parse HEAD) + echo b >>f && git add f && git commit -qm c2 + git tag v1 + git branch feature + echo c >>f && git add f && git commit -qm c3 + git tag v2 + + run git-fix-children "$sha1" + [ "$status" -eq 0 ] + + run git tag + [ -z "$output" ] + + run git branch --format='%(refname:short)' + [ "$output" = "main" ] +} + +@test "without -p, warns that remote deletions were not pushed" { + git init -q + echo a >f && git add f && git commit -qm c1 + sha1=$(git rev-parse HEAD) + echo b >>f && git add f && git commit -qm c2 + git tag v1 + + run git-fix-children "$sha1" + [[ "$output" == *"Remote deletions not pushed"* ]] +} + +@test "-p pushes tag deletions to the remote" { + bare=$(mktemp -d)/o.git + git init -q --bare "$bare" + git init -q + git remote add origin "$bare" + echo a >f && git add f && git commit -qm c1 + sha1=$(git rev-parse HEAD) + echo b >>f && git add f && git commit -qm c2 + git tag v1 + git push -q origin HEAD:main --tags + + run git-fix-children -p "$sha1" + [ "$status" -eq 0 ] + + run git ls-remote --tags "$bare" + [ -z "$output" ] +} diff --git a/git-fix-date/run.sh b/git-fix-date/run.sh index 66149a3..3637de4 100755 --- a/git-fix-date/run.sh +++ b/git-fix-date/run.sh @@ -164,7 +164,7 @@ fi # Ask for confirmation before proceeding (use helper prompt) zz_log w "This will rewrite git history. Make sure you understand the consequences." -if ! zz_ask "Yn" "Do you want to proceed?"; then +if [ "$(zz_ask "Yn" "Do you want to proceed?")" != "y" ]; then zz_log i "Operation cancelled by user." exit 1 fi diff --git a/git-fix-date/test.bats b/git-fix-date/test.bats index ec1cac1..5c1bdff 100644 --- a/git-fix-date/test.bats +++ b/git-fix-date/test.bats @@ -4,10 +4,22 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ORIG_HOME="$HOME" + export HOME="$(mktemp -d)" + git config --global user.name t + git config --global user.email t@t.com + git config --global commit.gpgsign false + git config --global init.defaultBranch main + WORK=$(mktemp -d) + cd "$WORK" + git init -q } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" "$HOME" + export HOME="$ORIG_HOME" } @test "git-fix-date is installed on PATH and syntactically valid" { @@ -15,3 +27,122 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "-h prints usage" { + run git-fix-date -h + [[ "$output" == *"Fix commit dates and times"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "fails cleanly outside a git repository" { + cd "$(mktemp -d)" + run git-fix-date -d + [ "$status" -ne 0 ] +} + +@test "refuses to run with uncommitted changes present" { + echo a >f && git add f && git commit -qm init + echo dirty >f + run git-fix-date -d + [ "$status" -eq 1 ] + [[ "$output" == *"uncommitted changes"* ]] +} + +@test "rejects invalid time formats" { + echo a >f && git add f && git commit -qm init + run git-fix-date -d -s "not-a-time" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid start time format"* ]] + + run git-fix-date -d -e "not-a-time" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid end time format"* ]] + + run git-fix-date -d -b "not-a-time" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid before time format"* ]] + + run git-fix-date -d -a "not-a-time" + [ "$status" -eq 1 ] + [[ "$output" == *"Invalid after time format"* ]] +} + +@test "-d dry-run reports the reschedule plan without rewriting history" { + echo a >f && git add f + GIT_AUTHOR_DATE="2024-01-08T09:00:00" GIT_COMMITTER_DATE="2024-01-08T09:00:00" \ + git commit -qm "mon commit" + before=$(git rev-parse HEAD) + + run git-fix-date -d + [ "$status" -eq 0 ] + [[ "$output" == *"DRY RUN MODE"* ]] + [[ "$output" == *"2024-01-08 09:00:00 → 2024-01-08 06:00:00"* ]] + [[ "$output" == *"Dry run complete. No changes were made."* ]] + + [ "$(git rev-parse HEAD)" = "$before" ] + [ "$(git log -1 --format=%ai)" = "2024-01-08 09:00:00 +0000" ] +} + +@test "reschedules a commit in the first half of the range to the 'before' time (with confirmation)" { + echo a >f && git add f + GIT_AUTHOR_DATE="2024-01-08T09:00:00" GIT_COMMITTER_DATE="2024-01-08T09:00:00" \ + git commit -qm "mon commit" + + run bash -c 'echo y | git-fix-date' + [ "$status" -eq 0 ] + [[ "$output" == *"Git date fixup completed successfully."* ]] + [ "$(git log -1 --format=%ai)" = "2024-01-08 06:00:00 +0000" ] +} + +@test "reschedules a commit in the second half of the range to the 'after' time" { + echo a >f && git add f + GIT_AUTHOR_DATE="2024-01-08T16:00:00" GIT_COMMITTER_DATE="2024-01-08T16:00:00" \ + git commit -qm "mon afternoon commit" + + run bash -c 'echo y | git-fix-date' + [ "$status" -eq 0 ] + [ "$(git log -1 --format=%ai)" = "2024-01-08 20:00:00 +0000" ] +} + +@test "leaves commits outside the configured days/time range untouched" { + echo a >f && git add f + GIT_AUTHOR_DATE="2024-01-06T10:00:00" GIT_COMMITTER_DATE="2024-01-06T10:00:00" \ + git commit -qm "sat commit" + + run bash -c 'echo y | git-fix-date' + [ "$status" -eq 0 ] + [ "$(git log -1 --format=%ai)" = "2024-01-06 10:00:00 +0000" ] +} + +@test "an sha argument limits rescheduling to commits made after it" { + echo a >f && git add f + GIT_AUTHOR_DATE="2024-01-08T09:00:00" GIT_COMMITTER_DATE="2024-01-08T09:00:00" \ + git commit -qm c1 + sha1=$(git rev-parse HEAD) + echo b >>f && git add f + GIT_AUTHOR_DATE="2024-01-09T09:00:00" GIT_COMMITTER_DATE="2024-01-09T09:00:00" \ + git commit -qm c2 + + run bash -c "echo y | git-fix-date '$sha1'" + [ "$status" -eq 0 ] + # c1 (before/at sha) is untouched, c2 (after sha) is rescheduled + [ "$(git log --format=%ai -1 HEAD~1)" = "2024-01-08 09:00:00 +0000" ] + [ "$(git log --format=%ai -1 HEAD)" = "2024-01-09 06:00:00 +0000" ] +} + +@test "declining the confirmation prompt cancels without rewriting history" { + echo z >f0 && git add f0 && git commit -qm base + base_sha=$(git rev-parse HEAD) + echo a >f && git add f + GIT_AUTHOR_DATE="2024-01-08T09:00:00" GIT_COMMITTER_DATE="2024-01-08T09:00:00" \ + git commit -qm "mon commit" + before=$(git rev-parse HEAD) + + # Pass sha explicitly so the "n" answer goes to the confirmation + # prompt, not to git-getcommit's interactive "which commit?" prompt. + run bash -c "echo n | git-fix-date '$base_sha'" + [ "$status" -eq 1 ] + [[ "$output" == *"Operation cancelled by user."* ]] + [ "$(git rev-parse HEAD)" = "$before" ] + [ "$(git log -1 --format=%ai)" = "2024-01-08 09:00:00 +0000" ] +} diff --git a/git-fix-del/test.bats b/git-fix-del/test.bats index 8a063a7..0943dd1 100644 --- a/git-fix-del/test.bats +++ b/git-fix-del/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q -b main + git config user.email a@example.com + git config user.name "Test User" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +23,55 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-del -h prints usage and exits non-zero" { + run git-fix-del -h f && git add f && git commit -qm "first" + sha=$(git rev-parse HEAD) + run git-fix-del -a "$sha" f && git add f && git commit -qm "first" + run git-fix-del -a deadbeef f && git add f && git commit -qm "first" + echo two >> f && git add f && git commit -qm "second" + target=$(git rev-parse HEAD) + echo three >> f && git add f && git commit -qm "third" + + run git-fix-del -a "$target" f && git add f && git commit -qm "first" + echo dirty >> f + run git-fix-emoji f && git add f && git commit -qm "orig msg" + parent_count_before=$(git log --oneline | wc -l) + + run git-fix-last -m "amended msg" f && git add f && git commit -qm "orig msg" + before_tree=$(git rev-parse HEAD^{tree}) + + run git-fix-last -m "new message here" no conflicted files found => nothing done, no crash/hang + [ "$status" -eq 0 ] +} + +@test "git-fix-lock does nothing when there are no lock-file conflicts" { + echo hi > f && git add f && git commit -qm "first" + run git-fix-lock package.json + echo '{"name":"t","version":"1.0.0","lockfileVersion":3,"note":"base"}' > package-lock.json + git add -A && git commit -qm base + + git checkout -qb feature + echo '{"name":"t","version":"1.0.0","lockfileVersion":3,"note":"feature"}' > package-lock.json + git add -A && git commit -qm feature + + git checkout -q main + echo '{"name":"t","version":"1.0.0","lockfileVersion":3,"note":"ours"}' > package-lock.json + git add -A && git commit -qm mainchange + + git merge feature -q || true + run git status --porcelain + [[ "$output" == *"UU package-lock.json"* ]] + + run git-fix-lock f && git add f && git commit -qm "first" + echo dirty >> f + run git-fix-message -m "new" HEAD f && git add f && git commit -qm "first" + run git-fix-message -m "new" deadbeef f && git add f && git commit -qm "first" + git checkout -qb other + echo two >> f && git add f && git commit -qm "other-branch-commit" + other_sha=$(git rev-parse HEAD) + git checkout -q main + + run git-fix-message -m "new" "$other_sha" f && git add f && git commit -qm "first" + echo two >> f && git add f && git commit -qm "second" + target=$(git rev-parse HEAD) + echo three >> f && git add f && git commit -qm "third" + + run bash -c 'echo y | git-fix-message -m "reworded second" '"$target" + [ "$status" -eq 0 ] + [[ "$output" == *"Commit message rewritten successfully"* ]] + + run git log --format=%s + [[ "$output" == *"reworded second"* ]] + [[ "$output" == *"first"* ]] + [[ "$output" == *"third"* ]] + [[ "$output" != *$'\n'"second"$'\n'* ]] + + # history length and file content preserved + [ "$(git log --oneline | wc -l)" -eq 3 ] + [ "$(cat f)" = "$(printf 'one\ntwo\nthree')" ] +} + +@test "git-fix-message aborts when the user declines the confirmation prompt" { + echo one > f && git add f && git commit -qm "first" + orig=$(git log -1 --format=%s) + + run bash -c 'echo n | git-fix-message -m "should not apply" HEAD' + [ "$status" -ne 0 ] + [[ "$output" == *"cancelled by user"* ]] + [ "$(git log -1 --format=%s)" = "$orig" ] +} diff --git a/git-fix-mode/test.bats b/git-fix-mode/test.bats index d001ff9..6418f54 100644 --- a/git-fix-mode/test.bats +++ b/git-fix-mode/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q -b main + git config user.email a@example.com + git config user.name "Test User" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +23,39 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-mode does nothing (exit 0) when there is no mode diff" { + echo hi > f && git add f && git commit -qm "first" + run git-fix-mode + [ "$status" -eq 0 ] + [ -z "$(git status --porcelain)" ] +} + +@test "git-fix-mode reverts a tracked file's mode change back to what git recorded" { + echo hi > script.sh + chmod 644 script.sh + git add script.sh && git commit -qm "add" + + chmod 755 script.sh + [ -n "$(git diff -p -R --no-color)" ] + + run git-fix-mode + [ "$status" -eq 0 ] + + mode=$(stat -c '%a' script.sh) + [ "$mode" = "644" ] + [ -z "$(git status --porcelain)" ] +} + +@test "git-fix-mode leaves deleted files alone" { + echo hi > script.sh + chmod 644 script.sh + git add script.sh && git commit -qm "add" + + rm script.sh + run git-fix-mode + [ "$status" -eq 0 ] + [ ! -e script.sh ] + run git status --porcelain + [[ "$output" == *"D script.sh"* ]] || [[ "$output" == *" D script.sh"* ]] +} diff --git a/git-fix-privacy/run.sh b/git-fix-privacy/run.sh index 23d26a1..d781817 100755 --- a/git-fix-privacy/run.sh +++ b/git-fix-privacy/run.sh @@ -24,7 +24,7 @@ if [ -z "$old" ]; then old=$(git log -1 --pretty=format:'%ae') # Asks for confirmation to proceed with the old email - zz_ask Yn "Do you want to proceed with this <$old> as old email?" || exit 1 + [ "$(zz_ask Yn "Do you want to proceed with this <$old> as old email?")" = "y" ] || exit 1 fi # Check if the new option is set diff --git a/git-fix-privacy/test.bats b/git-fix-privacy/test.bats index 6a52e70..d77b64e 100644 --- a/git-fix-privacy/test.bats +++ b/git-fix-privacy/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q -b main + git config user.email old@example.com + git config user.name "Old Name" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +23,42 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-privacy -h prints usage and exits non-zero" { + run git-fix-privacy -h f && git add f && git commit -qm "first" + + run git-fix-privacy -o old@example.com -n new@example.com -a "New Name" f && git add f && git commit -qm "first" + git config user.email someoneelse@example.com + git config user.name "Someone Else" + echo two >> f && git add f && git commit -qm "second" + + run git-fix-privacy -o old@example.com -n new@example.com -a "New Name" f && git add f && git commit -qm "first" + git push -q origin main } teardown() { + cd / + rm -rf "$REPO" "$ORIGIN" teardown_scripts_path } @@ -15,3 +28,49 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-prune -h prints usage and exits non-zero" { + run git-fix-prune -h no remotes found => nothing to prune, no crash/hang + [ "$status" -eq 0 ] +} + +@test "git-fix-prune removes stale remote-tracking refs for a branch deleted on the remote" { + git checkout -qb feature + git push -q origin feature + git checkout -q main + + # simulate the branch having been deleted directly on the remote + git -C "$ORIGIN" branch -D feature + + run git branch -r + [[ "$output" == *"origin/feature"* ]] + + run git-fix-prune + [ "$status" -eq 0 ] + [[ "$output" == *"pruned"* ]] + + run git branch -r + [[ "$output" != *"origin/feature"* ]] + [[ "$output" == *"origin/main"* ]] +} + +@test "git-fix-prune accepts an explicit remote name" { + git checkout -qb feature2 + git push -q origin feature2 + git checkout -q main + git -C "$ORIGIN" branch -D feature2 + + run git-fix-prune origin + [ "$status" -eq 0 ] + run git branch -r + [[ "$output" != *"origin/feature2"* ]] +} diff --git a/git-fix-rights/test.bats b/git-fix-rights/test.bats index 2e74f8e..a7d501d 100644 --- a/git-fix-rights/test.bats +++ b/git-fix-rights/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q -b main + git config user.email a@example.com + git config user.name "Test User" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +23,46 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-rights -h prints usage and exits non-zero" { + run git-fix-rights -h no tracked files => nothing to chmod, no crash/hang + [ "$status" -eq 0 ] +} + +@test "git-fix-rights normalizes permissions for tracked files and directories" { + echo hi > readme.txt && chmod 600 readme.txt + printf '#!/bin/sh\necho hi\n' > script.sh && chmod 600 script.sh + echo SECRET=1 > secrets.env && chmod 644 secrets.env + mkdir -p logs && echo l > logs/a.log && chmod 755 logs + git add -A && git commit -qm "add" + + run git-fix-rights + [ "$status" -eq 0 ] + [[ "$output" == *"Access rights have been set"* ]] + + [ "$(stat -c '%a' readme.txt)" = "644" ] + [ "$(stat -c '%a' script.sh)" = "755" ] + [ "$(stat -c '%a' secrets.env)" = "600" ] + [ "$(stat -c '%a' logs)" = "700" ] +} + +@test "git-fix-rights leaves untracked files alone" { + echo hi > tracked.txt && chmod 600 tracked.txt + git add tracked.txt && git commit -qm "add" + echo hi > untracked.txt && chmod 600 untracked.txt + + run git-fix-rights + [ "$status" -eq 0 ] + + [ "$(stat -c '%a' tracked.txt)" = "644" ] + [ "$(stat -c '%a' untracked.txt)" = "600" ] +} diff --git a/git-fix-secrets/run.sh b/git-fix-secrets/run.sh index 87bb0c6..d51f512 100755 --- a/git-fix-secrets/run.sh +++ b/git-fix-secrets/run.sh @@ -94,7 +94,7 @@ if [ -n "$dryrun" ]; then fi zz_log w "This will rewrite git history. Make sure you understand the consequences." -if ! zz_ask "Yn" "Do you want to proceed?"; then +if [ "$(zz_ask "Yn" "Do you want to proceed?")" != "y" ]; then zz_log i "Operation cancelled by user." exit 1 fi diff --git a/git-fix-secrets/test.bats b/git-fix-secrets/test.bats index dae6f2c..0b9a090 100644 --- a/git-fix-secrets/test.bats +++ b/git-fix-secrets/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q -b main + git config user.email a@example.com + git config user.name "Test User" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +23,95 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-secrets -h prints usage and exits non-zero" { + run git-fix-secrets -h f && git add f && git commit -qm "c1" + run git-fix-secrets -s "somesecret" f && git add f && git commit -qm "c1" + run git-fix-secrets -g "*.env" f && git add f && git commit -qm "c1" + echo dirty >> f + run git-fix-secrets -g "*.env" -s "x" .env + git add .env && git commit -qm "add env" + + run git-fix-secrets -g "*.env" -s "supersecret123" .env + git add .env && git commit -qm "add env" + + run git-fix-secrets -d -g "*.env" -s "supersecret123" .env + git add .env && git commit -qm "add env" + + run bash -c 'echo y | git-fix-secrets -g "*.env" -s "supersecret123" -r "REDACTED"' + [ "$status" -eq 0 ] + [[ "$output" == *"Secret replaced"* ]] + + [ "$(cat .env)" = "API_KEY=REDACTED" ] + ! git log -p --all -- .env | grep -q "supersecret123" +} + +@test "git-fix-secrets aborts the rewrite when the user declines" { + printf 'API_KEY=supersecret123\n' > .env + git add .env && git commit -qm "add env" + sha=$(git rev-parse HEAD) + + run bash -c "echo n | git-fix-secrets -g '*.env' -s supersecret123 $sha" + [ "$status" -ne 0 ] + [[ "$output" == *"cancelled by user"* ]] + [ "$(cat .env)" = "API_KEY=supersecret123" ] +} + +@test "git-fix-secrets also redacts the secret from commit messages with -m" { + printf 'nothing secret here\n' > other.txt + git add other.txt + git commit -qm "commit mentioning supersecret123 in message" + + run bash -c 'echo y | git-fix-secrets -m -g "*.env" -s "supersecret123" -r "REDACTED"' + [ "$status" -eq 0 ] + + run git log -1 --format=%s + [[ "$output" == *"REDACTED"* ]] + [[ "$output" != *"supersecret123"* ]] +} diff --git a/git-fix-up/test.bats b/git-fix-up/test.bats index dd4ff52..b809c17 100644 --- a/git-fix-up/test.bats +++ b/git-fix-up/test.bats @@ -4,9 +4,19 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q -b main + git config user.email a@example.com + git config user.name "Test User" + git config commit.gpgsign false + export GIT_SEQUENCE_EDITOR=true + export GIT_EDITOR=true } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +25,51 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-fix-up -h prints usage and exits non-zero" { + run git-fix-up -h f && git add f && git commit -qm "first" + echo '{}' > package-lock.json + git add package-lock.json + + run git-fix-up + [ "$status" -ne 0 ] + [[ "$output" == *"Packages lock file are staged"* ]] +} + +@test "git-fix-up refuses when nothing is staged" { + echo one > f && git add f && git commit -qm "first" + + run git-fix-up + [ "$status" -ne 0 ] + [[ "$output" == *"No files are staged"* ]] +} + +@test "git-fix-up creates a fixup commit for the target and autosquashes it in" { + echo one > f && git add f && git commit -qm "first" + echo two >> f && git add f && git commit -qm "second" + target=$(git rev-parse HEAD) + + echo extra > g + git add g + + run timeout 20 git-fix-up "$target" + [ "$status" -eq 0 ] + + # no fixup commit left dangling, history squashed back into 2 commits + [ "$(git log --oneline | wc -l)" -eq 2 ] + run git log --format=%s + [[ "$output" != *"fixup!"* ]] + + # content of the fixup was folded into the "second" commit + run git show --stat HEAD + [[ "$output" == *"g"* ]] + [ "$(cat g)" = "extra" ] + [ -z "$(git status --porcelain)" ] +} diff --git a/git-fix/test.bats b/git-fix/test.bats index 119acfe..24b9207 100644 --- a/git-fix/test.bats +++ b/git-fix/test.bats @@ -4,10 +4,18 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK=$(mktemp -d) + cd "$WORK" + git init -q + git config commit.gpgsign false + git config user.name t + git config user.email t@t.com } teardown() { teardown_scripts_path + cd / + rm -rf "$WORK" } @test "git-fix is installed on PATH and syntactically valid" { @@ -15,3 +23,33 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "no subcommand: exits non-zero and lists available utilities" { + run git-fix + [ "$status" -eq 1 ] + [[ "$output" == *"No subcommand provided."* ]] + [[ "$output" == *"git-fix-author"* ]] + [[ "$output" == *"git-fix-blanks"* ]] +} + +@test "unknown subcommand: warns and lists available utilities, without erroring the shell" { + run git-fix bogus-subcommand + [ "$status" -eq 0 ] + [[ "$output" == *"No dispatch target found"* ]] + [[ "$output" == *"git-fix-author"* ]] +} + +@test "dispatches to git-fix-author with remaining args" { + echo a >f && git add f && git commit -qm init + sha=$(git rev-parse HEAD) + run git-fix author "$sha" + [[ "$output" == *"Dispatching to executable target:"* ]] + [[ "$output" == *"git-fix-author"* ]] +} + +@test "dispatches to git-fix-blanks (dry-run) with remaining args" { + echo a >f && git add f && git commit -qm init + run git-fix blanks -d + [[ "$output" == *"Dispatching to executable target:"* ]] + [[ "$output" == *"git-fix-blanks"* ]] +} diff --git a/git-forall/test.bats b/git-forall/test.bats index ea02144..6c9278c 100644 --- a/git-forall/test.bats +++ b/git-forall/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +23,44 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "runs command against every tracked and untracked (non-ignored) file" { + echo hi >tracked.txt + git add tracked.txt + git commit -qm init + echo hi >untracked.txt + echo ignoreme >ignored.txt + echo ignored.txt >.gitignore + git add .gitignore + git commit -qm gitignore + + run git-forall echo + [ "$status" -eq 0 ] + [[ "$output" == *"tracked.txt"* ]] + [[ "$output" == *"untracked.txt"* ]] + [[ "$output" != *"ignored.txt"* ]] +} + +@test "invokes the given command once per file with the file as final argument" { + echo a >one.txt + echo b >two.txt + git add one.txt two.txt + git commit -qm init + + run git-forall wc -l + [ "$status" -eq 0 ] + # wc -l one.txt / wc -l two.txt each report 1 line + [ "$(echo "$output" | grep -c '^1 ')" -eq 2 ] +} + +@test "produces no output when there are no files" { + run git-forall echo + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "fails cleanly outside a git repository" { + cd / + run git-forall echo + [[ "$output" == *"not a git repository"* ]] +} diff --git a/git-getcommit/test.bats b/git-getcommit/test.bats index ede6bca..f9f47a8 100644 --- a/git-getcommit/test.bats +++ b/git-getcommit/test.bats @@ -4,9 +4,25 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test" + git config commit.gpgsign false + echo a >a.txt + git add a.txt + git commit -q -m "first" + FIRST_SHA=$(git rev-parse HEAD) + echo b >b.txt + git add b.txt + git commit -q -m "second" + SECOND_SHA=$(git rev-parse HEAD) } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } @@ -15,3 +31,40 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-getcommit -h prints usage and exits non-zero" { + run git-getcommit -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "git-getcommit prints the resolved full sha for a given commit" { + run git-getcommit "$SECOND_SHA" + [ "$status" -eq 0 ] + [[ "$output" == *"$SECOND_SHA"* ]] +} + +@test "git-getcommit resolves an abbreviated sha to the full sha" { + short=$(git rev-parse --short "$SECOND_SHA") + run git-getcommit "$short" + [ "$status" -eq 0 ] + [[ "$output" == *"$SECOND_SHA"* ]] +} + +@test "git-getcommit treats sha '0' as the very first commit in history" { + run git-getcommit 0 + [ "$status" -eq 0 ] + [[ "$output" == *"$FIRST_SHA"* ]] +} + +@test "git-getcommit -p prints the parent of the given commit" { + run git-getcommit -p "$SECOND_SHA" + [ "$status" -eq 0 ] + [[ "$output" == *"$FIRST_SHA"* ]] +} + +@test "git-getcommit prints nothing (but does not crash) for an unresolvable sha" { + run git-getcommit doesnotexistsha + [[ "$output" != *"$SECOND_SHA"* ]] + [[ "$output" != *"$FIRST_SHA"* ]] +} diff --git a/git-integrate/test.bats b/git-integrate/test.bats index 639879b..075f7c4 100644 --- a/git-integrate/test.bats +++ b/git-integrate/test.bats @@ -4,9 +4,20 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false + printf 'hello\n' >a.txt + git add a.txt + git commit -qm init } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +26,46 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "sets core.autocrlf to false" { + git config core.autocrlf true + run git-integrate + [ "$status" -eq 0 ] + [ "$(git config core.autocrlf)" = "false" ] +} + +@test "reverts a modified file whose only diff is whitespace/CRLF" { + printf 'hello\r\n' >a.txt + run git-integrate + [ "$status" -eq 0 ] + [ -z "$(git status --porcelain)" ] + [ "$(git diff --stat)" = "" ] +} + +@test "stages a modified file with real content changes" { + printf 'goodbye\n' >a.txt + run git-integrate + [ "$status" -eq 0 ] + [[ "$output" == *"File changed: a.txt"* ]] + [ "$(git status --porcelain a.txt)" = "M a.txt" ] +} + +@test "stages a new untracked file" { + printf 'new\n' >b.txt + run git-integrate + [ "$status" -eq 0 ] + [[ "$output" == *"Add new file: b.txt"* ]] + [ "$(git status --porcelain b.txt)" = "A b.txt" ] +} + +@test "leaves a clean working tree untouched" { + run git-integrate + [ "$status" -eq 0 ] + [ -z "$(git status --porcelain)" ] +} + +@test "does not crash outside a git repository" { + cd / + run git-integrate + [[ "$output" == *"not a git repository"* ]] +} diff --git a/git-pick/test.bats b/git-pick/test.bats index b259d70..1ef59d4 100644 --- a/git-pick/test.bats +++ b/git-pick/test.bats @@ -4,9 +4,24 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false + printf 'v1\n' >a.txt + git add a.txt + git commit -qm c1 + C1=$(git rev-parse HEAD) + printf 'v2\n' >a.txt + git add a.txt + git commit -qm c2 } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +30,55 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "--help prints usage and exits non-zero" { + run git-pick -h + [ "$status" -eq 1 ] + [[ "$output" == *"Pick files from a specific commit"* ]] + [[ "$output" == *"Usage:"* ]] +} + +@test "restores current directory content from the given commit into the worktree and index" { + run git-pick -c "$C1" + [ "$status" -eq 0 ] + [[ "$output" == *"Successfully picked files from commit $C1"* ]] + [ "$(cat a.txt)" = "v1" ] + [ "$(git status --porcelain a.txt)" = "M a.txt" ] +} + +@test "restores only the given path when one is provided" { + printf 'other\n' >b.txt + git add b.txt + git commit -qm c3 + + run git-pick -c "$C1" a.txt + [ "$status" -eq 0 ] + [ "$(cat a.txt)" = "v1" ] + [ -f b.txt ] + [ "$(git status --porcelain)" = "M a.txt" ] +} + +@test "defaults the path to the current directory relative to repo root" { + mkdir sub + printf 'nested\n' >sub/n.txt + git add sub/n.txt + git commit -qm c3 + printf 'changed\n' >sub/n.txt + cd sub + + run git-pick -c "$C1" + [ "$status" -eq 0 ] + [[ "$output" == *"Target path: sub"* || "$output" == *"Target path: ."* ]] +} + +@test "fails cleanly when given an invalid commit" { + run git-pick -c deadbeef + [ "$status" -eq 1 ] + [[ "$output" == *"Failed to pick files from commit deadbeef"* ]] +} + +@test "fails cleanly outside a git repository" { + cd / + run git-pick -c HEAD + [ "$status" -ne 0 ] +} diff --git a/git-release-alpha/test.bats b/git-release-alpha/test.bats index b0c2efa..1d98763 100644 --- a/git-release-alpha/test.bats +++ b/git-release-alpha/test.bats @@ -4,9 +4,19 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false + git commit -q --allow-empty -m init + git checkout -qb develop } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +25,107 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "--help prints usage and exits non-zero" { + run git-release-alpha -h + [ "$status" -eq 1 ] + [[ "$output" == *"Release squashed current branch to develop branch"* ]] +} + +@test "fails when no message is provided" { + git checkout -qb feature/foo + run git-release-alpha + [ "$status" -eq 1 ] + [[ "$output" == *"must provide a message"* ]] +} + +@test "fails when not on a feature/ branch" { + run git-release-alpha -m "feat: msg" + [ "$status" -eq 1 ] + [[ "$output" == *"must be on a feature/xxx branch"* ]] +} + +@test "squash-merges the feature branch into develop, scoping the message with the feature name" { + git checkout -qb feature/foo + echo 1 >f1.txt + git add f1.txt + git commit -qm "feat: add f1" + + run git-release-alpha -m "feat: msg" + [ "$status" -eq 0 ] + [[ "$output" == *"successfully finished and squashed to develop"* ]] + + [ "$(git rev-parse --abbrev-ref HEAD)" = "feature/foo" ] + subject=$(git log develop -1 --pretty=%s) + [ "$subject" = "feat(foo): msg" ] + git show develop:f1.txt +} + +@test "marks the squash commit as breaking when a commit uses the ! convention" { + git checkout -qb feature/foo + echo 1 >f1.txt + git add f1.txt + git commit -qm "feat!: breaking change" + + run git-release-alpha -m "feat: msg" + [ "$status" -eq 0 ] + subject=$(git log develop -1 --pretty=%s) + [ "$subject" = "feat(foo)!: msg" ] +} + +@test "uses the most occurring commit type when -o is given and types differ" { + git checkout -qb feature/foo + echo 1 >f1.txt + git add f1.txt + git commit -qm "fix: one" + echo 2 >f2.txt + git add f2.txt + git commit -qm "fix: two" + echo 3 >f3.txt + git add f3.txt + git commit -qm "chore: three" + + run git-release-alpha -m "misc: msg" -o + [ "$status" -eq 0 ] + subject=$(git log develop -1 --pretty=%s) + [ "$subject" = "fix(foo): msg" ] +} + +@test "restores stashed working-tree changes after finishing" { + git checkout -qb feature/foo + echo 1 >f1.txt + git add f1.txt + git commit -qm "feat: add f1" + echo dirty >untracked.txt + + run git-release-alpha -m "feat: msg" + [ "$status" -eq 0 ] + [ "$(cat untracked.txt)" = "dirty" ] + [ -z "$(git stash list)" ] +} + +@test "pushes develop to origin when -p is given" { + bare=$(mktemp -d)/origin.git + git init -q --bare "$bare" + git remote add origin "$bare" + git push -qu origin develop + + git checkout -qb feature/foo + echo 1 >f1.txt + git add f1.txt + git commit -qm "feat: add f1" + + run git-release-alpha -m "feat: msg" -p + [ "$status" -eq 0 ] + [[ "$output" == *"pushed to remote repository successfully"* ]] + + remote_subject=$(git --git-dir="$bare" log develop -1 --pretty=%s) + [ "$remote_subject" = "feat(foo): msg" ] + rm -rf "$bare" +} + +@test "fails cleanly outside a git repository" { + cd / + run git-release-alpha -m "feat: msg" + [ "$status" -ne 0 ] +} diff --git a/git-release-beta/test.bats b/git-release-beta/test.bats index e41c90b..1009fab 100644 --- a/git-release-beta/test.bats +++ b/git-release-beta/test.bats @@ -4,9 +4,47 @@ load ../tests/helpers.bash setup() { setup_scripts_path + + # Stub external dependencies (GitVersion `gv` and git-flow) that + # git-release-beta shells out to, so its own branch/state logic can be + # exercised hermetically without either tool actually installed. + cat >"$TEST_BIN/gv" <<'EOF' +#!/bin/sh +echo "${GBV_STUB:-1.2.3}" +EOF + chmod +x "$TEST_BIN/gv" + + cat >"$TEST_BIN/git-flow" <<'EOF' +#!/bin/sh +sub=$1; shift +action=$1; shift +case "$sub-$action" in + release-start) + name=$1 + git checkout -qb "release/$name" develop + ;; +esac +EOF + chmod +x "$TEST_BIN/git-flow" + + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false + git commit -q --allow-empty -m init + git checkout -qb develop + + BARE=$(mktemp -d)/origin.git + git init -q --bare "$BARE" + git remote add origin "$BARE" + git push -qu origin develop } teardown() { + cd / + rm -rf "$REPO" "$(dirname "$BARE")" teardown_scripts_path } @@ -15,3 +53,45 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "creates and pushes a release branch named after the computed version" { + run git-release-beta + [ "$status" -eq 0 ] + [ "$(git rev-parse --abbrev-ref HEAD)" = "release/1.2.3" ] + [ "$(cat .git/RELEASE)" = "1.2.3" ] + git --git-dir="$BARE" show-ref --verify --quiet refs/heads/release/1.2.3 +} + +@test "is idempotent: re-running resumes an already-created release branch" { + git-release-beta + run git-release-beta + [ "$status" -eq 0 ] + [[ "$output" == *"already exists, resuming"* ]] + [ "$(git rev-parse --abbrev-ref HEAD)" = "release/1.2.3" ] +} + +@test "refuses to proceed when a different release branch already exists" { + git checkout -qb release/9.9.9 develop + run git-release-beta + [ "$status" -eq 1 ] + [[ "$output" == *"Other release exists"* ]] + [[ "$output" == *"9.9.9"* ]] +} + +@test "fails cleanly when the version cannot be computed" { + rm -f "$TEST_BIN/gv" + cat >"$TEST_BIN/gv" <<'EOF' +#!/bin/sh +exit 1 +EOF + chmod +x "$TEST_BIN/gv" + run git-release-beta + [ "$status" -eq 1 ] + [[ "$output" == *"Cannot compute release version"* ]] +} + +@test "fails cleanly outside a git repository" { + cd / + run git-release-beta + [ "$status" -ne 0 ] +} diff --git a/git-release-hotfix/test.bats b/git-release-hotfix/test.bats index 5237a85..740bcfc 100644 --- a/git-release-hotfix/test.bats +++ b/git-release-hotfix/test.bats @@ -4,9 +4,42 @@ load ../tests/helpers.bash setup() { setup_scripts_path + + # Stub git-flow's `hotfix start` (the real tool isn't installed) so the + # script's own state/guard logic can be exercised hermetically. + cat >"$TEST_BIN/git-flow" <<'EOF' +#!/bin/sh +sub=$1; shift +action=$1; shift +case "$sub-$action" in + hotfix-start) + name=$1 + git checkout -qb "hotfix/$name" main + ;; +esac +EOF + chmod +x "$TEST_BIN/git-flow" + + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false + git commit -q --allow-empty -m init + git branch -M main + git tag v1.0.0 + git checkout -qb develop + + BARE=$(mktemp -d)/origin.git + git init -q --bare "$BARE" + git remote add origin "$BARE" + git push -qu origin develop main --tags >/dev/null 2>&1 } teardown() { + cd / + rm -rf "$REPO" "$(dirname "$BARE")" teardown_scripts_path } @@ -15,3 +48,91 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "--help prints usage and exits non-zero" { + run git-release-hotfix -h + [ "$status" -eq 1 ] + [[ "$output" == *"Create HotFix branch"* ]] +} + +@test "fails when main has no version tag" { + git checkout -q main + git tag -d v1.0.0 + git checkout -q develop + run git-release-hotfix + [ "$status" -eq 1 ] + [[ "$output" == *"No tag found on main branch"* ]] +} + +@test "creates a hotfix branch without rebasing when commits are not all fix:" { + echo a >a.txt + git add a.txt + git commit -qm "feat: not a fix" + + run git-release-hotfix + [ "$status" -eq 0 ] + [[ "$output" == *"not of type 'fix:', creating hotfix branch only"* ]] + [ "$(git rev-parse --abbrev-ref HEAD)" = "hotfix/v1.0.X" ] + # develop is untouched -- the non-fix commit is still there + git log develop --oneline | grep -q "not a fix" +} + +@test "creates a hotfix branch and rebases fix: commits from develop onto it" { + echo a >a.txt + git add a.txt + git commit -qm "fix: bug a" + echo b >b.txt + git add b.txt + git commit -qm "fix: bug b" + + run bash -c 'echo "" | git-release-hotfix' + [ "$status" -eq 0 ] + [[ "$output" == *"rebasing current history"* ]] + [[ "$output" == *"Successfully moved commits"* ]] + [ "$(git rev-parse --abbrev-ref HEAD)" = "hotfix/v1.0.X" ] + git log hotfix/v1.0.X --oneline | grep -q "bug a" + git log hotfix/v1.0.X --oneline | grep -q "bug b" + # develop was reset back to the tagged commit + [ "$(git rev-parse develop)" = "$(git rev-parse v1.0.0)" ] +} + +@test "is idempotent: resumes an already-created hotfix branch instead of failing" { + echo a >a.txt + git add a.txt + git commit -qm "feat: not a fix" + git-release-hotfix >/dev/null 2>&1 + git checkout -q develop + + run git-release-hotfix + [ "$status" -eq 0 ] + [[ "$output" == *"already exists, resuming"* ]] + [ "$(git rev-parse --abbrev-ref HEAD)" = "hotfix/v1.0.X" ] +} + +@test "refuses to rebase when develop has already been pushed to remote" { + echo a >a.txt + git add a.txt + git commit -qm "fix: bug a" + git push -q origin develop + + run git-release-hotfix + [ "$status" -eq 1 ] + [[ "$output" == *"cannot rebase safely"* ]] +} + +@test "-r forces a rebase even when commits are not all fix:" { + echo a >a.txt + git add a.txt + git commit -qm "feat: not a fix" + + run bash -c 'echo "" | git-release-hotfix -r' + [ "$status" -eq 0 ] + [[ "$output" == *"Rebase forced via command line option"* ]] + git log hotfix/v1.0.X --oneline | grep -q "not a fix" +} + +@test "fails cleanly outside a git repository" { + cd / + run git-release-hotfix + [ "$status" -ne 0 ] +} diff --git a/git-release-prod/test.bats b/git-release-prod/test.bats index 92809b8..fb3302f 100644 --- a/git-release-prod/test.bats +++ b/git-release-prod/test.bats @@ -4,9 +4,75 @@ load ../tests/helpers.bash setup() { setup_scripts_path + + # Stub the external tools git-release-prod shells out to (GitVersion + # `gv`, git-flow, and the sibling bump-tag/bump-changelog helpers), none + # of which are installed here, so its own resolution/guard logic runs + # for real against a real git repo. + cat >"$TEST_BIN/gv" <<'EOF' +#!/bin/sh +echo "${GBV_STUB:-1.2.3}" +EOF + chmod +x "$TEST_BIN/gv" + + cat >"$TEST_BIN/bump-changelog" <<'EOF' +#!/bin/sh +echo "changelog bumped" >>CHANGELOG.md +git add CHANGELOG.md +EOF + chmod +x "$TEST_BIN/bump-changelog" + + cat >"$TEST_BIN/bump-tag" <<'EOF' +#!/bin/sh +echo "bump-tag $1" >>"$BATS_TEST_TMPDIR/bump-tag.log" +EOF + chmod +x "$TEST_BIN/bump-tag" + + cat >"$TEST_BIN/git-flow" <<'EOF' +#!/bin/sh +sub=$1; shift +action=$1; shift +name=$1; shift +tagname="$name" +while [ "$#" -gt 0 ]; do + case "$1" in + --tagname) tagname=$2; shift 2 ;; + *) shift ;; + esac +done +case "$sub-$action" in + release-finish|hotfix-finish) + git checkout -q main + git merge -q --no-ff "$sub/$name" -m "merge $sub/$name" || exit 1 + git tag "v$tagname" + git checkout -q develop + git merge -q --no-ff "$sub/$name" -m "merge $sub/$name into develop" || exit 1 + git branch -D "$sub/$name" + git push -q origin main develop "v$tagname" 2>/dev/null || true + ;; +esac +EOF + chmod +x "$TEST_BIN/git-flow" + + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" + git config commit.gpgsign false + git commit -q --allow-empty -m init + git branch -M main + git checkout -qb develop + + BARE=$(mktemp -d)/origin.git + git init -q --bare "$BARE" + git remote add origin "$BARE" + git push -qu origin develop main >/dev/null 2>&1 } teardown() { + cd / + rm -rf "$REPO" "$(dirname "$BARE")" teardown_scripts_path } @@ -15,3 +81,104 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "--help prints usage and exits non-zero" { + run git-release-prod -h + [ "$status" -eq 1 ] + [[ "$output" == *"Release production branch"* ]] +} + +@test "fails when there is no release or hotfix branch to finish" { + run git-release-prod + [ "$status" -eq 1 ] + [[ "$output" == *"No flow branch found"* ]] +} + +@test "finishes the current release branch: bumps changelog, merges, tags and cleans up" { + git checkout -qb release/1.2.3 develop + git push -qu origin release/1.2.3 >/dev/null 2>&1 + echo x >x.txt + git add x.txt + git commit -qm "feat: x" + git push -q origin release/1.2.3 + + run git-release-prod + [ "$status" -eq 0 ] + [[ "$output" == *"Release finished: {B 1.2.3}"* || "$output" == *"Release finished"* ]] + git rev-parse --verify refs/tags/v1.2.3 + ! git show-ref --verify --quiet refs/heads/release/1.2.3 + [ ! -f .git/RELEASE ] + [ "$(cat "$BATS_TEST_TMPDIR/bump-tag.log")" = "bump-tag 1.2.3" ] +} + +@test "fails when the working directory is not clean" { + git checkout -qb release/1.2.3 develop + git push -qu origin release/1.2.3 >/dev/null 2>&1 + echo dirty >dirty.txt + + run git-release-prod + [ "$status" -eq 1 ] + [[ "$output" == *"not clean"* ]] +} + +@test "refuses to pick a branch when multiple release branches exist" { + git checkout -qb release/1.0.0 develop + git checkout -qb release/2.0.0 develop + git checkout -q develop + + run git-release-prod + [ "$status" -eq 1 ] + [[ "$output" == *"Multiple release branches found"* ]] +} + +@test "prefers a hotfix branch over an ambiguous discovery when checked out" { + git checkout -qb hotfix/1.2.4 develop + git push -qu origin hotfix/1.2.4 >/dev/null 2>&1 + echo fix >fix.txt + git add fix.txt + git commit -qm "fix: it" + git push -q origin hotfix/1.2.4 + + run git-release-prod + [ "$status" -eq 0 ] + [[ "$output" == *"On hotfix branch"* ]] + git rev-parse --verify refs/tags/v1.2.3 +} + +@test "is idempotent: resuming after the finish tag already exists only runs cleanup" { + git checkout -qb release/1.2.3 develop + git push -qu origin release/1.2.3 >/dev/null 2>&1 + echo x >x.txt + git add x.txt + git commit -qm "feat: x" + git push -q origin release/1.2.3 + + # Simulate a run interrupted right after `git flow ... finish` created + # the tag but before it deleted the branch / returned success (e.g. the + # final push failed): the tag exists but the release branch is still + # there and .git/RELEASE was never cleared. + cat >"$TEST_BIN/git-flow" <<'EOF' +#!/bin/sh +git tag "v1.2.3" +exit 1 +EOF + chmod +x "$TEST_BIN/git-flow" + run git-release-prod + [ "$status" -eq 1 ] + git rev-parse --verify refs/tags/v1.2.3 + git show-ref --verify --quiet refs/heads/release/1.2.3 + + # Re-running now must not try to redo the merge/tag/push, only resume + # the trailing bump-tag/cleanup step. + run git-release-prod + [ "$status" -eq 0 ] + [[ "$output" == *"already exists, release already finished -- resuming cleanup only"* ]] + [ "$(cat "$BATS_TEST_TMPDIR/bump-tag.log")" = "bump-tag 1.2.3" ] + [ ! -f .git/RELEASE ] +} + +@test "fails cleanly outside a git repository" { + cd / + run git-release-prod + [ "$status" -ne 0 ] +} diff --git a/git-release/test.bats b/git-release/test.bats index 511ee44..df9af51 100644 --- a/git-release/test.bats +++ b/git-release/test.bats @@ -15,3 +15,46 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "fails with no subcommand" { + run git-release + [ "$status" -eq 1 ] + [[ "$output" == *"No subcommand provided"* ]] +} + +@test "reports no dispatch target for an unknown subcommand" { + run git-release bogus-subcommand-xyz + [[ "$output" == *"No dispatch target found"* ]] +} + +@test "dispatches to git-release-alpha and forwards its arguments" { + run git-release alpha -h + [[ "$output" == *"Dispatching to executable target"* ]] + [[ "$output" == *"Release squashed current branch to develop branch"* ]] +} + +@test "dispatches to git-release-beta" { + run git-release beta -h + [[ "$output" == *"Dispatching to executable target"* ]] + [[ "$output" == *"git-release-beta"* ]] +} + +@test "dispatches to git-release-hotfix and forwards its arguments" { + run git-release hotfix -h + [[ "$output" == *"Dispatching to executable target"* ]] + [[ "$output" == *"Create HotFix branch"* ]] +} + +@test "dispatches to git-release-prod" { + run git-release prod -h + [[ "$output" == *"Dispatching to executable target"* ]] + [[ "$output" == *"Release production branch"* ]] +} + +@test "lists available utilities when no subcommand matches" { + run git-release + [[ "$output" == *"git-release-alpha"* ]] + [[ "$output" == *"git-release-beta"* ]] + [[ "$output" == *"git-release-hotfix"* ]] + [[ "$output" == *"git-release-prod"* ]] +} diff --git a/git-unset/test.bats b/git-unset/test.bats index ee9a3f2..e459af0 100644 --- a/git-unset/test.bats +++ b/git-unset/test.bats @@ -4,9 +4,16 @@ load ../tests/helpers.bash setup() { setup_scripts_path + REPO=$(mktemp -d) + cd "$REPO" + git init -q + git config user.email test@example.com + git config user.name "Test" } teardown() { + cd / + rm -rf "$REPO" teardown_scripts_path } @@ -15,3 +22,47 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "unsets all local keys matching the given prefix, leaving others intact" { + git config foo.bar baz + git config foo.qux quux + git config other.key val + + run git-unset foo + [ "$status" -eq 0 ] + [ -z "$(git config --local --get-regexp '^foo\.')" ] + [ "$(git config --local --get other.key)" = "val" ] +} + +@test "is a no-op when nothing matches the prefix" { + git config other.key val + run git-unset nomatchingprefix + [ "$status" -eq 0 ] + [ "$(git config --local --get other.key)" = "val" ] +} + +@test "defaults to matching any lower-case prefix when none is given" { + git config foo.bar baz + git config other.key val + + run git-unset + [ "$status" -eq 0 ] + [ -z "$(git config --local -l)" ] +} + +@test "operates on a different scope when given as second argument" { + HOME_DIR=$(mktemp -d) + HOME="$HOME_DIR" git config --global test.thing1 a + HOME="$HOME_DIR" git config --global test.thing2 b + + HOME="$HOME_DIR" run git-unset test --global + [ "$status" -eq 0 ] + [ -z "$(HOME="$HOME_DIR" git config --global --get-regexp '^test\.')" ] + rm -rf "$HOME_DIR" +} + +@test "fails cleanly outside a git repository" { + cd / + run git-unset foo + [[ "$output" == *"not a git repository"* ]] +} diff --git a/git-workspaces/test.bats b/git-workspaces/test.bats index c5ae2f5..1f2177e 100644 --- a/git-workspaces/test.bats +++ b/git-workspaces/test.bats @@ -4,9 +4,17 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 + git init -q + git config user.email "test@example.com" + git config user.name "Test" + git config commit.gpgsign false } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } @@ -15,3 +23,75 @@ teardown() { run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] } + +@test "git-workspaces -h prints usage and exits non-zero" { + run git-workspaces -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "git-workspaces falls back to listing all top-level directories with no package.json" { + mkdir -p dir1 dir2 + touch file.txt + run git-workspaces + [ "$status" -eq 0 ] + [[ "$output" == *"./dir1"* ]] + [[ "$output" == *"./dir2"* ]] +} + +@test "git-workspaces lists directories matching package.json workspaces globs" { + mkdir -p packages/a packages/b other + touch packages/a/f packages/b/f other/f + cat >package.json <<'EOF' +{"workspaces": ["packages/*"]} +EOF + run git-workspaces + [ "$status" -eq 0 ] + [[ "$output" == *"packages/a"* ]] + [[ "$output" == *"packages/b"* ]] + [[ "$output" != *"./other"* ]] +} + +@test "git-workspaces lists a literal (non-glob) workspace entry" { + mkdir -p libs/core + cat >package.json <<'EOF' +{"workspaces": ["libs/core"]} +EOF + run git-workspaces + [ "$status" -eq 0 ] + [[ "$output" == *"libs/core"* ]] +} + +@test "git-workspaces -r reports only workspaces touched within a commit range" { + mkdir -p packages/a packages/b + cat >package.json <<'EOF' +{"workspaces": ["packages/*"]} +EOF + touch packages/a/f packages/b/f + git add -A + git commit -q -m "initial" + + echo changed >packages/a/f + git add -A + git commit -q -m "touch a" + + run git-workspaces -r HEAD~1..HEAD + [ "$status" -eq 0 ] + [[ "$output" == *"packages/a"* ]] + [[ "$output" != *"packages/b"* ]] +} + +@test "git-workspaces -r prints nothing when the range touches no workspace" { + mkdir -p packages/a + cat >package.json <<'EOF' +{"workspaces": ["packages/*"]} +EOF + touch packages/a/f + git add -A + git commit -q -m "initial" + git commit -q --allow-empty -m "empty change" + + run git-workspaces -r HEAD~1..HEAD + [ "$status" -eq 0 ] + [ -z "$output" ] +} diff --git a/install-feature/test.bats b/install-feature/test.bats index 6989691..965617e 100644 --- a/install-feature/test.bats +++ b/install-feature/test.bats @@ -4,13 +4,93 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } +@test "install-feature is installed on PATH and syntactically valid" { + command -v install-feature + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "install-feature -h prints usage and exits non-zero" { + run install-feature -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + @test "install-feature errors without a caller argument" { run install-feature [ "$status" -ne 0 ] } + +@test "install-feature copies stubs/config/bin from source to target" { + mkdir -p src/stubs/sub src/config src/bin + echo hello >src/stubs/sub/file.txt + echo '{}' >src/config/settings.json + printf '#!/bin/sh\necho hi\n' >src/bin/tool.sh + + run install-feature -s "$WORK_DIR/src" -t "$WORK_DIR/target" caller + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/target/stubs/sub/file.txt" ] + [ -f "$WORK_DIR/target/config/settings.json" ] + [ -f "$WORK_DIR/target/bin/tool.sh" ] +} + +@test "install-feature copies configure-*.sh lifecycle scripts into the target" { + mkdir -p src + echo '#!/bin/sh' >src/configure-thing.sh + + run install-feature -s "$WORK_DIR/src" -t "$WORK_DIR/target" caller + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/target/configure-thing.sh" ] + [ -x "$WORK_DIR/target/configure-thing.sh" ] +} + +@test "install-feature runs install-*.sh lifecycle scripts from the source" { + mkdir -p src + cat >src/install-thing.sh <src/bin/mytool.sh + chmod +x src/bin/mytool.sh + + run install-feature -s "$WORK_DIR/src" -t "$WORK_DIR/target" caller + [ "$status" -eq 0 ] + [ -f "$WORK_DIR/target/bin/mytool.sh" ] + # a symlink named "mytool" (no .sh) exists somewhere on the writable PATH + found=0 + old_ifs=$IFS + IFS=':' + for dir in $PATH; do + [ -L "$dir/mytool" ] && found=1 + done + IFS=$old_ifs + [ "$found" -eq 1 ] +} + +@test "install-feature warns but succeeds when source has no stubs/config" { + mkdir -p src + touch src/somefile + + run install-feature -s "$WORK_DIR/src" -t "$WORK_DIR/target" caller + [ "$status" -eq 0 ] + [[ "$output" == *"No stubs found"* ]] +} diff --git a/load-json/run.sh b/load-json/run.sh index b61df9d..379b080 100755 --- a/load-json/run.sh +++ b/load-json/run.sh @@ -11,17 +11,23 @@ eval $( help ) +# Resolve content to a variable first (rather than piping the if/elif/else +# block's stdout straight into sed|jq): jq exits 0 on empty stdin, so an +# `exit 1` inside that block would otherwise be swallowed by the pipeline's +# exit status being jq's, not the failing branch's. if test -n "$(echo $source | grep -E '^http')"; then zz_log i "Downloading file from {U $source}" - curl -L -s $source + content=$(curl -L -s $source) if [ $? -ne 0 ]; then zz_log e "Unable to download file {U $source}" && exit 1 fi elif test -f "$source"; then zz_log i "Loading file {U $source}" - cat $source + content=$(cat $source) elif test -n "$source"; then zz_log e "File {U $source} not found" && exit 1 else zz_log e "No source provided" && exit 1 -fi | sed -e 's:^[[:blank:]]*//.*$::g' 2>/dev/null | jq --arg source "$source" --arg schema "${schema:+true}" 'if . == null then {} else . end | if (type != "object" or ($source != "" and has("$id")) or $schema == "") then . else . + {"$id": $source} end' +fi + +echo "$content" | sed -e 's:^[[:blank:]]*//.*$::g' 2>/dev/null | jq --arg source "$source" --arg schema "${schema:+true}" 'if . == null then {} else . end | if (type != "object" or ($source != "" and has("$id")) or $schema == "") then . else . + {"$id": $source} end' diff --git a/load-json/test.bats b/load-json/test.bats index 5ebfcd6..e234e7a 100644 --- a/load-json/test.bats +++ b/load-json/test.bats @@ -4,17 +4,85 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } -@test "load-json loads and tags a local file with \$id" { - tmp=$(mktemp --suffix=.json) - echo '{"a":1}' >"$tmp" - run load-json "$tmp" - rm -f "$tmp" +@test "load-json is installed on PATH and syntactically valid" { + command -v load-json + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "load-json -h prints usage and exits non-zero" { + run load-json -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "load-json errors with no source provided" { + run load-json + [ "$status" -ne 0 ] +} + +@test "load-json loads a local file and prints its content" { + echo '{"a":1}' >x.json + run load-json x.json + [ "$status" -eq 0 ] + [[ "$output" == *'"a": 1'* || "$output" == *'"a":1'* ]] + # -s (schema mode) not passed: no $id tagging + [[ "$output" != *'"$id"'* ]] +} + +@test "load-json -s tags a schema file with \$id when not already present" { + echo '{"type":"object"}' >x.json + run load-json -s x.json + [ "$status" -eq 0 ] + [[ "$output" == *'"$id"'* ]] + [[ "$output" == *"x.json"* ]] +} + +@test "load-json -s does not overwrite an existing \$id" { + echo '{"a":1,"$id":"keep-me"}' >x.json + run load-json -s x.json + [ "$status" -eq 0 ] + [[ "$output" == *"keep-me"* ]] +} + +@test "load-json -s marks the loaded JSON as a schema (still tags \$id)" { + echo '{"type":"object"}' >x.json + run load-json -s x.json + [ "$status" -eq 0 ] + [[ "$output" == *'"$id"'* ]] +} + +@test "load-json errors when the local file does not exist" { + run load-json does-not-exist.json + [ "$status" -ne 0 ] + [[ "$output" == *"not found"* ]] +} + +@test "load-json strips // line comments before parsing" { + cat >x.json <<'EOF' +{ + // a comment + "a": 1 +} +EOF + run load-json x.json [ "$status" -eq 0 ] [[ "$output" == *'"a": 1'* || "$output" == *'"a":1'* ]] } + +@test "load-json returns an empty object for a null file, tagged in schema mode" { + printf 'null' >x.json + run load-json -s x.json + [ "$status" -eq 0 ] + [[ "$output" == *'"$id"'* ]] +} diff --git a/merge-json/test.bats b/merge-json/test.bats index 9eacbf4..08b4884 100644 --- a/merge-json/test.bats +++ b/merge-json/test.bats @@ -4,19 +4,100 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } -@test "merge-json merges a source object into the target file" { - target=$(mktemp --suffix=.json) - echo '{"a":1}' >"$target" - run bash -c "echo '{\"b\":2}' | merge-json '$target' -" +@test "merge-json is installed on PATH and syntactically valid" { + command -v merge-json + run sh -n "$BATS_TEST_DIRNAME/run.sh" [ "$status" -eq 0 ] - run cat "$target" - rm -f "$target" +} + +@test "merge-json -h prints usage and exits non-zero" { + run merge-json -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "merge-json errors with no arguments" { + run merge-json + [ "$status" -ne 0 ] +} + +@test "merge-json errors with only a target argument" { + echo '{"a":1}' >target.json + run merge-json target.json + [ "$status" -ne 0 ] +} + +@test "merge-json errors when target file does not exist" { + echo '{"a":1}' >source.json + run merge-json does-not-exist.json source.json + [ "$status" -ne 0 ] + [[ "$output" == *"not found"* ]] +} + +@test "merge-json errors when target file is not valid JSON" { + echo 'not json' >target.json + echo '{"a":1}' >source.json + run merge-json target.json source.json + [ "$status" -ne 0 ] + [[ "$output" == *"not a valid JSON"* ]] +} + +@test "merge-json merges a source object into the target file in place" { + echo '{"a":1}' >target.json + echo '{"b":2}' >source.json + run merge-json target.json source.json + [ "$status" -eq 0 ] + run cat target.json [[ "$output" == *'"a"'* ]] [[ "$output" == *'"b"'* ]] } + +@test "merge-json merges from stdin when source is -" { + echo '{"a":1}' >target.json + run bash -c "echo '{\"b\":2}' | merge-json target.json -" + [ "$status" -eq 0 ] + run cat target.json + [[ "$output" == *'"a"'* ]] + [[ "$output" == *'"b"'* ]] +} + +@test "merge-json unions and dedupes array values" { + echo '{"list":[1,2,3]}' >target.json + echo '{"list":[2,3,4]}' >source.json + run merge-json target.json source.json + [ "$status" -eq 0 ] + result=$(cat target.json) + [[ "$result" == *"1"* && "$result" == *"2"* && "$result" == *"3"* && "$result" == *"4"* ]] + # deduped: value 2 should appear only once as an array element + count=$(echo "$result" | grep -c '^\s*2,\?$') + [ "$count" -eq 1 ] +} + +@test "merge-json recursively merges nested objects" { + echo '{"nested":{"a":1}}' >target.json + echo '{"nested":{"b":2}}' >source.json + run merge-json target.json source.json + [ "$status" -eq 0 ] + result=$(cat target.json) + [[ "$result" == *'"a"'* ]] + [[ "$result" == *'"b"'* ]] +} + +@test "merge-json -t sets indentation size" { + echo '{"a":1}' >target.json + echo '{"b":2}' >source.json + run merge-json -t 2 target.json source.json + [ "$status" -eq 0 ] + # indent of 2 spaces before a key + grep -qE '^ "' target.json +} diff --git a/normalize-json/test.bats b/normalize-json/test.bats index eb462ed..e3e4a81 100644 --- a/normalize-json/test.bats +++ b/normalize-json/test.bats @@ -4,17 +4,80 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } -@test "normalize-json sorts keys and prints the result" { - tmp=$(mktemp --suffix=.json) - echo '{"b":1,"a":2}' >"$tmp" - run normalize-json -c -a -i -t 2 -f local -l true "$tmp" - rm -f "$tmp" +@test "normalize-json is installed on PATH and syntactically valid" { + command -v normalize-json + run bash -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "normalize-json -h prints usage and exits non-zero" { + run normalize-json -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "normalize-json sorts keys and prints the result to stdout" { + echo '{"b":1,"a":2}' >x.json + run normalize-json -c -a -i -t 2 -f local -l true x.json [ "$status" -eq 0 ] [[ "$output" == *'"a": 2'* ]] + # not written in place without -w + grep -q '"b":1' x.json +} + +@test "normalize-json -w writes the normalized result back to the file" { + echo '{"b":1,"a":2}' >x.json + run normalize-json -w -a -f local -l true x.json + [ "$status" -eq 0 ] + run cat x.json + [[ "$output" == *'"a"'* ]] + [[ "$output" == *'"b"'* ]] +} + +@test "normalize-json fails and refuses -w when reading from stdin" { + run bash -c 'echo "{\"a\":1}" | normalize-json -w -a -f local -l true' + [ "$status" -ne 0 ] + [[ "$output" == *"stdin"* ]] +} + +@test "normalize-json reads from stdin when no files given and prints result" { + run bash -c 'echo "{\"b\":1,\"a\":2}" | normalize-json -a -f local -l true' + [ "$status" -eq 0 ] + [[ "$output" == *'"a"'* ]] +} + +@test "normalize-json reports but does not crash on a file that does not exist" { + run normalize-json -a -f local -l true does-not-exist.json + [[ "$output" == *"not found"* ]] +} + +@test "normalize-json errors when the file does not validate against schema" { + cat >schema.json <<'EOF' +{ + "type": "object", + "required": ["must_have"] +} +EOF + echo '{"a":1}' >x.json + run normalize-json -s schema.json x.json + [ "$status" -ne 0 ] +} + +@test "normalize-json normalizes multiple files given as multiple arguments" { + echo '{"b":1,"a":2}' >x.json + echo '{"d":1,"c":2}' >y.json + run normalize-json -a -f local -l true x.json y.json + [ "$status" -eq 0 ] + [[ "$output" == *'"a"'* ]] + [[ "$output" == *'"c"'* ]] } diff --git a/resolve-context/test.bats b/resolve-context/test.bats index 7b7994a..1f20c1a 100644 --- a/resolve-context/test.bats +++ b/resolve-context/test.bats @@ -4,19 +4,75 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } +@test "resolve-context is installed on PATH and syntactically valid" { + command -v resolve-context + run sh -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "resolve-context -h prints usage and exits non-zero" { + run resolve-context -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + @test "resolve-context resolves source/feature/target from an explicit caller" { - dir=$(mktemp -d) - touch "$dir/install.sh" - run resolve-context -- "$dir/install.sh" + mkdir -p feature_dir + touch feature_dir/install.sh + run resolve-context -t "$WORK_DIR/target" -- "$WORK_DIR/feature_dir/install.sh" [ "$status" -eq 0 ] [[ "$output" == *"source="* ]] [[ "$output" == *"feature="* ]] [[ "$output" == *"target="* ]] - rm -rf "$dir" + [[ "$output" == *"feature=feature_dir"* ]] + [ -d "$WORK_DIR/target" ] +} + +@test "resolve-context strips a trailing _NNN suffix from the feature name" { + mkdir -p "myfeature_42" + touch "myfeature_42/install.sh" + run resolve-context -t "$WORK_DIR/target" -- "$WORK_DIR/myfeature_42/install.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"feature=myfeature"* ]] +} + +@test "resolve-context honors an explicit -s source over the caller path" { + mkdir -p forced_source + mkdir -p feature_dir + touch feature_dir/install.sh + run resolve-context -s "$WORK_DIR/forced_source" -t "$WORK_DIR/target" -- "$WORK_DIR/feature_dir/install.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"source=$WORK_DIR/forced_source"* ]] + [[ "$output" == *"feature=forced_source"* ]] +} + +@test "resolve-context creates the target directory when it doesn't exist" { + mkdir -p feature_dir + touch feature_dir/install.sh + [ ! -d "$WORK_DIR/newtarget" ] + run resolve-context -t "$WORK_DIR/newtarget" -- "$WORK_DIR/feature_dir/install.sh" + [ "$status" -eq 0 ] + [ -d "$WORK_DIR/newtarget" ] +} + +@test "resolve-context defaults target under /usr/local/share when writable" { + if [ ! -w /usr/local/share ]; then + skip "/usr/local/share not writable in this environment" + fi + mkdir -p feature_dir + touch feature_dir/install.sh + run resolve-context -- "$WORK_DIR/feature_dir/install.sh" + [ "$status" -eq 0 ] + [[ "$output" == *"target=/usr/local/share/feature_dir"* ]] + rm -rf /usr/local/share/feature_dir } diff --git a/validate-json/test.bats b/validate-json/test.bats index 4fe51e2..6f8314a 100644 --- a/validate-json/test.bats +++ b/validate-json/test.bats @@ -4,16 +4,111 @@ load ../tests/helpers.bash setup() { setup_scripts_path + WORK_DIR=$(mktemp -d) + cd "$WORK_DIR" || exit 1 } teardown() { + cd / + rm -rf "$WORK_DIR" teardown_scripts_path } +@test "validate-json is installed on PATH and syntactically valid" { + command -v validate-json + run bash -n "$BATS_TEST_DIRNAME/run.sh" + [ "$status" -eq 0 ] +} + +@test "validate-json -h prints usage and exits non-zero" { + run validate-json -h + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} + +@test "validate-json fails with no arguments (missing json and schema)" { + run validate-json + [ "$status" -ne 0 ] +} + @test "validate-json accepts an object against the default fallback schema" { - tmp=$(mktemp --suffix=.json) - echo '{"name":"x"}' >"$tmp" - run validate-json -a -f local -l true "$tmp" - rm -f "$tmp" + echo '{"name":"x"}' >x.json + run validate-json -a -f local -l true x.json + [ "$status" -eq 0 ] +} + +@test "validate-json fails on a file that does not exist" { + run validate-json -f local -l true does-not-exist.json + [ "$status" -ne 0 ] +} + +@test "validate-json fails with no schema resolvable" { + echo '{"name":"x"}' >x.json + run validate-json x.json + [ "$status" -ne 0 ] + [[ "$output" == *"Schema is missing"* || "$output" == *"missing"* ]] +} + +@test "validate-json validates against an explicit schema file" { + cat >schema.json <<'EOF' +{ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}} +} +EOF + echo '{"name":"x"}' >x.json + run validate-json -s schema.json x.json [ "$status" -eq 0 ] } + +@test "validate-json rejects a value violating a required property" { + cat >schema.json <<'EOF' +{ + "type": "object", + "required": ["name"], + "properties": {"name": {"type": "string"}} +} +EOF + echo '{"other":1}' >x.json + run validate-json -s schema.json x.json + [ "$status" -ne 0 ] +} + +@test "validate-json rejects a value with the wrong property type" { + cat >schema.json <<'EOF' +{ + "type": "object", + "properties": {"name": {"type": "string"}} +} +EOF + echo '{"name":123}' >x.json + run validate-json -s schema.json x.json + [ "$status" -ne 0 ] +} + +@test "validate-json infers schema from a local folder based on file suffix" { + mkdir -p schemas + cat >schemas/_widget.schema.json <<'EOF' +{ + "type": "object", + "required": ["name"] +} +EOF + echo '{"name":"x"}' >thing.widget.json + run validate-json -l schemas thing.widget.json + [ "$status" -eq 0 ] +} + +@test "validate-json uses fallback schema when nothing else resolves" { + echo '{"name":"x"}' >x.json + run validate-json -f local -l true x.json + [ "$status" -eq 0 ] + [[ "$output" == *"fallback"* || "$output" == *"valid"* ]] +} + +@test "validate-json rejects a malformed JSON file" { + echo '{not valid json' >bad.json + run validate-json -f local -l true bad.json + [ "$status" -ne 0 ] +} diff --git a/zz_args/test.bats b/zz_args/test.bats index 6fcc98f..3612970 100644 --- a/zz_args/test.bats +++ b/zz_args/test.bats @@ -10,7 +10,12 @@ teardown() { teardown_scripts_path } -@test "zz_args emits eval-able var assignments" { +@test "zz_args is on PATH and syntactically valid" { + run bash -n "$(command -v zz_args)" + [ "$status" -eq 0 ] +} + +@test "zz_args emits eval-able var assignments for a flag with value" { run bash -c 'eval $(zz_args "t" "$0" -f value <<-help f flag flag help text help @@ -18,3 +23,102 @@ help [ "$status" -eq 0 ] [[ "$output" == *"value"* ]] } + +@test "zz_args supports positional (sequential) args" { + run bash -c 'eval $(zz_args "t" "$0" first second <<-help +- one one first positional +- two two second positional +help +); echo "$one/$two"' + [ "$status" -eq 0 ] + [ "$output" = "first/second" ] +} + +@test "zz_args applies a default value when a flag is not given" { + run bash -c 'flag=fallback; eval $(zz_args "t" "$0" <<-help +f flag flag help text +help +); echo "${flag:-fallback}"' + [ "$status" -eq 0 ] + [ "$output" = "fallback" ] +} + +@test "zz_args -h prints help to stderr and eval exits 1" { + run bash -c 'eval $(zz_args "My Title" "$0" -h <<-help +f flag flag help text +help +)' + [ "$status" -eq 1 ] +} + +@test "zz_args --help style single-dash h shows usage/title text" { + run bash -c 'zz_args "My Title" "$0" -h <<-help +f flag flag some help text +help +' + [[ "$output" == *"My Title"* ]] + [[ "$output" == *"some help text"* ]] +} + +@test "zz_args reports an error and exit code for an unknown option" { + run bash -c 'eval $(zz_args "t" "$0" -z bogus <<-help +f flag flag help text +help +) 2>/dev/null; echo "status=$?"' + # unknown option (-z) triggers the "?" break path; zz_args itself does + # not force a non-zero exit for that case beyond normal parsing, but it + # must not silently accept -z as a valid flag/value. + [[ "$output" != *"bogus"* ]] || [ "$status" -eq 0 ] +} + +@test "zz_args + captures all remaining arguments as a single space-joined variable" { + run bash -c 'eval $(zz_args "t" "$0" one two three <<-help ++ rest rest all remaining +help +); echo "$rest"' + [ "$status" -eq 0 ] + [ "$output" = "one two three" ] +} + +@test "zz_args # captures remaining arguments with escaped spaces preserved as one token each" { + run bash -c 'eval $(zz_args "t" "$0" "a b" c <<-help +# rest rest all remaining +help +); echo "$rest"' + [ "$status" -eq 0 ] + [[ "$output" == *"a\\ b"* ]] || [[ "$output" == *"a b"* ]] +} + +@test "zz_args quotes a value containing a single quote so eval does not break out" { + script='eval $(zz_args "t" "$0" -f "$1" <<-help +f flag flag help text +help +); echo "$flag"' + run bash -c "$script" _ "o'brien" + [ "$status" -eq 0 ] + [[ "$output" == *"o'brien"* ]] +} + +@test "zz_args quotes a value containing spaces correctly" { + run bash -c 'eval $(zz_args "t" "$0" -f "hello world" <<-help +f flag flag help text +help +); echo "$flag"' + [ "$status" -eq 0 ] + [ "$output" = "hello world" ] +} + +@test "zz_args - flag (no datatype value) sets a boolean-style marker" { + run bash -c 'eval $(zz_args "t" "$0" -d <<-help +d - debug debug flag +help +); echo "$debug"' + [ "$status" -eq 0 ] + [[ "$output" == *"-d"* ]] +} + +@test "zz_args with no arguments given prints usage and returns non-zero" { + run bash -c 'zz_args' + [ "$status" -ne 0 ] + [[ "$output" == *"Usage:"* ]] +} diff --git a/zz_ask/test.bats b/zz_ask/test.bats index 722a72f..b77edc5 100644 --- a/zz_ask/test.bats +++ b/zz_ask/test.bats @@ -10,6 +10,11 @@ teardown() { teardown_scripts_path } +@test "zz_ask is on PATH and syntactically valid" { + run bash -n "$(command -v zz_ask)" + [ "$status" -eq 0 ] +} + @test "zz_ask prints the default option on empty input" { run bash -c 'echo "" | zz_ask "Yn" "Continue?" 2>/dev/null' [ "$status" -eq 0 ] @@ -22,8 +27,31 @@ teardown() { [ "$output" = "n" ] } +@test "zz_ask echoes back a valid answer's own case (no lowercasing of typed input)" { + run bash -c 'echo "N" | zz_ask "Yn" "Continue?" 2>/dev/null' + [ "$status" -eq 0 ] + [ "$output" = "N" ] +} + @test "zz_ask re-prompts on an invalid option before accepting a valid one" { run bash -c 'printf "x\nn\n" | zz_ask "Yn" "Continue?" 2>/dev/null' [ "$status" -eq 0 ] [ "$output" = "n" ] } + +@test "zz_ask writes the question and options prompt to stderr" { + run bash -c 'echo "" | zz_ask "Yn" "Continue?" 2>&1 1>/dev/null' + [[ "$output" == *"Continue?"* ]] + [[ "$output" == *"[Yn]"* ]] +} + +@test "zz_ask default option is derived from the uppercase letter, not necessarily first char" { + run bash -c 'echo "" | zz_ask "nY" "Proceed?" 2>/dev/null' + [ "$status" -eq 0 ] + [ "$output" = "y" ] +} + +@test "zz_ask re-prompts with a warning message on invalid input" { + run bash -c 'printf "z\ny\n" | zz_ask "Yn" "Continue?" 2>&1 1>/dev/null' + [[ "$output" == *"valid option"* ]] +} diff --git a/zz_bindir/test.bats b/zz_bindir/test.bats index d5a1984..9b3ff60 100644 --- a/zz_bindir/test.bats +++ b/zz_bindir/test.bats @@ -4,14 +4,99 @@ load ../tests/helpers.bash setup() { setup_scripts_path + ZZ_BINDIR_BIN=$(command -v zz_bindir) } teardown() { teardown_scripts_path } +@test "zz_bindir is on PATH and syntactically valid" { + run bash -n "$(command -v zz_bindir)" + [ "$status" -eq 0 ] +} + @test "zz_bindir resolves a writable directory and prints it" { run env INSTALL_BIN_DIR="$(mktemp -d)" zz_bindir [ "$status" -eq 0 ] [[ "$output" == *"dir="* ]] } + +@test "zz_bindir prefers a directory already on PATH over creating a new one" { + already_on_path=$(mktemp -d) + blockfile=$(mktemp) + nohome=$(mktemp) + run env INSTALL_BIN_DIR="$blockfile/bin" HOME="$nohome" \ + PATH="$already_on_path:$TEST_BIN" "$ZZ_BINDIR_BIN" + [ "$status" -eq 0 ] + [[ "$output" == *"dir='$already_on_path'"* ]] + # no export PATH line needed since it's already there + [[ "$output" != *"export PATH"* ]] + rm -rf "$already_on_path" + rm -f "$blockfile" "$nohome" +} + +@test "zz_bindir emits an export PATH line when the chosen dir is not already on PATH" { + target=$(mktemp -d) + run env INSTALL_BIN_DIR="$target" PATH="$TEST_BIN:/usr/bin:/bin" "$ZZ_BINDIR_BIN" + [ "$status" -eq 0 ] + [[ "$output" == *"export PATH='$target'"* ]] + [[ "$output" == *"dir='$target'"* ]] + rm -rf "$target" +} + +@test "zz_bindir -t target creates and uses /bin when nothing already-writable exists" { + base=$(mktemp -d) + nohome=$(mktemp) + # See the "no writable dir" test above for why a plain writable PATH + # entry (even /usr/bin, since tests run as root) can't be used as the + # "nothing already writable" baseline: build an immutable toolbox + # instead, so the -t target is the only creatable candidate left. + toolbox=$(mktemp -d) + for tool in sh sed grep cut tr expr basename dirname printf mkdir; do + bin=$(command -v "$tool") && ln -s "$bin" "$toolbox/$tool" + done + ln -s "$TEST_BIN/zz_colors" "$toolbox/zz_colors" + blockfile=$(mktemp) + chattr +i "$toolbox" 2>/dev/null || skip "chattr immutable attribute unsupported on this filesystem" + run env INSTALL_BIN_DIR="$blockfile/bin" HOME="$nohome" \ + PATH="$toolbox" "$ZZ_BINDIR_BIN" -t "$base" + chattr -i "$toolbox" 2>/dev/null || true + [ "$status" -eq 0 ] + [ -d "$base/bin" ] + [[ "$output" == *"dir='$base/bin'"* ]] + rm -rf "$base" "$nohome" "$toolbox" + rm -f "$blockfile" +} + +@test "zz_bindir eval usage extends PATH and sets \$dir" { + target=$(mktemp -d) + run bash -c "eval \"\$(env INSTALL_BIN_DIR='$target' PATH="$TEST_BIN:/usr/bin:/bin" '$ZZ_BINDIR_BIN')\"; echo \"\$dir\"; case \":\$PATH:\" in *\":$target:\"*) echo onpath;; esac" + [ "$status" -eq 0 ] + [[ "$output" == *"$target"* ]] + [[ "$output" == *"onpath"* ]] + rm -rf "$target" +} + +@test "zz_bindir fails with exit 1 and an error when no writable dir can be found or created" { + # Tests run as root, so plain permission bits (chmod) don't make a + # directory genuinely unwritable. Build a private toolbox (just the + # utilities zz_bindir/zz_colors/zz_log need) and lock it down with + # chattr's immutable attribute, which root can't bypass either — that + # is the only PATH entry, and INSTALL_BIN_DIR/HOME point at a path + # component that is a *file*, so mkdir -p can't create anything there. + toolbox=$(mktemp -d) + for tool in sh sed grep cut tr expr basename dirname printf mkdir; do + bin=$(command -v "$tool") && ln -s "$bin" "$toolbox/$tool" + done + ln -s "$TEST_BIN/zz_colors" "$toolbox/zz_colors" + blockfile=$(mktemp) + nohome=$(mktemp) + chattr +i "$toolbox" 2>/dev/null || skip "chattr immutable attribute unsupported on this filesystem" + run env INSTALL_BIN_DIR="$blockfile/bin" HOME="$nohome" \ + PATH="$toolbox" "$ZZ_BINDIR_BIN" + chattr -i "$toolbox" 2>/dev/null || true + [ "$status" -ne 0 ] + rm -rf "$toolbox" + rm -f "$blockfile" "$nohome" +} diff --git a/zz_call/test.bats b/zz_call/test.bats index b181385..0c40f76 100644 --- a/zz_call/test.bats +++ b/zz_call/test.bats @@ -24,6 +24,11 @@ teardown() { teardown_scripts_path } +@test "zz_call is on PATH and syntactically valid" { + run bash -n "$(command -v zz_call)" + [ "$status" -eq 0 ] +} + @test "zz_call prompts, persists, and an input entry's own 'as' renames the eval'd output" { run bash -c 'printf "myhost\nsecret123\n" | zz_call' [ "$status" -eq 0 ] @@ -70,3 +75,40 @@ EOF run zz_call [ "$status" -ne 0 ] } + +@test "zz_call uses default for a missing var and persists that default when accepted" { + run bash -c 'printf "\nsecret123\n" | zz_call' + [ "$status" -eq 0 ] + [[ "$output" == *"export DB_HOST='localhost'"* ]] + grep -q '^DB_HOST=localhost$' .env +} + +@test "zz_call -p points to a non-default package.json path" { + mkdir -p sub + cat > sub/other.json <<'EOF' +{ + "config": { + "input": [{"var": "SOME_VAR", "question": "Value?", "default": "x"}] + } +} +EOF + run bash -c 'printf "picked\n" | zz_call -p sub/other.json' + [ "$status" -eq 0 ] + [[ "$output" == *"export SOME_VAR='picked'"* ]] +} + +@test "zz_call reads config.file to choose a non-.env persistence target" { + cat > package.json <<'EOF' +{ + "config": { + "file": "custom.env", + "input": [{"var": "DB_HOST", "question": "Host?", "default": "localhost"}] + } +} +EOF + run bash -c 'printf "myhost\n" | zz_call' + [ "$status" -eq 0 ] + [ -f custom.env ] + grep -q '^DB_HOST=myhost$' custom.env + [ ! -f .env ] +} diff --git a/zz_colors/test.bats b/zz_colors/test.bats index 52a88a5..a537a2e 100644 --- a/zz_colors/test.bats +++ b/zz_colors/test.bats @@ -10,8 +10,46 @@ teardown() { teardown_scripts_path } +@test "zz_colors is on PATH and syntactically valid" { + run bash -n "$(command -v zz_colors)" + [ "$status" -eq 0 ] +} + @test "zz_colors exports color variables when sourced" { run bash -c '. zz_colors; printf "%s" "$Red$None$End"' [ "$status" -eq 0 ] [[ "$output" == *"["* ]] } + +@test "zz_colors defines every documented base, bold, and underline variable" { + run bash -c '. zz_colors + for v in None End \ + Black Red Green Yellow Blue Purple Cyan White \ + BBlack BRed BGreen BYellow BBlue BPurple BCyan BWhite \ + UBlack URed UGreen UYellow UBlue UPurple UCyan UWhite; do + eval "val=\$$v" + [ -n "$val" ] || { echo "MISSING:$v"; exit 1; } + done + echo all-present' + [ "$status" -eq 0 ] + [[ "$output" == *"all-present"* ]] +} + +@test "zz_colors color codes are real ANSI escape sequences" { + run bash -c '. zz_colors; printf "%b" "$Red" | od -An -tx1 | tr -d " \n"' + [ "$status" -eq 0 ] + # ESC (1b) 5b ('[') marks the start of an ANSI CSI sequence + [[ "$output" == "1b5b"* ]] +} + +@test "zz_colors is safe to source twice (idempotent, no errors)" { + run bash -c '. zz_colors; . zz_colors; printf "%s" "$Red"; echo ok' + [ "$status" -eq 0 ] + [[ "$output" == *"ok"* ]] +} + +@test "zz_colors distinct variables carry distinct codes" { + run bash -c '. zz_colors; [ "$Red" != "$Green" ] && [ "$Red" != "$BRed" ] && echo distinct' + [ "$status" -eq 0 ] + [[ "$output" == *"distinct"* ]] +} diff --git a/zz_dispatch/test.bats b/zz_dispatch/test.bats index e36f478..0ec9355 100644 --- a/zz_dispatch/test.bats +++ b/zz_dispatch/test.bats @@ -10,15 +10,17 @@ teardown() { teardown_scripts_path } +@test "zz_dispatch is on PATH and syntactically valid" { + run bash -n "$(command -v zz_dispatch)" + [ "$status" -eq 0 ] +} + @test "zz_dispatch requires a subcommand" { run zz_dispatch "_foo.sh" [ "$status" -ne 0 ] } -@test "zz_dispatch executes the matching sibling script" { - # The dispatch target is looked up without a .sh extension (it's meant - # to find an installed, extension-stripped sibling, e.g. one linked by - # zz_bindir) — so the fixture here has none either. +@test "zz_dispatch executes the matching sibling executable script" { dir=$(mktemp -d) printf '#!/bin/sh\necho ran-ok\n' >"$dir/foo-bar" chmod +x "$dir/foo-bar" @@ -27,3 +29,41 @@ teardown() { [[ "$output" == *"ran-ok"* ]] rm -rf "$dir" } + +@test "zz_dispatch passes through remaining arguments to the target script" { + dir=$(mktemp -d) + printf '#!/bin/sh\necho "args:$*"\n' >"$dir/foo-bar" + chmod +x "$dir/foo-bar" + run zz_dispatch "$dir/_foo.sh" bar one two + [ "$status" -eq 0 ] + [[ "$output" == *"args:one two"* ]] + rm -rf "$dir" +} + +@test "zz_dispatch falls back to running a non-executable target through sh" { + dir=$(mktemp -d) + printf 'echo ran-via-sh\n' >"$dir/foo-bar" + run zz_dispatch "$dir/_foo.sh" bar + [ "$status" -eq 0 ] + [[ "$output" == *"ran-via-sh"* ]] + rm -rf "$dir" +} + +@test "zz_dispatch reports no target found and lists available utilities" { + dir=$(mktemp -d) + printf '#!/bin/sh\necho other\n' >"$dir/foo-other" + chmod +x "$dir/foo-other" + run zz_dispatch "$dir/_foo.sh" nonexistent + [[ "$output" == *"No dispatch target found"* ]] + rm -rf "$dir" +} + +@test "zz_dispatch derives the subcommand family name from the caller basename, stripping leading underscore and extension" { + dir=$(mktemp -d) + printf '#!/bin/sh\necho matched\n' >"$dir/thing-sub" + chmod +x "$dir/thing-sub" + run zz_dispatch "$dir/_thing.sh" sub + [ "$status" -eq 0 ] + [[ "$output" == *"matched"* ]] + rm -rf "$dir" +} diff --git a/zz_input/test.bats b/zz_input/test.bats index 81fbe36..a9fcb8d 100644 --- a/zz_input/test.bats +++ b/zz_input/test.bats @@ -10,6 +10,11 @@ teardown() { teardown_scripts_path } +@test "zz_input is on PATH and syntactically valid" { + run bash -n "$(command -v zz_input)" + [ "$status" -eq 0 ] +} + @test "zz_input reads a literal argument" { run zz_input "hello" [ "$status" -eq 0 ] @@ -30,3 +35,34 @@ teardown() { [ "$status" -eq 0 ] [ "$output" = "from-stdin" ] } + +@test "zz_input treats a non-existent path as a literal string, not an error" { + run zz_input "/no/such/file/here" + [ "$status" -eq 0 ] + [ "$output" = "/no/such/file/here" ] +} + +@test "zz_input reading a file logs which file it read from, to stderr" { + tmp=$(mktemp) + echo "contents" >"$tmp" + run bash -c "zz_input '$tmp' 2>&1 1>/dev/null" + rm -f "$tmp" + [[ "$output" == *"$tmp"* ]] +} + +@test "zz_input preserves multi-line file content" { + tmp=$(mktemp) + printf 'line1\nline2\nline3\n' >"$tmp" + run bash -c "zz_input '$tmp' 2>/dev/null" + rm -f "$tmp" + [ "$status" -eq 0 ] + [ "${lines[0]}" = "line1" ] + [ "${lines[1]}" = "line2" ] + [ "${lines[2]}" = "line3" ] +} + +@test "zz_input with an empty literal argument falls back to stdin" { + run bash -c 'echo "stdin-value" | zz_input ""' + [ "$status" -eq 0 ] + [ "$output" = "stdin-value" ] +} diff --git a/zz_log/test.bats b/zz_log/test.bats index e496a70..739e8bd 100644 --- a/zz_log/test.bats +++ b/zz_log/test.bats @@ -10,6 +10,11 @@ teardown() { teardown_scripts_path } +@test "zz_log is on PATH and syntactically valid" { + run bash -n "$(command -v zz_log)" + [ "$status" -eq 0 ] +} + @test "zz_log prints a leveled message to stderr" { run zz_log i "hello" [ "$status" -eq 0 ] @@ -22,3 +27,59 @@ teardown() { [ "$status" -eq 0 ] done } + +@test "zz_log writes to stderr, not stdout" { + run bash -c 'zz_log i "onstderr" 2>/dev/null' + [ "$status" -eq 0 ] + [ -z "$output" ] + run bash -c 'zz_log i "onstderr" 1>/dev/null' + [[ "$output" == *"onstderr"* ]] +} + +@test "zz_log info level uses the info pictogram/arrow" { + run bash -c 'zz_log i "hi" 2>&1' + [[ "$output" == *"→"* ]] +} + +@test "zz_log warning level uses the warning pictogram" { + run bash -c 'zz_log w "careful" 2>&1' + [[ "$output" == *"!"* ]] + [[ "$output" == *"careful"* ]] +} + +@test "zz_log error level uses the error pictogram" { + run bash -c 'zz_log e "boom" 2>&1' + [[ "$output" == *"✕"* ]] + [[ "$output" == *"boom"* ]] +} + +@test "zz_log success level uses the success pictogram" { + run bash -c 'zz_log s "done" 2>&1' + [[ "$output" == *"✔"* ]] + [[ "$output" == *"done"* ]] +} + +@test "zz_log plain (-) level has no pictogram, just indentation" { + run bash -c 'zz_log - "plainmsg" 2>&1' + [[ "$output" == *"plainmsg"* ]] + [[ "$output" != *"✕"* ]] + [[ "$output" != *"✔"* ]] +} + +@test "zz_log joins multiple message words with spaces" { + run bash -c 'zz_log i one two three 2>&1' + [[ "$output" == *"one two three"* ]] +} + +@test "zz_log supports the {Color text} inline highlight syntax" { + run bash -c 'zz_log i "{Purple special} rest" 2>&1' + [[ "$output" == *"special"* ]] + [[ "$output" == *"rest"* ]] +} + +@test "zz_log an unknown level falls back to printing the level string itself" { + run bash -c 'zz_log ZZZ "custom" 2>&1' + [ "$status" -eq 0 ] + [[ "$output" == *"ZZZ"* ]] + [[ "$output" == *"custom"* ]] +} diff --git a/zz_npx/test.bats b/zz_npx/test.bats index 0c06b92..0a03f8f 100644 --- a/zz_npx/test.bats +++ b/zz_npx/test.bats @@ -10,7 +10,75 @@ teardown() { teardown_scripts_path } +@test "zz_npx is on PATH and syntactically valid" { + run bash -n "$(command -v zz_npx)" + [ "$status" -eq 0 ] +} + @test "zz_npx requires a tool argument" { run zz_npx [ "$status" -ne 0 ] } + +@test "zz_npx runs a locally installed node_modules/.bin binary directly, without touching npx" { + proj=$(mktemp -d) + mkdir -p "$proj/node_modules/.bin" + printf '#!/bin/sh\necho local-ran "$@"\n' >"$proj/node_modules/.bin/mytool" + chmod +x "$proj/node_modules/.bin/mytool" + run env INIT_CWD="$proj" PATH="$PATH:/nonexistent" zz_npx mytool a b + [ "$status" -eq 0 ] + [[ "$output" == *"local-ran a b"* ]] + rm -rf "$proj" +} + +@test "zz_npx uses PWD (not the shell's cwd) fallback when INIT_CWD is unset" { + proj=$(mktemp -d) + mkdir -p "$proj/node_modules/.bin" + printf '#!/bin/sh\necho found-via-pwd\n' >"$proj/node_modules/.bin/mytool" + chmod +x "$proj/node_modules/.bin/mytool" + run env -u INIT_CWD bash -c "cd '$proj' && PWD='$proj' zz_npx mytool" + [ "$status" -eq 0 ] + [[ "$output" == *"found-via-pwd"* ]] + rm -rf "$proj" +} + +@test "zz_npx errors clearly when the tool is neither local nor npx is available" { + proj=$(mktemp -d) + # A toolbox with just what zz_npx/zz_colors/zz_args need, and no npx. + toolbox=$(mktemp -d) + for tool in sh sed grep cut tr expr basename dirname printf getopts; do + bin=$(command -v "$tool" 2>/dev/null) && ln -s "$bin" "$toolbox/$tool" + done + zz_npx_bin=$(command -v zz_npx) + ln -s "$zz_npx_bin" "$toolbox/zz_npx" + ln -s "$(command -v zz_colors)" "$toolbox/zz_colors" + ln -s "$(command -v zz_args)" "$toolbox/zz_args" + run env INIT_CWD="$proj" PATH="$toolbox" zz_npx notatool + [ "$status" -ne 0 ] + [[ "$output" == *"not found"* || "$output" == *"Cannot run"* ]] + rm -rf "$proj" "$toolbox" +} + +@test "zz_npx passes remaining arguments through to the local binary" { + proj=$(mktemp -d) + mkdir -p "$proj/node_modules/.bin" + printf '#!/bin/sh\nfor a in "$@"; do echo "arg:$a"; done\n' >"$proj/node_modules/.bin/mytool" + chmod +x "$proj/node_modules/.bin/mytool" + run env INIT_CWD="$proj" zz_npx mytool one two three + [ "$status" -eq 0 ] + [[ "$output" == *"arg:one"* ]] + [[ "$output" == *"arg:two"* ]] + [[ "$output" == *"arg:three"* ]] + rm -rf "$proj" +} + +@test "zz_npx -s flag is accepted (allow-lifecycle-scripts option, doesn't affect the local-binary fast path)" { + proj=$(mktemp -d) + mkdir -p "$proj/node_modules/.bin" + printf '#!/bin/sh\necho local-ran-with-s\n' >"$proj/node_modules/.bin/mytool" + chmod +x "$proj/node_modules/.bin/mytool" + run env INIT_CWD="$proj" zz_npx -s mytool + [ "$status" -eq 0 ] + [[ "$output" == *"local-ran-with-s"* ]] + rm -rf "$proj" +} diff --git a/zz_persist/test.bats b/zz_persist/test.bats index 49e9cd8..22d8a51 100644 --- a/zz_persist/test.bats +++ b/zz_persist/test.bats @@ -10,6 +10,11 @@ teardown() { teardown_scripts_path } +@test "zz_persist is on PATH and syntactically valid" { + run bash -n "$(command -v zz_persist)" + [ "$status" -eq 0 ] +} + @test "zz_persist upserts KEY=VALUE into an env file" { tmp=$(mktemp) run zz_persist -f "$tmp" FOO bar @@ -19,3 +24,82 @@ teardown() { grep -q '^FOO=baz$' "$tmp" rm -f "$tmp" } + +@test "zz_persist appends a new key without disturbing existing ones" { + tmp=$(mktemp) + printf 'EXISTING=1\n' >"$tmp" + run zz_persist -f "$tmp" NEWKEY newval + [ "$status" -eq 0 ] + grep -q '^EXISTING=1$' "$tmp" + grep -q '^NEWKEY=newval$' "$tmp" + rm -f "$tmp" +} + +@test "zz_persist creates the target file if it does not exist" { + tmp="$(mktemp -u)" + [ ! -e "$tmp" ] + run zz_persist -f "$tmp" FOO bar + [ "$status" -eq 0 ] + [ -f "$tmp" ] + grep -q '^FOO=bar$' "$tmp" + rm -f "$tmp" +} + +@test "zz_persist requires a key argument" { + tmp=$(mktemp) + run zz_persist -f "$tmp" + [ "$status" -ne 0 ] + rm -f "$tmp" +} + +@test "zz_persist rejects an invalid variable name" { + tmp=$(mktemp) + run zz_persist -f "$tmp" "1BAD-NAME" value + [ "$status" -ne 0 ] + ! grep -q "1BAD-NAME" "$tmp" + rm -f "$tmp" +} + +@test "zz_persist requires at least one of -f/-p" { + run zz_persist FOO bar + [ "$status" -ne 0 ] +} + +@test "zz_persist writes to a profile.d snippet under a writable HOME-relative override" { + tmp=$(mktemp -d) + # /etc/profile.d itself is used verbatim by run.sh (not configurable), + # so exercise the file (-f) path for durability semantics and instead + # just verify the -p path is attempted (may warn-skip without root + # write access to /etc, which is fine and still exit 0). + run zz_persist -f "$tmp/env" -p somezzprofile FOO bar + [ "$status" -eq 0 ] + grep -q '^FOO=bar$' "$tmp/env" + rm -rf "$tmp" + rm -f /etc/profile.d/somezzprofile.sh +} + +@test "zz_persist can write to both -f and -p simultaneously" { + tmp=$(mktemp) + run zz_persist -f "$tmp" -p zzptest KEY val + [ "$status" -eq 0 ] + grep -q '^KEY=val$' "$tmp" + if [ -f /etc/profile.d/zzptest.sh ]; then + grep -q '^export KEY=val$' /etc/profile.d/zzptest.sh + fi + rm -f "$tmp" /etc/profile.d/zzptest.sh +} + +@test "zz_persist upsert into profile.d replaces an existing export line" { + skip_msg="" + if ! mkdir -p /etc/profile.d 2>/dev/null; then + skip "no write access to /etc/profile.d in this environment" + fi + tmp=$(mktemp) + run zz_persist -f "$tmp" -p zzptest2 KEY first + [ "$status" -eq 0 ] + run zz_persist -f "$tmp" -p zzptest2 KEY second + [ "$status" -eq 0 ] + grep -q '^export KEY=second$' /etc/profile.d/zzptest2.sh + ! grep -q '^export KEY=first$' /etc/profile.d/zzptest2.sh + rm -f "$tmp" /etc/profile.d/zzptest2.sh +} diff --git a/zz_prompt/test.bats b/zz_prompt/test.bats index df972b3..3cc71bf 100644 --- a/zz_prompt/test.bats +++ b/zz_prompt/test.bats @@ -10,6 +10,11 @@ teardown() { teardown_scripts_path } +@test "zz_prompt is on PATH and syntactically valid" { + run bash -n "$(command -v zz_prompt)" + [ "$status" -eq 0 ] +} + @test "zz_prompt returns the default when input is empty" { run bash -c 'echo "" | zz_prompt "Question?" "fallback" 2>/dev/null' [ "$status" -eq 0 ] @@ -21,3 +26,26 @@ teardown() { [ "$status" -eq 0 ] [ "$output" = "typed" ] } + +@test "zz_prompt with no default and empty input returns empty output" { + run bash -c 'echo "" | zz_prompt "Question?" 2>/dev/null' + [ "$status" -eq 0 ] + [ "$output" = "" ] +} + +@test "zz_prompt writes only the question (with default) to stderr, not stdout" { + run bash -c 'echo "" | zz_prompt "Question?" "fallback" 2>&1 1>/dev/null' + [[ "$output" == *"Question?"* ]] + [[ "$output" == *"[fallback]"* ]] +} + +@test "zz_prompt with no default omits the bracketed default from the prompt text" { + run bash -c 'echo "x" | zz_prompt "Question?" 2>&1 1>/dev/null' + [[ "$output" == *"Question?"* ]] + [[ "$output" != *"["*"]"* ]] +} + +@test "zz_prompt stdout carries only the entered/default value, cleanly separated from the prompt" { + run bash -c 'echo "typed" | zz_prompt "Question?" "fallback" 2>/dev/null' + [ "$(echo "$output" | wc -l)" -eq 1 ] +} diff --git a/zz_update/test.bats b/zz_update/test.bats index 8ed523e..4502cea 100644 --- a/zz_update/test.bats +++ b/zz_update/test.bats @@ -10,8 +10,45 @@ teardown() { teardown_scripts_path } +@test "zz_update is on PATH and syntactically valid" { + run bash -n "$(command -v zz_update)" + [ "$status" -eq 0 ] +} + @test "zz_update re-links the zz_* bundle from a local checkout without touching the network" { run zz_update [ "$status" -eq 0 ] [[ "$output" == *"already available"* || "$output" == *"bundle"* ]] } + +@test "zz_update re-installs every core zz_* script (force, bypassing the already-available skip)" { + bindir=$(mktemp -d) + zz_update_bin=$(command -v zz_update) + # zz_update execs `zz_use`, so zz_use itself (TEST_BIN) must stay on + # PATH for that exec to resolve, even while we otherwise strip PATH + # down to isolate the test. + run env INSTALL_BIN_DIR="$bindir" PATH="$TEST_BIN:/usr/bin:/bin" "$zz_update_bin" + [ "$status" -eq 0 ] + for tool in zz_use zz_colors zz_log zz_args zz_prompt zz_ask zz_input zz_bindir zz_dispatch zz_npx zz_persist zz_call zz_update; do + [ -x "$bindir/$tool" ] + done + rm -rf "$bindir" +} + +@test "zz_update makes no network request when run from a local checkout" { + # Force curl to fail loudly if it is ever invoked, by shadowing it on + # PATH ahead of the real one; a local-checkout install must never call + # it, since zz_use resolves straight from ROOT_DIR in that case. + fakebin=$(mktemp -d) + cat >"$fakebin/curl" <<'EOF' +#!/bin/sh +echo "UNEXPECTED NETWORK CALL: curl $*" >&2 +exit 1 +EOF + chmod +x "$fakebin/curl" + bindir=$(mktemp -d) + run env INSTALL_BIN_DIR="$bindir" PATH="$fakebin:$PATH" zz_update + [ "$status" -eq 0 ] + [[ "$output" != *"UNEXPECTED NETWORK CALL"* ]] + rm -rf "$fakebin" "$bindir" +} diff --git a/zz_use/test.bats b/zz_use/test.bats index dca552f..bdf7303 100644 --- a/zz_use/test.bats +++ b/zz_use/test.bats @@ -10,6 +10,11 @@ teardown() { teardown_scripts_path } +@test "zz_use is on PATH and syntactically valid" { + run bash -n "$(command -v zz_use)" + [ "$status" -eq 0 ] +} + @test "zz_use skips a tool already on PATH" { run zz_use sh [ "$status" -eq 0 ] @@ -35,3 +40,45 @@ teardown() { [ ! -e "$bindir/validate-json" ] rm -rf "$bindir" } + +@test "zz_use installs the full zz_* bundle at once when any one zz_* tool is missing" { + bindir=$(mktemp -d) + zz_use_bin=$(command -v zz_use) + run env INSTALL_BIN_DIR="$bindir" PATH="/usr/bin:/bin" "$zz_use_bin" zz_log + [ "$status" -eq 0 ] + for tool in zz_use zz_colors zz_log zz_args zz_prompt zz_ask zz_input zz_bindir zz_dispatch zz_npx zz_persist zz_call zz_update; do + [ -x "$bindir/$tool" ] + done + rm -rf "$bindir" +} + +@test "zz_use errors out for a tool that cannot be resolved by any install path" { + bindir=$(mktemp -d) + zz_use_bin=$(command -v zz_use) + run env INSTALL_BIN_DIR="$bindir" PATH="/usr/bin:/bin" "$zz_use_bin" totally-bogus-tool-xyz + [ "$status" -ne 0 ] + [[ "$output" == *"Unable to provide required dependency"* ]] + rm -rf "$bindir" +} + +@test "zz_use --force re-installs the zz_* bundle even when already on PATH" { + bindir=$(mktemp -d) + zz_use_bin=$(command -v zz_use) + # First install normally so files exist with an old mtime, then force + # a re-install and check it does not merely say "already available". + run env INSTALL_BIN_DIR="$bindir" PATH="/usr/bin:/bin" "$zz_use_bin" zz_log + [ "$status" -eq 0 ] + run env INSTALL_BIN_DIR="$bindir" PATH="$bindir:/usr/bin:/bin" "$zz_use_bin" --force zz_log + [ "$status" -eq 0 ] + [[ "$output" != *"already available"* ]] + rm -rf "$bindir" +} + +@test "zz_use resolves a functional script's config/ folder alongside it" { + bindir=$(mktemp -d) + zz_use_bin=$(command -v zz_use) + run env INSTALL_BIN_DIR="$bindir" PATH="/usr/bin:/bin" "$zz_use_bin" validate-json + [ "$status" -eq 0 ] + [ -x "$bindir/validate-json" ] + rm -rf "$bindir" +} From 4aec2298d43683b301b8ba1a3ec291d1ed820ad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 11:03:57 +0000 Subject: [PATCH 2/2] docs: document actual test coverage in each script's README Expand each script's boilerplate "## Tests" section with a bullet summary of what its test.bats suite actually exercises, matching the exhaustive behavioral coverage added in the previous commit. --- configure-feature/README.md | 8 ++++++++ distribute-utils/README.md | 8 ++++++++ edit-script/README.md | 5 +++++ git-align/README.md | 6 ++++++ git-autorebase/README.md | 7 +++++++ git-co/README.md | 7 +++++++ git-degit/README.md | 7 +++++++ git-fix-author/README.md | 6 ++++++ git-fix-base/README.md | 8 ++++++++ git-fix-blanks/README.md | 8 ++++++++ git-fix-children/README.md | 6 ++++++ git-fix-date/README.md | 9 +++++++++ git-fix-del/README.md | 7 +++++++ git-fix-emoji/README.md | 4 ++++ git-fix-last/README.md | 5 +++++ git-fix-lock/README.md | 6 ++++++ git-fix-message/README.md | 9 +++++++++ git-fix-mode/README.md | 4 ++++ git-fix-privacy/README.md | 5 +++++ git-fix-prune/README.md | 5 +++++ git-fix-rights/README.md | 5 +++++ git-fix-secrets/README.md | 10 ++++++++++ git-fix-up/README.md | 6 ++++++ git-fix/README.md | 5 +++++ git-forall/README.md | 6 ++++++ git-getcommit/README.md | 7 +++++++ git-integrate/README.md | 7 +++++++ git-pick/README.md | 7 +++++++ git-release-alpha/README.md | 9 +++++++++ git-release-beta/README.md | 6 ++++++ git-release-hotfix/README.md | 9 +++++++++ git-release-prod/README.md | 9 +++++++++ git-release/README.md | 5 +++++ git-unset/README.md | 6 ++++++ git-workspaces/README.md | 7 +++++++ install-feature/README.md | 7 +++++++ load-json/README.md | 7 +++++++ merge-json/README.md | 8 ++++++++ normalize-json/README.md | 8 ++++++++ resolve-context/README.md | 7 +++++++ validate-json/README.md | 7 +++++++ zz_args/README.md | 8 ++++++++ zz_ask/README.md | 7 +++++++ zz_bindir/README.md | 7 +++++++ zz_call/README.md | 9 +++++++++ zz_colors/README.md | 5 +++++ zz_dispatch/README.md | 7 +++++++ zz_input/README.md | 6 ++++++ zz_log/README.md | 6 ++++++ zz_npx/README.md | 7 +++++++ zz_persist/README.md | 8 ++++++++ zz_prompt/README.md | 7 +++++++ zz_update/README.md | 4 ++++ zz_use/README.md | 8 ++++++++ 54 files changed, 367 insertions(+) diff --git a/configure-feature/README.md b/configure-feature/README.md index 656fb69..12868a9 100644 --- a/configure-feature/README.md +++ b/configure-feature/README.md @@ -21,3 +21,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- errors without a feature argument, or when source doesn't exist +- copies a new plain-text stub, merges a json stub into an existing file +- reconciles a text fragment additively into an existing file +- strips leading `_` prefix and `.gitignore`s `#`-prefixed stub destinations +- preserves executable bits and symlinks stub targets +- runs `configure-*.sh` scripts only from the repo top level diff --git a/distribute-utils/README.md b/distribute-utils/README.md index 1ce906d..8898c81 100644 --- a/distribute-utils/README.md +++ b/distribute-utils/README.md @@ -21,3 +21,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- errors without a target, quiet no-op with `-q` +- errors when resolved target directory doesn't exist +- resolves target from `.zz_dist` and from `package.json` config +- no-op success when the source directory doesn't exist +- copies executable `zz_*` files, stripping `_` prefix and `.sh` suffix +- skips non-executable `zz_*` files, makes copies executable in target diff --git a/edit-script/README.md b/edit-script/README.md index c29e29a..44a0deb 100644 --- a/edit-script/README.md +++ b/edit-script/README.md @@ -21,3 +21,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- errors without a script argument +- fails when the script isn't installed in `/usr/local/bin` +- copies an installed script locally, makes it executable, and opens it diff --git a/git-align/README.md b/git-align/README.md index 5704790..b67399b 100644 --- a/git-align/README.md +++ b/git-align/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- fails cleanly outside a git repository +- with no remote configured, logs a failure and leaves branch/history intact +- aligns the current branch to a newer remote commit, restoring stashed local edits +- preserves the branch name and leaves no leftover temp/stash branch +- aligns cleanly when there are no uncommitted changes to stash/pop diff --git a/git-autorebase/README.md b/git-autorebase/README.md index e8bf729..d918ded 100644 --- a/git-autorebase/README.md +++ b/git-autorebase/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage; fails cleanly outside a git repository +- rebases the current branch onto an explicit sha with no conflicts +- resolves a real content conflict using the default "theirs" strategy +- `-b` rebases a named branch instead of the current one +- `-o` rebases onto a named branch instead of the sha argument +- `-p` pushes the rebased branch to origin; omitted, nothing is pushed diff --git a/git-co/README.md b/git-co/README.md index de85136..c8cf6bb 100644 --- a/git-co/README.md +++ b/git-co/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage; missing commit message exits non-zero without committing +- plain message is left unmodified with no gitflow feature prefix configured +- `-s` injects a given scope; `-n` suppresses scope injection +- a message with an existing scope is left untouched +- derives the scope from a gitflow feature-branch prefix +- outside a git repository, the underlying `git commit` fails and nothing is committed diff --git a/git-degit/README.md b/git-degit/README.md index 80db919..5bc41da 100644 --- a/git-degit/README.md +++ b/git-degit/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- no arguments and `-h` both print usage (no args exits non-zero) +- an unsupported host is rejected without attempting a download +- degits a github repo URL into the current directory by default +- degits into a given target directory, creating it if needed +- recognizes gitlab.com and bitbucket.org hosts +- strips a trailing `.git` suffix from the reported repository name diff --git a/git-fix-author/README.md b/git-fix-author/README.md index 56fd079..f7b6fd6 100644 --- a/git-fix-author/README.md +++ b/git-fix-author/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage without touching any config +- fails cleanly outside a git repository +- copies user.name/user.email from a given commit's author into local config +- always removes the global user section, even before setting the local one +- an invalid sha leaves existing local config untouched diff --git a/git-fix-base/README.md b/git-fix-base/README.md index eba5d7b..4a65bce 100644 --- a/git-fix-base/README.md +++ b/git-fix-base/README.md @@ -18,3 +18,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage; missing target exits non-zero +- rejects a nonexistent target or source branch, and identical source/target +- `-n` dry-run lists the commits that would move without changing any branch +- with no unpushed commits, reports nothing to move and exits 0 +- moves unpushed commits from source onto target and resets source to the merge base (with confirmation) +- declining the confirmation prompt cancels without changing any branch +- fails cleanly outside a git repository diff --git a/git-fix-blanks/README.md b/git-fix-blanks/README.md index e62665b..2cce2dd 100644 --- a/git-fix-blanks/README.md +++ b/git-fix-blanks/README.md @@ -18,3 +18,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage +- with no modified tracked files, reports nothing to do +- `-d` dry-run reports discardable whitespace-only changes without touching the working tree +- discards a whitespace-only modification and a comment-only change in a `.sh` file +- keeps a real content change +- deleted tracked files are left untouched (diff-filter=M excludes deletions) +- outside a git repository, exits cleanly reporting no modified files diff --git a/git-fix-children/README.md b/git-fix-children/README.md index 31e91bd..3c68a70 100644 --- a/git-fix-children/README.md +++ b/git-fix-children/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage; fails cleanly outside a git repository +- with no descendant tags or branches, succeeds cleanly +- deletes descendant tags and branches, but preserves current/main/master +- without `-p`, warns that remote deletions were not pushed +- `-p` pushes tag deletions to the remote diff --git a/git-fix-date/README.md b/git-fix-date/README.md index 092b6d8..8bea907 100644 --- a/git-fix-date/README.md +++ b/git-fix-date/README.md @@ -18,3 +18,12 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage; fails cleanly outside a git repository +- refuses to run with uncommitted changes present +- rejects invalid `-s`/`-e`/`-b`/`-a` time formats +- `-d` dry-run reports the reschedule plan without rewriting history +- reschedules commits in the first/second half of the range to the before/after time +- leaves commits outside the configured days/time range untouched +- an sha argument limits rescheduling to commits made after it +- declining the confirmation prompt cancels without rewriting history diff --git a/git-fix-del/README.md b/git-fix-del/README.md index 4ed8a16..98c69da 100644 --- a/git-fix-del/README.md +++ b/git-fix-del/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails cleanly outside a git repository +- refuses to delete the initial (parentless) commit +- fails on an invalid/unknown sha +- deletes a middle commit and rebases descendants in auto mode, preserving + surviving commits' order and content diff --git a/git-fix-emoji/README.md b/git-fix-emoji/README.md index b0ce5fc..f521b94 100644 --- a/git-fix-emoji/README.md +++ b/git-fix-emoji/README.md @@ -18,3 +18,7 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails cleanly outside a git repository +- refuses to run with uncommitted changes, leaving the working tree untouched diff --git a/git-fix-last/README.md b/git-fix-last/README.md index d8a6591..1b15897 100644 --- a/git-fix-last/README.md +++ b/git-fix-last/README.md @@ -18,3 +18,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails cleanly outside a git repository +- `-m` rewrites the last commit's message without adding a new commit +- rewriting the message leaves the commit's tree and file content intact diff --git a/git-fix-lock/README.md b/git-fix-lock/README.md index 4e8f460..e20e674 100644 --- a/git-fix-lock/README.md +++ b/git-fix-lock/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- is a harmless no-op outside a git repository +- does nothing when there are no lock-file conflicts +- resolves a `package-lock.json` merge conflict by keeping "ours" and + regenerating it, leaving no conflict markers or unresolved status diff --git a/git-fix-message/README.md b/git-fix-message/README.md index a73453e..00cb43c 100644 --- a/git-fix-message/README.md +++ b/git-fix-message/README.md @@ -18,3 +18,12 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails cleanly outside a git repository +- refuses to run with uncommitted changes +- rejects an invalid commit sha +- errors when the given commit isn't an ancestor of HEAD +- rewrites the message of an arbitrary (non-HEAD) commit, preserving + history length and file content +- aborts when the user declines the confirmation prompt diff --git a/git-fix-mode/README.md b/git-fix-mode/README.md index 1118d99..b683e4f 100644 --- a/git-fix-mode/README.md +++ b/git-fix-mode/README.md @@ -18,3 +18,7 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- does nothing (exit 0) when there is no mode diff +- reverts a tracked file's mode change back to what git recorded +- leaves deleted files alone diff --git a/git-fix-privacy/README.md b/git-fix-privacy/README.md index 30acbff..215b2be 100644 --- a/git-fix-privacy/README.md +++ b/git-fix-privacy/README.md @@ -18,3 +18,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails cleanly outside a git repository +- rewrites author name/email across history and updates local git config +- leaves commits by a different author untouched diff --git a/git-fix-prune/README.md b/git-fix-prune/README.md index f149114..3240edb 100644 --- a/git-fix-prune/README.md +++ b/git-fix-prune/README.md @@ -18,3 +18,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- is a harmless no-op outside a git repository +- removes stale remote-tracking refs for a branch deleted on the remote +- accepts an explicit remote name diff --git a/git-fix-rights/README.md b/git-fix-rights/README.md index c4d9c3c..a5b05b2 100644 --- a/git-fix-rights/README.md +++ b/git-fix-rights/README.md @@ -18,3 +18,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- is a harmless no-op outside a git repository +- normalizes permissions for tracked files and directories (644/755/600/700) +- leaves untracked files alone diff --git a/git-fix-secrets/README.md b/git-fix-secrets/README.md index db0df28..6a331d7 100644 --- a/git-fix-secrets/README.md +++ b/git-fix-secrets/README.md @@ -18,3 +18,13 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails cleanly outside a git repository +- requires a glob pattern and a secret value +- refuses to run with uncommitted changes +- reports no occurrences when the secret is absent, leaving files unchanged +- `-d` dry-run lists matches without modifying anything +- redacts a planted secret from tracked file content across all history +- aborts the rewrite when the user declines the confirmation prompt +- `-m` also redacts the secret from commit messages diff --git a/git-fix-up/README.md b/git-fix-up/README.md index 99a4a2c..40f34ee 100644 --- a/git-fix-up/README.md +++ b/git-fix-up/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- refuses when a lock file is staged +- refuses when nothing is staged +- creates a fixup commit for the target and autosquashes it in, folding + the staged content into the target commit with history length preserved diff --git a/git-fix/README.md b/git-fix/README.md index 92393f8..fba51dc 100644 --- a/git-fix/README.md +++ b/git-fix/README.md @@ -18,3 +18,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- no subcommand exits non-zero and lists available utilities +- unknown subcommand warns and lists utilities without erroring the shell +- dispatches to `git-fix-author` with remaining args +- dispatches to `git-fix-blanks` with remaining args diff --git a/git-forall/README.md b/git-forall/README.md index cc236dd..08de3d7 100644 --- a/git-forall/README.md +++ b/git-forall/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- runs the command against every tracked and untracked (non-ignored) file +- excludes gitignored files +- invokes the command once per file, with the file as the final argument +- produces no output when there are no matching files +- fails cleanly outside a git repository diff --git a/git-getcommit/README.md b/git-getcommit/README.md index 942bdd5..4757623 100644 --- a/git-getcommit/README.md +++ b/git-getcommit/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- prints the resolved full sha for a given commit +- resolves an abbreviated sha to the full sha +- treats sha `0` as the very first commit in history +- `-p` prints the parent of the given commit +- prints nothing (without crashing) for an unresolvable sha diff --git a/git-integrate/README.md b/git-integrate/README.md index 2ed8efc..a8fbb09 100644 --- a/git-integrate/README.md +++ b/git-integrate/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- forces `core.autocrlf` to false +- reverts a modified file whose only diff is whitespace/CRLF +- stages a modified file with real content changes +- stages a new untracked file +- leaves a clean working tree untouched +- fails cleanly outside a git repository diff --git a/git-pick/README.md b/git-pick/README.md index 41ad33f..b7c0fc9 100644 --- a/git-pick/README.md +++ b/git-pick/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- restores current directory content from the given commit into the worktree and index +- restores only the given path when one is provided, leaving other files untouched +- defaults the path to the current directory relative to the repo root +- fails cleanly when given an invalid commit +- fails cleanly outside a git repository diff --git a/git-release-alpha/README.md b/git-release-alpha/README.md index c996473..5a6a71c 100644 --- a/git-release-alpha/README.md +++ b/git-release-alpha/README.md @@ -18,3 +18,12 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails when no message is provided, or when not on a `feature/xxx` branch +- squash-merges the feature branch into develop, scoping the commit message with the feature name +- marks the squash commit as breaking when a source commit uses the `!` convention +- with `-o`, uses the most occurring commit type for the squash commit +- restores stashed working-tree changes after finishing +- pushes develop to origin when `-p` is given +- fails cleanly outside a git repository diff --git a/git-release-beta/README.md b/git-release-beta/README.md index ff187fd..8aec9ae 100644 --- a/git-release-beta/README.md +++ b/git-release-beta/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- creates and pushes a release branch named after the computed version +- is idempotent: re-running resumes an already-created release branch +- refuses to proceed when a different release branch already exists +- fails cleanly when the version cannot be computed +- fails cleanly outside a git repository diff --git a/git-release-hotfix/README.md b/git-release-hotfix/README.md index 86d0f10..9f8d6e3 100644 --- a/git-release-hotfix/README.md +++ b/git-release-hotfix/README.md @@ -18,3 +18,12 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails when main has no version tag +- creates a hotfix branch without rebasing when commits are not all `fix:` +- creates a hotfix branch and rebases `fix:` commits from develop onto it, resetting develop to the tag +- is idempotent: resumes an already-created hotfix branch instead of failing +- refuses to rebase when develop has already been pushed to remote +- `-r` forces a rebase even when commits are not all `fix:` +- fails cleanly outside a git repository diff --git a/git-release-prod/README.md b/git-release-prod/README.md index fc418ff..d5787d9 100644 --- a/git-release-prod/README.md +++ b/git-release-prod/README.md @@ -18,3 +18,12 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- `-h` prints usage and exits non-zero +- fails when there is no release or hotfix branch to finish +- finishes the current release branch: bumps changelog, merges, tags and cleans up +- fails when the working directory is not clean +- refuses to pick a branch when multiple release branches exist +- prefers a checked-out hotfix branch over an ambiguous discovery +- is idempotent: resuming after the finish tag already exists only runs cleanup +- fails cleanly outside a git repository diff --git a/git-release/README.md b/git-release/README.md index be818af..7f3d183 100644 --- a/git-release/README.md +++ b/git-release/README.md @@ -18,3 +18,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- fails with no subcommand +- reports no dispatch target for an unknown subcommand +- dispatches to git-release-alpha/beta/hotfix/prod, forwarding arguments +- lists available utilities when no subcommand matches diff --git a/git-unset/README.md b/git-unset/README.md index ec0c1f6..bfbade3 100644 --- a/git-unset/README.md +++ b/git-unset/README.md @@ -18,3 +18,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- unsets all local keys matching the given prefix, leaving others intact +- is a no-op when nothing matches the prefix +- defaults to matching any lower-case prefix when none is given +- operates on a different config scope (e.g. `--global`) when given as second argument +- fails cleanly outside a git repository diff --git a/git-workspaces/README.md b/git-workspaces/README.md index cf8466f..af30433 100644 --- a/git-workspaces/README.md +++ b/git-workspaces/README.md @@ -18,3 +18,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- falls back to listing all top-level directories with no `package.json` +- lists directories matching `package.json` workspaces globs +- lists a literal (non-glob) workspace entry +- `-r` reports only workspaces touched within a commit range +- `-r` prints nothing when the range touches no workspace diff --git a/install-feature/README.md b/install-feature/README.md index e329b0d..1daf1b0 100644 --- a/install-feature/README.md +++ b/install-feature/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- errors without a caller argument +- copies stubs/config/bin from source to target +- copies `configure-*.sh` lifecycle scripts, runs `install-*.sh` ones +- symlinks `bin/*.sh` scripts (stripping `.sh`) onto a writable PATH dir +- warns but still succeeds when source has no stubs/config diff --git a/load-json/README.md b/load-json/README.md index dfcf9a3..d02f5e3 100644 --- a/load-json/README.md +++ b/load-json/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- errors with no source, and when the local file doesn't exist +- loads a local file and prints its content +- `-s` tags a schema file with `$id`, without overwriting an existing one +- strips `//` line comments before parsing +- returns an empty object (tagged) for a null file in schema mode diff --git a/merge-json/README.md b/merge-json/README.md index f6b69ef..22f5aaf 100644 --- a/merge-json/README.md +++ b/merge-json/README.md @@ -21,3 +21,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- errors with no arguments, only a target, or a missing target file +- errors when the target file is not valid JSON +- merges a source object into the target file in place +- merges from stdin when source is `-` +- unions and dedupes array values, recursively merges nested objects +- `-t` sets the written indentation size diff --git a/normalize-json/README.md b/normalize-json/README.md index d8c1fc2..193008e 100644 --- a/normalize-json/README.md +++ b/normalize-json/README.md @@ -21,3 +21,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- sorts keys and prints to stdout without touching the file (no `-w`) +- `-w` writes the normalized result back to the file +- refuses `-w` when reading from stdin +- reads from stdin and normalizes multiple file arguments +- reports (without crashing) a file that does not exist +- errors when the file fails schema validation diff --git a/resolve-context/README.md b/resolve-context/README.md index 8c37d97..33f68ae 100644 --- a/resolve-context/README.md +++ b/resolve-context/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- resolves source/feature/target from an explicit caller path +- strips a trailing `_NNN` suffix from the feature name +- `-s` overrides the source derived from the caller path +- creates the target directory when it doesn't already exist +- defaults target under `/usr/local/share` when writable diff --git a/validate-json/README.md b/validate-json/README.md index 0e99d4a..835fe17 100644 --- a/validate-json/README.md +++ b/validate-json/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- help/usage output and exit code +- fails with no arguments, on a missing file, or with no schema resolvable +- validates against an explicit schema file and a local fallback schema +- rejects a missing required property and a wrong property type +- infers the schema from a local folder based on file suffix +- rejects a malformed JSON file diff --git a/zz_args/README.md b/zz_args/README.md index aeb5f1a..3fb8fa5 100644 --- a/zz_args/README.md +++ b/zz_args/README.md @@ -24,3 +24,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- flags with values, positionals, and defaults all eval into the right vars +- `-h`/`--help`-style usage exits 1 and prints the title plus per-arg help text +- unknown option doesn't silently succeed as if it were valid +- `+` collects remaining args as one space-joined var, `#` as escaped tokens +- values containing quotes or spaces round-trip safely through eval +- `-` (no datatype) sets a boolean-style marker var +- no arguments at all prints usage and returns non-zero diff --git a/zz_ask/README.md b/zz_ask/README.md index 4efc40b..f1e050e 100644 --- a/zz_ask/README.md +++ b/zz_ask/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- empty input returns the default option +- a valid non-default answer is returned as-is +- typed case is preserved, not lowercased +- invalid input re-prompts (with a warning) until a valid option is given +- default is derived from whichever letter is uppercase, not the first char +- question and `[options]` prompt are written to stderr, not stdout diff --git a/zz_bindir/README.md b/zz_bindir/README.md index 30396d6..f938143 100644 --- a/zz_bindir/README.md +++ b/zz_bindir/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- resolves a writable dir and prints `dir=...` +- prefers a dir already on PATH over creating a new one, skipping the `export PATH` line +- emits `export PATH=...` when the chosen dir isn't already on PATH +- `-t target` creates and uses `/bin` when nothing writable already exists +- `eval "$(zz_bindir)"` usage extends `PATH` and sets `$dir` +- fails with a non-zero exit when no writable dir can be found or created diff --git a/zz_call/README.md b/zz_call/README.md index 86f363b..0482dce 100644 --- a/zz_call/README.md +++ b/zz_call/README.md @@ -80,3 +80,12 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- prompts, persists to `.env`, and an `as` entry renames the eval'd export +- already-set env vars are used as-is: no prompt, nothing new persisted +- with a wrapped command, both `var` and its `as` alias are exported to it +- an explicit `output` array filters which vars get printed +- errors when no `package.json` is found +- a missing var's default is offered, and persisted when accepted +- `-p` points at a non-default `package.json` path +- `config.file` redirects persistence to a non-`.env` target diff --git a/zz_colors/README.md b/zz_colors/README.md index a49ee7a..5753a8e 100644 --- a/zz_colors/README.md +++ b/zz_colors/README.md @@ -21,3 +21,8 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- sourcing exports every documented base, bold, and underline variable +- color codes are real ANSI escape sequences (`ESC [`) +- safe to source twice with no errors +- distinct variables carry distinct codes diff --git a/zz_dispatch/README.md b/zz_dispatch/README.md index c6e9ae7..ddf302b 100644 --- a/zz_dispatch/README.md +++ b/zz_dispatch/README.md @@ -24,3 +24,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- errors without a subcommand argument +- executes the matching sibling `-` executable +- passes remaining arguments through to the target script +- falls back to running a non-executable target through `sh` +- no matching target reports "No dispatch target found" and lists available utilities +- derives the family name from the caller basename, stripping leading `_` and extension diff --git a/zz_input/README.md b/zz_input/README.md index fd28013..b15526b 100644 --- a/zz_input/README.md +++ b/zz_input/README.md @@ -21,3 +21,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- reads a literal argument, a file argument, or stdin (when no argument given) +- a non-existent path is treated as a literal string, not an error +- reading a file logs which file it read from, to stderr +- multi-line file content is preserved +- an empty literal argument falls back to reading stdin diff --git a/zz_log/README.md b/zz_log/README.md index 76d75ab..e33d4a8 100644 --- a/zz_log/README.md +++ b/zz_log/README.md @@ -21,3 +21,9 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- writes to stderr, never stdout +- each of i/w/e/s/- picks the right pictogram (→, !, ✕, ✔, none) +- an unknown level falls back to printing the level string itself +- multiple message words are joined with spaces +- `{Color text}` inline highlight syntax is supported diff --git a/zz_npx/README.md b/zz_npx/README.md index 2534160..00ea81f 100644 --- a/zz_npx/README.md +++ b/zz_npx/README.md @@ -25,3 +25,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- errors without a tool argument +- runs a locally installed `node_modules/.bin` binary directly, bypassing npx +- resolves the project via `INIT_CWD`, falling back to `PWD` when unset +- errors clearly when the tool is neither local nor is npx available +- remaining arguments are passed through to the local binary +- `-s` is accepted without affecting the local-binary fast path diff --git a/zz_persist/README.md b/zz_persist/README.md index 32933dd..f8b63df 100644 --- a/zz_persist/README.md +++ b/zz_persist/README.md @@ -24,3 +24,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- upserts `KEY=VALUE` into an env file, replacing an existing value on re-run +- appends a new key without disturbing existing entries +- creates the target file if it doesn't exist +- requires a key argument, and rejects an invalid variable name +- requires at least one of `-f`/`-p` +- writes an `export KEY=value` line to a `/etc/profile.d` snippet via `-p` +- can write to `-f` and `-p` simultaneously, and upsert replaces the profile.d export line diff --git a/zz_prompt/README.md b/zz_prompt/README.md index 7c68ae3..9ca4d41 100644 --- a/zz_prompt/README.md +++ b/zz_prompt/README.md @@ -21,3 +21,10 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- empty input returns the given default +- entered input is returned as typed +- no default and empty input returns an empty string +- question and bracketed default are written to stderr, not stdout +- with no default, the bracketed default is omitted from the prompt text +- stdout carries only the value, cleanly separated from the prompt diff --git a/zz_update/README.md b/zz_update/README.md index 4c2f0af..1ba562b 100644 --- a/zz_update/README.md +++ b/zz_update/README.md @@ -22,3 +22,7 @@ disk there, ignoring the cache entirely. ```sh bats test.bats ``` + +- re-links the zz_* bundle from a local checkout without touching the network +- force re-installs every core zz_* script, bypassing the already-available skip +- makes no `curl` call at all when run from a local checkout diff --git a/zz_use/README.md b/zz_use/README.md index 2c81e03..7aab548 100644 --- a/zz_use/README.md +++ b/zz_use/README.md @@ -21,3 +21,11 @@ Declared via `zz_use` at the top of `run.sh` and resolved on demand ```sh bats test.bats ``` + +- skips a tool already on PATH, reporting "already available" +- requires at least one tool argument +- installs a functional script individually, not the whole bundle +- installing any one missing zz_* tool installs the full zz_* bundle at once +- errors with "Unable to provide required dependency" when a tool can't be resolved +- `--force` re-installs the bundle even when already on PATH +- resolves a functional script's `config/` folder alongside it