From 5230e542360f8b07b7375c943ffb99501f4aacd8 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Fri, 13 Mar 2026 11:13:59 +1300 Subject: [PATCH] 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 --- scripts/git-status-report | 295 ++++++++++++++++++++++++++++++++ specs/git-status-report.spec.md | 103 +++++++++++ tests/test-git-status-report.sh | 241 ++++++++++++++++++++++++++ 3 files changed, 639 insertions(+) create mode 100755 scripts/git-status-report create mode 100644 specs/git-status-report.spec.md create mode 100755 tests/test-git-status-report.sh diff --git a/scripts/git-status-report b/scripts/git-status-report new file mode 100755 index 0000000..d368220 --- /dev/null +++ b/scripts/git-status-report @@ -0,0 +1,295 @@ +#!/usr/bin/env bash +# git-status-report — Scan directories for git repos and report status +set -uo pipefail + +# --- Colours --- +RED='\033[31m' +GREEN='\033[32m' +YELLOW='\033[33m' +CYAN='\033[36m' +BOLD='\033[1m' +DIM='\033[2m' +RESET='\033[0m' + +# --- Defaults --- +DRYRUN=false +TARGET_DIR="." + +usage() { + cat <<'USAGE' +Usage: git-status-report [OPTIONS] [DIRECTORY] + +Recursively scan for git repositories and report uncommitted changes +and remote sync status. + +Arguments: + DIRECTORY Root directory to scan (default: current directory) + +Options: + -n, --dryrun List discovered repos without running status checks + -h, --help Show this help message +USAGE +} + +# --- Parse args --- +while [[ $# -gt 0 ]]; do + case "$1" in + -n|--dryrun) DRYRUN=true; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + *) TARGET_DIR="$1"; shift ;; + esac +done + +# Resolve to absolute path for display +TARGET_DIR="$(cd "$TARGET_DIR" 2>/dev/null && pwd)" || { + echo "Error: cannot access directory '$TARGET_DIR'" >&2 + exit 2 +} + +# --- Discovery phase --- +discover_repos() { + local dir="$1" + local repos=() + + while IFS= read -r -d '' gitdir; do + repos+=("$(dirname "$gitdir")") + done < <(find -L "$dir" -name .git -type d -print0 2>/dev/null | sort -z) + + # Filter out nested repos (a repo whose path is a subdirectory of another) + local filtered=() + for repo in "${repos[@]}"; do + local is_nested=false + for other in "${filtered[@]}"; do + if [[ "$repo" == "$other"/* ]]; then + is_nested=true + break + fi + done + if ! $is_nested; then + filtered+=("$repo") + fi + done + + printf '%s\n' "${filtered[@]}" +} + +REPOS=() +while IFS= read -r repo; do + [[ -n "$repo" ]] && REPOS+=("$repo") +done < <(discover_repos "$TARGET_DIR") + +REPO_COUNT=${#REPOS[@]} + +if [[ $REPO_COUNT -eq 0 ]]; then + echo "No git repositories found in $TARGET_DIR" + exit 0 +fi + +# --- Dryrun --- +if $DRYRUN; then + echo "[dryrun] Would check $REPO_COUNT repositories:" + for repo in "${REPOS[@]}"; do + local_path="${repo#"$TARGET_DIR"}" + [[ -z "$local_path" ]] && local_path="." + local_path="${local_path#/}" + echo " $TARGET_DIR/$local_path" + done + exit 0 +fi + +# --- Status phase --- +DIRTY_REPOS=() +DIRTY_OUTPUT=() +CLEAN_COUNT=0 + +get_char_diff() { + local repo="$1" + local file="$2" + local status="$3" + + if [[ "$status" == "?" ]]; then + # Untracked: count all characters as added + local target="$repo/$file" + if [[ -d "$target" ]]; then + # Directory: sum all file sizes recursively + local chars + chars=$(find "$target" -type f -exec cat {} + 2>/dev/null | wc -c || echo 0) + echo "+${chars}" + return + fi + if file "$target" 2>/dev/null | grep -qP "binary|image|archive" && ! file "$target" 2>/dev/null | grep -q "text"; then + echo "(binary)" + return + fi + local chars + chars=$(wc -c < "$target" 2>/dev/null || echo 0) + echo "+${chars}" + return + fi + + if [[ "$status" == "D" ]]; then + # Deleted: count from last committed version + local chars + chars=$(git -C "$repo" show "HEAD:$file" 2>/dev/null | wc -c || echo 0) + if [[ "$chars" == "0" ]]; then + echo "(binary)" + else + echo "-${chars}" + fi + return + fi + + # Modified: diff character counts + local diff_output + diff_output=$(git -C "$repo" diff HEAD -- "$file" 2>/dev/null || git -C "$repo" diff -- "$file" 2>/dev/null || true) + + if [[ -z "$diff_output" ]]; then + # Staged but no diff against HEAD — try cached + diff_output=$(git -C "$repo" diff --cached -- "$file" 2>/dev/null || true) + fi + + # Check for binary + if echo "$diff_output" | grep -q "Binary files"; then + echo "(binary)" + return + fi + + local added=0 removed=0 + while IFS= read -r line; do + case "$line" in + +*) added=$((added + ${#line} - 1)) ;; # -1 for the leading + + -*) removed=$((removed + ${#line} - 1)) ;; + esac + done < <(echo "$diff_output" | grep -E '^\+[^+]|^-[^-]' || true) + + echo "+${added} / -${removed}" +} + +get_remote_status() { + local repo="$1" + local output="" + + local branch + branch=$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null || echo "") + + if [[ -z "$branch" ]]; then + # Return empty — detached HEAD alone isn't a divergence. + return + fi + + local remotes + remotes=$(git -C "$repo" remote 2>/dev/null || true) + + if [[ -z "$remotes" ]]; then + # Return empty — "no remotes" is not a divergence. + # The caller adds this note only when the repo is already dirty. + return + fi + + while IFS= read -r remote; do + [[ -z "$remote" ]] && continue + + # Fetch is too slow for a status report — use local tracking info + local remote_branch="${remote}/${branch}" + + if ! git -C "$repo" rev-parse --verify "$remote_branch" &>/dev/null; then + output+=" ${DIM}↕ ${remote}/${branch}: (no upstream)${RESET}\n" + continue + fi + + local counts + counts=$(git -C "$repo" rev-list --left-right --count "${branch}...${remote_branch}" 2>/dev/null || echo "0 0") + local ahead behind + ahead=$(echo "$counts" | cut -f1) + behind=$(echo "$counts" | cut -f2) + + if [[ "$ahead" -eq 0 && "$behind" -eq 0 ]]; then + continue # In sync — nothing to report + fi + + local colour="$YELLOW" + output+=" ${colour}↕ ${remote}/${branch}: ${ahead} ahead, ${behind} behind${RESET}\n" + done <<< "$remotes" + + [[ -n "$output" ]] && echo -e "$output" +} + +for repo in "${REPOS[@]}"; do + rel_path="${repo#"$TARGET_DIR"}" + [[ -z "$rel_path" ]] && rel_path="." + rel_path="${rel_path#/}" + + # Get porcelain status + porcelain=$(git -C "$repo" status --porcelain 2>/dev/null || true) + remote_info=$(get_remote_status "$repo") + + if [[ -z "$porcelain" && -z "$remote_info" ]]; then + CLEAN_COUNT=$((CLEAN_COUNT + 1)) + continue + fi + + # Build output for this repo + section="" + section+="$(printf "${BOLD}── dirty: %s ──${RESET}" "$rel_path")\n" + + if [[ -n "$porcelain" ]]; then + while IFS= read -r line; do + [[ -z "$line" ]] && continue + local_status="${line:0:2}" + file="${line:3}" + + # Determine primary status character + status_char="${local_status:0:1}" + [[ "$status_char" == " " ]] && status_char="${local_status:1:1}" + [[ "$status_char" == "?" ]] && status_char="?" + + # Format the status display + case "$status_char" in + M) display_status="${YELLOW} M${RESET}" ;; + A) display_status="${GREEN} A${RESET}" ;; + D) display_status="${RED} D${RESET}" ;; + R) display_status="${CYAN} R${RESET}" ;; + ?) display_status="${RED}??${RESET}" ;; + *) display_status=" ${status_char}" ;; + esac + + char_diff=$(get_char_diff "$repo" "$file" "$status_char") + section+="$(printf " %b %-30s %s" "$display_status" "$file" "$char_diff")\n" + done <<< "$porcelain" + fi + + if [[ -n "$remote_info" ]]; then + section+="$remote_info" + elif [[ -n "$porcelain" ]]; then + # Show "(no remotes)" only when there are local changes + has_remotes=$(git -C "$repo" remote 2>/dev/null || true) + if [[ -z "$has_remotes" ]]; then + section+=" ${DIM}(no remotes)${RESET}\n" + fi + fi + + DIRTY_REPOS+=("$rel_path") + DIRTY_OUTPUT+=("$section") +done + +# --- Report phase --- +echo "" +echo "Scanned $REPO_COUNT repositories in $TARGET_DIR" +echo "" + +for output in "${DIRTY_OUTPUT[@]}"; do + echo -e "$output" +done + +if [[ $CLEAN_COUNT -gt 0 ]]; then + printf "${GREEN}✓ %d repositories are clean and in sync.${RESET}\n" "$CLEAN_COUNT" +elif [[ ${#DIRTY_REPOS[@]} -eq 0 ]]; then + printf "${GREEN}✓ All %d repositories are clean and in sync.${RESET}\n" "$REPO_COUNT" +fi + +# Exit 1 if any repo is dirty or out of sync +if [[ ${#DIRTY_REPOS[@]} -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/specs/git-status-report.spec.md b/specs/git-status-report.spec.md new file mode 100644 index 0000000..888020f --- /dev/null +++ b/specs/git-status-report.spec.md @@ -0,0 +1,103 @@ +# git-status-report + +## Purpose + +Recursively scan a directory tree for git repositories and produce a concise report showing uncommitted changes and remote sync status. + +## Usage + +``` +git-status-report [OPTIONS] [DIRECTORY] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `DIRECTORY` | `.` (current directory) | Root directory to scan | + +### Flags + +| Flag | Short | Description | +|------|-------|-------------| +| `--dryrun` | `-n` | List discovered repos without running status checks | +| `--help` | `-h` | Show usage information | + +## Behaviour + +1. **Discovery phase:** Walk `DIRECTORY` recursively, identifying directories that contain a `.git` folder. Stop descending into a directory once a `.git` is found (don't scan nested repos inside a git worktree). + +2. **Status phase:** For each discovered repo: + a. Run `git status --porcelain` to detect uncommitted changes (staged, unstaged, untracked). + b. For each changed file, compute the character-level diff: `+N` added, `-N` removed. For untracked files, count all characters as `+N`. For deleted files, count all characters as `-N`. + c. Run `git remote` to list remotes. For each remote, run `git rev-list --left-right --count .../` to determine ahead/behind counts. + +3. **Report phase:** Print a grouped report: + - **Clean repos** are listed in a single summary line (count only) unless there are none. + - **Dirty repos** get a section each, showing: + - Repo path (relative to `DIRECTORY`) + - Each changed file with its status and `+N / -N` character counts + - Ahead/behind status per remote/branch + - **Repos with remote divergence** (ahead or behind) but no local changes still get a section showing the ahead/behind status. + +4. **Exit code:** + - `0` — all repos clean and in sync + - `1` — at least one repo has uncommitted changes or is out of sync + +## Dryrun Behaviour + +When `--dryrun` is passed: +- Perform the discovery phase only +- Print each discovered repo path (relative to `DIRECTORY`), one per line +- Prefix output with `[dryrun] Would check N repositories:` +- Do NOT run any git status or remote checks +- Exit code is always `0` + +## Edge Cases + +| Scenario | Handling | +|----------|----------| +| No git repos found | Print "No git repositories found in " and exit 0 | +| Repo has no remotes | Skip remote sync section for that repo, show "(no remotes)" | +| Repo has detached HEAD | Show branch as `(detached HEAD)` and skip remote comparison | +| Remote branch doesn't exist | Show "(no upstream)" for that remote | +| Binary files changed | Show `(binary)` instead of character counts | +| Permission denied on subdirectory | Skip with warning to stderr, continue scanning | +| Nested git repos (submodules) | Stop at the outermost `.git` — don't descend further | +| Symlinked directories | Follow symlinks during discovery | + +## Examples + +### Clean repos +``` +Scanned 5 repositories in ~/dev + +✓ All 5 repositories are clean and in sync. +``` + +### Mixed status +``` +Scanned 5 repositories in ~/dev + +── dirty: project-alpha ── + M src/main.py +42 / -17 + M README.md +5 / -0 + ?? TODO.txt +120 + ↕ origin/main: 2 ahead, 0 behind + +── dirty: infra-configs ── + D old-config.yaml -89 + ↕ origin/main: 0 ahead, 3 behind + +✓ 3 repositories are clean and in sync. +``` + +### Dryrun +``` +[dryrun] Would check 5 repositories: + ~/dev/project-alpha + ~/dev/project-beta + ~/dev/infra-configs + ~/dev/scripts + ~/dev/docs +``` diff --git a/tests/test-git-status-report.sh b/tests/test-git-status-report.sh new file mode 100755 index 0000000..36152da --- /dev/null +++ b/tests/test-git-status-report.sh @@ -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"