#!/bin/bash # git-overview - Quick status of all your git and jj repos set -o pipefail RED='\033[31m' GREEN='\033[32m' YELLOW='\033[33m' CYAN='\033[36m' DIM='\033[90m' BOLD='\033[1m' RESET='\033[0m' SYM_DIRTY="○" SYM_CLEAN="◆" SYM_STASH="◇" MAX_DEPTH=1 NO_COLOR=false SHOW_CLEAN=true SORT_BY="name" usage() { echo "Usage: $(basename "$0") [options] [directory]" echo "" echo "Options:" echo " -d N Search depth (default: 1)" echo " -t Sort by time (recent first)" echo " -a Only show repos needing attention" echo " --no-color Disable colors" echo " -h Show this help" exit 0 } while [[ $# -gt 0 ]]; do case "$1" in -d) MAX_DEPTH="$2"; shift 2 ;; -t) SORT_BY="time"; shift ;; -a) SHOW_CLEAN=false; shift ;; --no-color) NO_COLOR=true; shift ;; -h|--help) usage ;; -*) echo "Unknown: $1"; usage ;; *) TARGET="$1"; shift ;; esac done TARGET="${TARGET:-.}" [[ "$NO_COLOR" == true ]] && RED="" GREEN="" YELLOW="" CYAN="" DIM="" BOLD="" RESET="" trunc() { local str="$1" max="$2" [[ ${#str} -gt $max ]] && echo "${str:0:$((max-1))}…" || echo "$str" } append() { # append a detail token, space-separated local -n _ref=$1 [[ -n "$_ref" ]] && _ref+=" " _ref+="$2" } get_repo_info_jj() { local dir="$1" base="$2" local name branch status="clean" details="" name=$(realpath --relative-to="$base" "$dir") cd "$dir" 2>/dev/null || return 1 branch=$(jj log -r '@' --no-graph --template 'bookmarks' 2>/dev/null) [[ -z "$branch" ]] && branch=$(jj log -r '@' --no-graph --template 'commit_id.short(8)' 2>/dev/null || echo "?") local wc_empty wc_empty=$(jj log -r '@' --no-graph --template 'if(empty, "1", "0")' 2>/dev/null) if [[ "$wc_empty" == "0" ]]; then local modified modified=$(jj diff --stat 2>/dev/null | tail -1 | grep -o '^[0-9]*') append details "M:${modified:-?}" status="dirty" fi local ahead ahead=$(jj log -r 'trunk()..@ ~ empty()' --no-graph --template 'change_id ++ "\n"' 2>/dev/null | grep -c .) if [[ $ahead -gt 0 ]]; then append details "↑$ahead" status="dirty" fi local behind behind=$(jj log -r 'trunk()..remote_bookmarks(glob:"main") | trunk()..remote_bookmarks(glob:"master")' --no-graph --template 'change_id ++ "\n"' 2>/dev/null | grep -c .) [[ $behind -gt 0 ]] && append details "↓$behind" && status="dirty" local timestamp time read -r timestamp time <<< "$(jj log -r '@' --no-graph --template 'committer.timestamp().utc().format("%s") ++ " " ++ committer.timestamp().ago()' 2>/dev/null | sed 's/ ago//; s/, .*//')" [[ -z "$timestamp" ]] && timestamp="0" && time="–" echo "$name|$branch|$status|$details|$time|$timestamp" } get_repo_info_git() { local dir="$1" base="$2" local name branch status="clean" details="" name=$(realpath --relative-to="$base" "$dir") cd "$dir" 2>/dev/null || return 1 branch=$(git branch --show-current 2>/dev/null) [[ -z "$branch" ]] && branch=$(git rev-parse --short HEAD 2>/dev/null || echo "?") local porcelain porcelain=$(git status --porcelain 2>/dev/null) if [[ -n "$porcelain" ]]; then local modified staged untracked modified=$(echo "$porcelain" | grep -c "^ M\|^.M\|^M ") staged=$(echo "$porcelain" | grep -c "^[MADRC]") untracked=$(echo "$porcelain" | grep -c "^??") [[ $modified -gt 0 ]] && append details "M:$modified" [[ $staged -gt 0 ]] && append details "S:$staged" [[ $untracked -gt 0 ]] && append details "?:$untracked" status="dirty" fi local stashes stashes=$(git stash list 2>/dev/null | wc -l | tr -d ' ') if [[ $stashes -gt 0 ]]; then append details "stash:$stashes" [[ "$status" == "clean" ]] && status="stash" fi local upstream upstream=$(git rev-parse --abbrev-ref @{upstream} 2>/dev/null) if [[ -n "$upstream" ]]; then local ahead behind read -r ahead behind <<< "$(git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null)" [[ $ahead -gt 0 ]] && append details "↑$ahead" [[ $behind -gt 0 ]] && append details "↓$behind" fi local timestamp time read -r timestamp time <<< "$(git log -1 --format="%ct %cr" 2>/dev/null | sed 's/ ago//; s/, .*//')" [[ -z "$timestamp" ]] && timestamp="0" && time="–" echo "$name|$branch|$status|$details|$time|$timestamp" } get_repo_info() { local dir="$1" base="$2" if [[ -d "$dir/.jj" ]]; then get_repo_info_jj "$dir" "$base" else get_repo_info_git "$dir" "$base" fi } sort_repos() { if [[ "$SORT_BY" == "time" ]]; then sort -t'|' -k6 -rn else sort -t'|' -k1 fi } sorted_list() { local -n _arr=$1 [[ ${#_arr[@]} -eq 0 ]] && return local sorted=() while IFS= read -r line; do sorted+=("$line") done < <(printf '%s\n' "${_arr[@]}" | sort_repos) _arr=("${sorted[@]}") } main() { local target_path target_path=$(cd "$TARGET" 2>/dev/null && pwd) || { echo "Error: Cannot access $TARGET"; exit 1; } local dirty_repos=() clean_repos=() while IFS= read -r -d '' git_dir; do local repo_dir info row_status repo_dir=$(dirname "$git_dir") info=$(get_repo_info "$repo_dir" "$target_path") [[ -z "$info" ]] && continue row_status=$(echo "$info" | cut -d'|' -f3) if [[ "$row_status" == "dirty" || "$row_status" == "stash" ]]; then dirty_repos+=("$info") else clean_repos+=("$info") fi done < <(find "$target_path" -maxdepth $((MAX_DEPTH + 1)) -type d -name ".git" -print0 2>/dev/null | sort -z) sorted_list dirty_repos sorted_list clean_repos local total=$(( ${#dirty_repos[@]} + ${#clean_repos[@]} )) echo -e "${BOLD}$(basename "$target_path")${RESET} ${DIM}($total repos)${RESET}" echo "" if [[ $total -eq 0 ]]; then echo "No git repos found" exit 0 fi for info in "${dirty_repos[@]}"; do IFS='|' read -r name branch status details time _ <<< "$info" local sym color if [[ "$status" == "stash" ]]; then sym=$SYM_STASH; color=$YELLOW else sym=$SYM_DIRTY; color=$RED fi printf "${color}${sym}${RESET} ${BOLD}%-20s${RESET} ${CYAN}%-12s${RESET} ${RED}%-14s${RESET} ${DIM}%s${RESET}\n" \ "$(trunc "$name" 20)" "$(trunc "$branch" 12)" "$details" "$time" done if [[ "$SHOW_CLEAN" == true && ${#clean_repos[@]} -gt 0 ]]; then [[ ${#dirty_repos[@]} -gt 0 ]] && echo "" for info in "${clean_repos[@]}"; do IFS='|' read -r name branch status details time _ <<< "$info" printf "${GREEN}${SYM_CLEAN}${RESET} ${DIM}%-20s %-12s${RESET} %-14s ${DIM}%s${RESET}\n" \ "$(trunc "$name" 20)" "$(trunc "$branch" 12)" "$details" "$time" done fi echo "" echo -e "${DIM}${SYM_DIRTY} dirty ${SYM_STASH} stash ${SYM_CLEAN} clean │ M modified S staged ? untracked ↑↓ ahead/behind${RESET}" } main