#!/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