Add git-status-report: recursive git repo scanner with status reporting

Scans a directory tree for git repos and produces a concise report
showing uncommitted changes (with +/- character counts) and
ahead/behind status per remote. Supports --dryrun to preview
discovered repos without running checks.

Includes OpenSpec, implementation, and 26-assertion test suite.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul O'Reilly
2026-03-13 11:13:59 +13:00
parent d7c8814a5a
commit 5230e54236
3 changed files with 639 additions and 0 deletions

241
tests/test-git-status-report.sh Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# Test script for git-status-report using --dryrun and live checks
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/git-status-report"
PASS=0
FAIL=0
TMPDIR=""
# --- Colours ---
RED='\033[31m'
GREEN='\033[32m'
RESET='\033[0m'
cleanup() {
[[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
setup_tmpdir() {
TMPDIR=$(mktemp -d)
}
pass() {
printf " ${GREEN}${RESET} %s\n" "$1"
PASS=$((PASS + 1))
}
fail() {
printf " ${RED}${RESET} %s\n" "$1"
[[ -n "${2:-}" ]] && printf " %s\n" "$2"
FAIL=$((FAIL + 1))
}
assert_contains() {
local output="$1" expected="$2" label="$3"
if echo "$output" | grep -qF -- "$expected"; then
pass "$label"
else
fail "$label" "Expected to find: $expected"
fi
}
assert_not_contains() {
local output="$1" unexpected="$2" label="$3"
if echo "$output" | grep -qF -- "$unexpected"; then
fail "$label" "Did not expect to find: $unexpected"
else
pass "$label"
fi
}
assert_exit_code() {
local actual="$1" expected="$2" label="$3"
if [[ "$actual" -eq "$expected" ]]; then
pass "$label"
else
fail "$label" "Expected exit $expected, got $actual"
fi
}
# ============================================================
echo "=== git-status-report tests ==="
echo ""
# --- Test: --help flag ---
echo "-- help flag --"
output=$(bash "$SCRIPT" --help 2>&1) || true
assert_contains "$output" "Usage:" "--help shows usage"
assert_contains "$output" "--dryrun" "--help mentions dryrun"
# --- Test: no repos found ---
echo "-- no repos found --"
setup_tmpdir
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
assert_contains "$output" "No git repositories found" "empty dir reports no repos"
# --- Test: dryrun discovers repos ---
echo "-- dryrun discovery --"
setup_tmpdir
mkdir -p "$TMPDIR/repo-a"
git -C "$TMPDIR/repo-a" init -q
mkdir -p "$TMPDIR/subdir/repo-b"
git -C "$TMPDIR/subdir/repo-b" init -q
output=$(bash "$SCRIPT" --dryrun "$TMPDIR" 2>&1)
rc=$?
assert_exit_code "$rc" 0 "dryrun exits 0"
assert_contains "$output" "[dryrun] Would check 2 repositories:" "dryrun header with count"
assert_contains "$output" "repo-a" "dryrun lists repo-a"
assert_contains "$output" "repo-b" "dryrun lists repo-b"
# --- Test: dryrun with -n short flag ---
echo "-- dryrun short flag --"
output=$(bash "$SCRIPT" -n "$TMPDIR" 2>&1)
assert_contains "$output" "[dryrun]" "-n flag works as dryrun"
# --- Test: clean repo ---
echo "-- clean repo --"
setup_tmpdir
mkdir -p "$TMPDIR/clean-repo"
git -C "$TMPDIR/clean-repo" init -q
git -C "$TMPDIR/clean-repo" commit --allow-empty -m "init" -q
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1)
rc=$?
assert_exit_code "$rc" 0 "clean repo exits 0"
assert_contains "$output" "clean and in sync" "clean repo shows clean message"
# --- Test: dirty repo with uncommitted changes ---
echo "-- dirty repo --"
setup_tmpdir
mkdir -p "$TMPDIR/dirty-repo"
git -C "$TMPDIR/dirty-repo" init -q
echo "hello world" > "$TMPDIR/dirty-repo/file.txt"
git -C "$TMPDIR/dirty-repo" add file.txt
git -C "$TMPDIR/dirty-repo" commit -m "init" -q
echo "hello world modified with extra content" > "$TMPDIR/dirty-repo/file.txt"
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) && rc=$? || rc=$?
assert_exit_code "$rc" 1 "dirty repo exits 1"
assert_contains "$output" "dirty: dirty-repo" "dirty repo header shown"
assert_contains "$output" "file.txt" "changed file listed"
# --- Test: untracked files ---
echo "-- untracked files --"
setup_tmpdir
mkdir -p "$TMPDIR/untracked-repo"
git -C "$TMPDIR/untracked-repo" init -q
git -C "$TMPDIR/untracked-repo" commit --allow-empty -m "init" -q
echo "new file content" > "$TMPDIR/untracked-repo/newfile.txt"
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) && rc=$? || rc=$?
assert_exit_code "$rc" 1 "untracked file makes repo dirty"
assert_contains "$output" "newfile.txt" "untracked file listed"
assert_contains "$output" "??" "untracked status shown"
# --- Test: deleted files ---
echo "-- deleted files --"
setup_tmpdir
mkdir -p "$TMPDIR/del-repo"
git -C "$TMPDIR/del-repo" init -q
echo "content to delete" > "$TMPDIR/del-repo/gone.txt"
git -C "$TMPDIR/del-repo" add gone.txt
git -C "$TMPDIR/del-repo" commit -m "init" -q
rm "$TMPDIR/del-repo/gone.txt"
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
assert_contains "$output" "gone.txt" "deleted file listed"
# --- Test: repo with no remotes ---
echo "-- no remotes --"
setup_tmpdir
mkdir -p "$TMPDIR/no-remote"
git -C "$TMPDIR/no-remote" init -q
echo "change" > "$TMPDIR/no-remote/file.txt"
git -C "$TMPDIR/no-remote" add file.txt
git -C "$TMPDIR/no-remote" commit -m "init" -q
echo "modified" > "$TMPDIR/no-remote/file.txt"
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
assert_contains "$output" "no remotes" "no remotes noted on dirty repo"
# --- Test: ahead of remote ---
echo "-- ahead of remote --"
setup_tmpdir
# Create a bare "remote"
git init -q --bare "$TMPDIR/remote.git"
mkdir -p "$TMPDIR/ahead-repo"
git -C "$TMPDIR/ahead-repo" init -q
git -C "$TMPDIR/ahead-repo" remote add origin "$TMPDIR/remote.git"
echo "first" > "$TMPDIR/ahead-repo/file.txt"
git -C "$TMPDIR/ahead-repo" add file.txt
git -C "$TMPDIR/ahead-repo" commit -m "first" -q
git -C "$TMPDIR/ahead-repo" push -u origin main -q 2>/dev/null || git -C "$TMPDIR/ahead-repo" push -u origin master -q 2>/dev/null
# Make a local-only commit
echo "second" > "$TMPDIR/ahead-repo/file.txt"
git -C "$TMPDIR/ahead-repo" add file.txt
git -C "$TMPDIR/ahead-repo" commit -m "second" -q
output=$(bash "$SCRIPT" "$TMPDIR/ahead-repo" 2>&1) || true
assert_contains "$output" "1 ahead" "ahead count shown"
# --- Test: mixed clean and dirty ---
echo "-- mixed repos --"
setup_tmpdir
mkdir -p "$TMPDIR/clean"
git -C "$TMPDIR/clean" init -q
git -C "$TMPDIR/clean" commit --allow-empty -m "init" -q
mkdir -p "$TMPDIR/dirty"
git -C "$TMPDIR/dirty" init -q
echo "content" > "$TMPDIR/dirty/file.txt"
git -C "$TMPDIR/dirty" add file.txt
git -C "$TMPDIR/dirty" commit -m "init" -q
echo "changed" > "$TMPDIR/dirty/file.txt"
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1) || true
assert_contains "$output" "Scanned 2 repositories" "scanned count correct"
assert_contains "$output" "dirty: dirty" "dirty repo listed"
assert_contains "$output" "1 repositories are clean" "clean count shown"
# --- Test: nested repos not double-counted ---
echo "-- nested repos --"
setup_tmpdir
mkdir -p "$TMPDIR/outer"
git -C "$TMPDIR/outer" init -q
git -C "$TMPDIR/outer" commit --allow-empty -m "init" -q
mkdir -p "$TMPDIR/outer/inner"
git -C "$TMPDIR/outer/inner" init -q
git -C "$TMPDIR/outer/inner" commit --allow-empty -m "init" -q
output=$(bash "$SCRIPT" --dryrun "$TMPDIR" 2>&1)
assert_contains "$output" "Would check 1 repositories:" "nested repo filtered out"
assert_contains "$output" "outer" "outer repo found"
assert_not_contains "$output" "inner" "inner repo excluded"
# --- Test: permission denied ---
echo "-- permission denied --"
setup_tmpdir
mkdir -p "$TMPDIR/accessible"
git -C "$TMPDIR/accessible" init -q
git -C "$TMPDIR/accessible" commit --allow-empty -m "init" -q
mkdir -p "$TMPDIR/blocked"
chmod 000 "$TMPDIR/blocked"
output=$(bash "$SCRIPT" "$TMPDIR" 2>&1)
rc=$?
# Should still find the accessible repo
assert_contains "$output" "Scanned 1 repositories" "survives permission denied"
chmod 755 "$TMPDIR/blocked" # Cleanup
# ============================================================
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [[ $FAIL -gt 0 ]]; then
exit 1
fi
printf "${GREEN}All tests passed.${RESET}\n"