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:
295
scripts/git-status-report
Executable file
295
scripts/git-status-report
Executable file
@@ -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
|
||||
Reference in New Issue
Block a user