|
#!/usr/bin/env bash |
|
# git-status-all — summarize git status across all repos under a directory tree |
|
|
|
# --- Colors --- |
|
R=$'\033[0m' |
|
C_DIR=$'\033[38;5;240m' # dark gray: intermediate directory labels |
|
C_REPO=$'\033[1;37m' # bold white: repo name |
|
C_STAGED=$'\033[33m' # yellow: staged (indexed) changes |
|
C_MODIFIED=$'\033[38;5;208m' # orange: unstaged modifications |
|
C_UNTRACKED=$'\033[38;5;245m' # medium gray: untracked files |
|
C_UNPUSHED=$'\033[38;5;196m' # red: committed but not pushed |
|
C_BEHIND=$'\033[36m' # cyan: remote commits to pull |
|
C_SEP=$'\033[38;5;238m' # dark gray: column separator |
|
C_BRANCH=$'\033[38;5;109m' # muted cyan: current branch name |
|
C_DIM=$'\033[2m' |
|
|
|
# strip_ansi: echoes its argument with ESC[...m sequences removed. |
|
# Used to measure visible cell width for column padding. |
|
strip_ansi() { |
|
local in="$1" out="" |
|
while [[ "$in" == *$'\033['* ]]; do |
|
out+="${in%%$'\033['*}" |
|
in="${in#*$'\033['}" |
|
in="${in#*m}" |
|
done |
|
out+="$in" |
|
printf '%s' "$out" |
|
} |
|
|
|
FETCH=false |
|
START_DIR="$(pwd)" |
|
|
|
usage() { |
|
printf "Usage: git-status-all [-f] [directory]\n\n" |
|
printf " -f fetch from remotes first (slower, accurate ↓ counts)\n" |
|
printf " directory scan from this path (default: current directory)\n\n" |
|
printf "Legend:\n" |
|
printf " ${C_STAGED}●N staged${R} — files in the index not yet committed\n" |
|
printf " ${C_MODIFIED}○N modified${R} — working-tree changes not staged\n" |
|
printf " ${C_UNTRACKED}?N untracked${R}— new files not tracked by git\n" |
|
printf " ${C_UNPUSHED}↑N unpushed${R} — commits ahead of remote (pushed not yet)\n" |
|
printf " ${C_BEHIND}↓N to pull${R} — remote commits not yet pulled\n" |
|
} |
|
|
|
while getopts "fh" opt; do |
|
case $opt in |
|
f) FETCH=true ;; |
|
h) usage; exit 0 ;; |
|
*) usage; exit 1 ;; |
|
esac |
|
done |
|
shift $((OPTIND-1)) |
|
[[ -n "$1" ]] && START_DIR="$(realpath "$1")" |
|
|
|
# Find all git repos, skipping common noise dirs |
|
mapfile -t ALL_REPOS < <( |
|
find "$START_DIR" -name ".git" -type d \ |
|
! -path "*/.git/*" \ |
|
! -path "*/node_modules/*" \ |
|
! -path "*/.cache/*" \ |
|
! -path "*/vendor/*" \ |
|
! -path "*/.cargo/*" \ |
|
| sed 's|/.git$||' \ |
|
| sort |
|
) |
|
|
|
if [[ ${#ALL_REPOS[@]} -eq 0 ]]; then |
|
echo "No git repositories found under $START_DIR" |
|
exit 0 |
|
fi |
|
|
|
# Optionally fetch all in parallel before inspecting |
|
if $FETCH; then |
|
printf "${C_DIM}Fetching remotes for %d repos...${R}\n\n" "${#ALL_REPOS[@]}" |
|
for repo in "${ALL_REPOS[@]}"; do |
|
git -C "$repo" fetch --all --quiet 2>/dev/null & |
|
done |
|
wait |
|
fi |
|
|
|
# --- Collect per-repo status --- |
|
declare -a RES_REPO=() |
|
declare -a RES_REL=() |
|
declare -a RES_DEPTH=() |
|
declare -a RES_STAGED=() |
|
declare -a RES_MODIFIED=() |
|
declare -a RES_UNTRACKED=() |
|
declare -a RES_AHEAD=() |
|
declare -a RES_BEHIND=() |
|
declare -a RES_BRANCH=() |
|
|
|
for repo in "${ALL_REPOS[@]}"; do |
|
if [[ "$repo" == "$START_DIR" ]]; then |
|
rel="." |
|
depth=0 |
|
else |
|
rel="${repo#"$START_DIR"/}" |
|
depth=$(tr -cd '/' <<< "$rel" | wc -c) |
|
fi |
|
|
|
# Parse porcelain output for uncommitted state |
|
staged=0; modified=0; untracked=0 |
|
while IFS= read -r line; do |
|
[[ -z "$line" ]] && continue |
|
x="${line:0:1}"; y="${line:1:1}" |
|
if [[ "$x" == "?" && "$y" == "?" ]]; then |
|
((untracked++)) |
|
else |
|
[[ "$x" =~ [MADRCU] ]] && ((staged++)) |
|
[[ "$y" =~ [MD] ]] && ((modified++)) |
|
fi |
|
done < <(git -C "$repo" status --porcelain 2>/dev/null) |
|
|
|
# Ahead/behind vs remote tracking branch |
|
ahead=0; behind=0 |
|
if git -C "$repo" rev-parse '@{u}' &>/dev/null 2>&1; then |
|
read -r ahead behind < <( |
|
git -C "$repo" rev-list --left-right --count HEAD...@{u} 2>/dev/null |
|
) |
|
ahead=${ahead:-0}; behind=${behind:-0} |
|
fi |
|
|
|
total=$(( staged + modified + untracked + ahead + behind )) |
|
[[ $total -eq 0 ]] && continue |
|
|
|
# Current branch — falls back to short SHA when detached. |
|
branch=$(git -C "$repo" symbolic-ref --short HEAD 2>/dev/null) |
|
if [[ -z "$branch" ]]; then |
|
branch=$(git -C "$repo" rev-parse --short HEAD 2>/dev/null) |
|
[[ -n "$branch" ]] && branch="@${branch}" |
|
fi |
|
|
|
RES_REPO+=("$repo") |
|
RES_REL+=("$rel") |
|
RES_DEPTH+=("$depth") |
|
RES_STAGED+=("$staged") |
|
RES_MODIFIED+=("$modified") |
|
RES_UNTRACKED+=("$untracked") |
|
RES_AHEAD+=("$ahead") |
|
RES_BEHIND+=("$behind") |
|
RES_BRANCH+=("$branch") |
|
done |
|
|
|
if [[ ${#RES_REPO[@]} -eq 0 ]]; then |
|
echo "All repositories are clean." |
|
exit 0 |
|
fi |
|
|
|
# --- Compute column width for repo name (so local/remote columns align) --- |
|
max_name_col=0 |
|
for i in "${!RES_REPO[@]}"; do |
|
bname="$(basename "${RES_REPO[$i]}")" |
|
col=$(( RES_DEPTH[i] * 2 + ${#bname} + 1 )) # +1 for trailing / |
|
(( col > max_name_col )) && max_name_col=$col |
|
done |
|
name_col=$(( max_name_col + 3 )) |
|
|
|
# --- Pre-build status strings & compute max visible width for branch alignment --- |
|
declare -a RES_STATUS_STR=() |
|
declare -a RES_STATUS_LEN=() |
|
max_status_len=0 |
|
for i in "${!RES_REPO[@]}"; do |
|
staged="${RES_STAGED[$i]}" |
|
modified="${RES_MODIFIED[$i]}" |
|
untracked="${RES_UNTRACKED[$i]}" |
|
ahead="${RES_AHEAD[$i]}" |
|
behind="${RES_BEHIND[$i]}" |
|
|
|
local_parts=() |
|
(( staged > 0 )) && local_parts+=("${C_STAGED}●${staged} staged${R}") |
|
(( modified > 0 )) && local_parts+=("${C_MODIFIED}○${modified} modified${R}") |
|
(( untracked > 0 )) && local_parts+=("${C_UNTRACKED}?${untracked} untracked${R}") |
|
(( ahead > 0 )) && local_parts+=("${C_UNPUSHED}↑${ahead} unpushed${R}") |
|
local_str="" |
|
for part in "${local_parts[@]}"; do |
|
[[ -n "$local_str" ]] && local_str+=" " |
|
local_str+="$part" |
|
done |
|
remote_str="" |
|
(( behind > 0 )) && remote_str="${C_BEHIND}↓${behind} to pull${R}" |
|
sep="" |
|
[[ -n "$local_str" && -n "$remote_str" ]] && sep=" ${C_SEP}│${R} " |
|
|
|
full="${local_str}${sep}${remote_str}" |
|
plain=$(strip_ansi "$full") |
|
RES_STATUS_STR+=("$full") |
|
RES_STATUS_LEN+=("${#plain}") |
|
(( ${#plain} > max_status_len )) && max_status_len=${#plain} |
|
done |
|
|
|
# --- Print --- |
|
declare -A SHOWN_DIRS |
|
|
|
for i in "${!RES_REPO[@]}"; do |
|
repo="${RES_REPO[$i]}" |
|
rel="${RES_REL[$i]}" |
|
depth="${RES_DEPTH[$i]}" |
|
branch="${RES_BRANCH[$i]}" |
|
status_str="${RES_STATUS_STR[$i]}" |
|
status_len="${RES_STATUS_LEN[$i]}" |
|
name="$(basename "$repo")/" |
|
indent=$(printf '%*s' $(( depth * 2 )) '') |
|
|
|
# Print any intermediate parent dirs not yet shown |
|
if [[ "$rel" != "." ]]; then |
|
IFS='/' read -ra parts <<< "$rel" |
|
partial="" |
|
for (( j=0; j < ${#parts[@]}-1; j++ )); do |
|
[[ -n "$partial" ]] && partial="${partial}/${parts[$j]}" || partial="${parts[$j]}" |
|
if [[ -z "${SHOWN_DIRS[$partial]}" ]]; then |
|
SHOWN_DIRS[$partial]=1 |
|
pdepth=$(tr -cd '/' <<< "$partial" | wc -c) |
|
pindent=$(printf '%*s' $(( pdepth * 2 )) '') |
|
printf "${pindent}${C_DIR}%s/${R}\n" "${parts[$j]}" |
|
fi |
|
done |
|
fi |
|
|
|
# Pad repo name to align the status column |
|
name_len=$(( depth * 2 + ${#name} )) |
|
name_pad=$(printf '%*s' $(( name_col - name_len )) '') |
|
|
|
# Pad status to align the branch column |
|
status_pad=$(printf '%*s' $(( max_status_len - status_len + 2 )) '') |
|
|
|
branch_col="" |
|
[[ -n "$branch" ]] && branch_col="${C_BRANCH}(${branch})${R}" |
|
|
|
printf "${indent}${C_REPO}%s${R}%s%s%s%s\n" \ |
|
"$name" "$name_pad" "$status_str" "$status_pad" "$branch_col" |
|
done |
|
|
|
# Footer legend |
|
printf "\n${C_DIM}%s${R}\n" "────────────────────────────────────────────────────" |
|
printf "${C_DIM}${C_STAGED}●${R}${C_DIM}staged ${C_MODIFIED}○${R}${C_DIM}modified ${C_UNTRACKED}?${R}${C_DIM}untracked ${C_UNPUSHED}↑${R}${C_DIM}unpushed (committed) ${C_BEHIND}↓${R}${C_DIM}to pull │ run -f to fetch remotes${R}\n" |