Skip to content

Instantly share code, notes, and snippets.

@rw3iss
Last active May 1, 2026 22:06
Show Gist options
  • Select an option

  • Save rw3iss/af30d2a1ef2d99312aabba538bbeb196 to your computer and use it in GitHub Desktop.

Select an option

Save rw3iss/af30d2a1ef2d99312aabba538bbeb196 to your computer and use it in GitHub Desktop.
git-status-all: color-coded recursive git status across all repos in a directory tree

git-status-all

A bash script that recursively finds every git repository under a directory and prints a color-coded, tree-structured status summary — showing uncommitted changes, unpushed commits, remote commits waiting to be pulled, and the current branch per repo, all on one line.

git-status-all sample output

Install

# 1. Download the script
curl -o ~/bin/git-status-all https://gist.githubusercontent.com/rw3iss/raw/git-status-all
chmod +x ~/bin/git-status-all

# 2. Make sure ~/bin is in your PATH (add to ~/.zshrc or ~/.bashrc if needed)
export PATH="$HOME/bin:$PATH"

# 3. Optional: add a short alias
echo 'alias gsa="git-status-all"' >> ~/.zshrc
source ~/.zshrc

Usage

git-status-all [-f] [directory]
gsa [-f] [directory]

  -f           Fetch from all remotes first (slower, but gives accurate ↓ pull counts)
  -h           Show help and legend
  directory    Directory to scan (default: current directory)

Examples

# Scan current directory and all subdirectories
gsa

# Scan a specific path
gsa ~/Sites

# Fetch remotes first for accurate "to pull" counts
gsa -f ~/projects

Sample Output

api-server/        ○1 modified                 (develop)
buyer-portal/      ○1 modified                 (super-admin/staging)
other/
  bulk-listing/    ↓2 to pull                  (staging)
  super-admin/
    buyer-portal/  ○2 modified                 (super-admin/develop)
    seller-portal/ ○1 modified                 (super-admin/staging)
seller-portal/     ○1 modified                 (staging)
shared/            ?2 untracked                (main)

────────────────────────────────────────────────────
●staged  ○modified  ?untracked  ↑unpushed (committed)  ↓to pull  │  run -f to fetch remotes

Color legend:

Symbol Color Meaning
●N staged Yellow Files added to the index, not yet committed
○N modified Orange Working-tree changes not yet staged
?N untracked Gray New files not tracked by git
↑N unpushed Red Commits made locally, not pushed to remote
↓N to pull Cyan Remote commits not yet pulled locally
(branch) Muted cyan Current branch (or @<short-sha> when detached)
Dim Separator between local and remote columns

Note: The / distinction lets you tell the difference between uncommitted work (staged/modified/untracked) and committed-but-unpushed work (↑). Both need attention before switching branches or handing off.

Notes

  • Skips node_modules/, vendor/, .cache/, .cargo/ automatically
  • Repos with no changes are silently omitted — only dirty repos appear
  • Without -f, remote counts () use cached tracking info and may be stale; run -f for accuracy
  • Requires bash 4+ (standard on Linux; macOS may need brew install bash)
#!/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"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment