Last active
March 27, 2026 21:36
-
-
Save adrianoluis/5b07cbf5bbc3679efff0b45b9d98c1cd to your computer and use it in GitHub Desktop.
Update master in all git repo under the provided directory or if missing uses running directory.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # git-sweep — Recursively find git repos and perform maintenance: | |
| # - Stash local changes (and show the diff) | |
| # - Switch to default branch, pull latest | |
| # - Optionally: clean untracked files, garbage collect, prune branches | |
| # - Report new commits, changed files, and pruned branches | |
| # - Switch back and restore stash | |
| # | |
| # Usage: ./git-sweep.sh [options] [root_directory] | |
| # Source: https://gist.github.com/adrianoluis/5b07cbf5bbc3679efff0b45b9d98c1cd | |
| readonly SCRIPT_NAME="$(basename "$0")" | |
| readonly VERSION="1.3.1" | |
| # ── Defaults ────────────────────────────────────────────────── | |
| GITROOT="." | |
| DO_CLEAN=true | |
| DO_PRUNE=true | |
| DO_GC=true | |
| TIMEOUT=30 | |
| # ── Colors (disabled when not a terminal or NO_COLOR is set) ── | |
| if [[ -t 1 ]] && [[ -z "${NO_COLOR:-}" ]]; then | |
| readonly C_RESET="\033[0m" C_BOLD="\033[1m" C_DIM="\033[2m" | |
| readonly C_GREEN="\033[32m" C_CYAN="\033[36m" C_YELLOW="\033[33m" | |
| readonly C_RED="\033[31m" C_MAGENTA="\033[35m" C_BLUE="\033[1;34m" | |
| else | |
| readonly C_RESET="" C_BOLD="" C_DIM="" | |
| readonly C_GREEN="" C_CYAN="" C_YELLOW="" | |
| readonly C_RED="" C_MAGENTA="" C_BLUE="" | |
| fi | |
| # ── Icons ───────────────────────────────────────────────────── | |
| readonly ICO_STASH="📥 " ICO_PULL="⬇️ " ICO_CLEAN="🧹 " ICO_GC="♻️ " | |
| readonly ICO_PRUNE="💥 " ICO_RESTORE="📤 " ICO_DONE="✅ " | |
| readonly ICO_SKIP="⏭️ " ICO_WARN="⚠️ " | |
| # ── Output helpers ──────────────────────────────────────────── | |
| phase() { printf " ${C_BOLD}%s %b${C_RESET}\n" "$1" "$2"; } | |
| detail() { printf " ${C_DIM}│${C_RESET} ${C_CYAN}%s${C_RESET}\n" "$*"; } | |
| detailx() { printf " ${C_DIM}│${C_RESET} %s\n" "$*"; } | |
| warn() { printf " ${C_YELLOW}${ICO_WARN} %s${C_RESET}\n" "$*" >&2; } | |
| err() { printf " ${C_RED}✖ %s${C_RESET}\n" "$*" >&2; } | |
| tgit() { timeout "${TIMEOUT}" git "$@"; } | |
| # ── Usage ───────────────────────────────────────────────────── | |
| usage() { | |
| cat <<EOF | |
| ${C_BOLD}${SCRIPT_NAME}${C_RESET} v${VERSION} — Sweep through git repos and tidy them up. | |
| ${C_BOLD}USAGE${C_RESET} | |
| $SCRIPT_NAME [options] [directory] | |
| ${C_BOLD}ARGUMENTS${C_RESET} | |
| directory Root directory to scan for git repos (default: .) | |
| ${C_BOLD}OPTIONS${C_RESET} | |
| -h, --help Show this help message and exit | |
| -v, --version Show version and exit | |
| -t, --timeout N Timeout in seconds for git network ops (default: 30) | |
| -C, --no-clean Skip removing untracked files (git clean -df) | |
| -P, --no-prune Skip pruning stale remote-tracking branches | |
| -G, --no-gc Skip garbage collection (git gc) | |
| -c, --clean-only Only run clean (skip prune and gc) | |
| -p, --prune-only Only run prune (skip clean and gc) | |
| -g, --gc-only Only run gc (skip clean and prune) | |
| ${C_BOLD}ENVIRONMENT${C_RESET} | |
| NO_COLOR Set to any value to disable colored output | |
| ${C_BOLD}EXAMPLES${C_RESET} | |
| $SCRIPT_NAME # Sweep current directory, all tasks | |
| $SCRIPT_NAME ~/projects # Sweep a specific directory | |
| $SCRIPT_NAME --no-gc ~/projects # Skip garbage collection | |
| $SCRIPT_NAME -p . # Only fetch, pull, and prune | |
| $SCRIPT_NAME -t 60 ~/projects # Use 60s timeout for network ops | |
| EOF | |
| exit 0 | |
| } | |
| version() { echo "$SCRIPT_NAME v$VERSION"; exit 0; } | |
| # ── Parse arguments ─────────────────────────────────────────── | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| -h|--help) usage ;; | |
| -v|--version) version ;; | |
| -C|--no-clean) DO_CLEAN=false; shift ;; | |
| -P|--no-prune) DO_PRUNE=false; shift ;; | |
| -G|--no-gc) DO_GC=false; shift ;; | |
| -c|--clean-only) DO_CLEAN=true; DO_PRUNE=false; DO_GC=false; shift ;; | |
| -p|--prune-only) DO_CLEAN=false; DO_PRUNE=true; DO_GC=false; shift ;; | |
| -g|--gc-only) DO_CLEAN=false; DO_PRUNE=false; DO_GC=true; shift ;; | |
| -t|--timeout) | |
| [[ -z "${2:-}" || ! "$2" =~ ^[0-9]+$ ]] && { err "--timeout requires a numeric argument"; exit 1; } | |
| TIMEOUT="$2"; shift 2 | |
| ;; | |
| -*) | |
| err "Unknown option: $1" | |
| printf "Run '%s --help' for usage.\n" "$SCRIPT_NAME" >&2 | |
| exit 1 | |
| ;; | |
| *) GITROOT="$1"; shift ;; | |
| esac | |
| done | |
| # ── Validate ────────────────────────────────────────────────── | |
| if [[ ! -d "$GITROOT" ]]; then | |
| err "Directory '$GITROOT' does not exist." | |
| exit 1 | |
| fi | |
| for cmd in git timeout find; do | |
| command -v "$cmd" &>/dev/null || { err "Required command '$cmd' not found."; exit 1; } | |
| done | |
| # ── Display helpers ─────────────────────────────────────────── | |
| repo_header() { | |
| local name="$1" path="$2" branch="$3" | |
| local branch_line="on $branch" | |
| local width=${#name} | |
| (( ${#path} > width )) && width=${#path} | |
| (( ${#branch_line} > width )) && width=${#branch_line} | |
| (( width += 2 )) | |
| (( width < 60 )) && width=60 | |
| local rule | |
| rule=$(printf '─%.0s' $(seq 1 "$width")) | |
| printf "\n${C_GREEN}╭%s╮${C_RESET}\n" "$rule" | |
| printf "${C_GREEN}│${C_RESET} ${C_BOLD}${C_MAGENTA}%s${C_RESET}%*s${C_GREEN}│${C_RESET}\n" \ | |
| "$name" $(( width - ${#name} - 1 )) "" | |
| printf "${C_GREEN}│${C_RESET} ${C_DIM}%s${C_RESET}%*s${C_GREEN}│${C_RESET}\n" \ | |
| "$path" $(( width - ${#path} - 1 )) "" | |
| printf "${C_GREEN}│${C_RESET} ${C_DIM}on${C_RESET} ${C_YELLOW}%s${C_RESET}%*s${C_GREEN}│${C_RESET}\n" \ | |
| "$branch" $(( width - ${#branch_line} - 1 )) "" | |
| printf "${C_GREEN}╰%s╯${C_RESET}\n" "$rule" | |
| } | |
| print_stat_block() { | |
| [[ -z "${1:-}" ]] && return | |
| while IFS= read -r line; do | |
| detail "$line" | |
| done <<< "$1" | |
| } | |
| # ── Discovery spinner ──────────────────────────────────────── | |
| readonly SPINNER_FRAMES=("⠋" "⠙" "⠹" "⠸" "⠼" "⠴" "⠦" "⠧" "⠇" "⠏") | |
| readonly DISCOVERY_PHRASES=( | |
| "Sniffing out .git directories…" | |
| "Peeking under every rock…" | |
| "Following the breadcrumbs…" | |
| "Herding repos into a list…" | |
| "Rummaging through directories…" | |
| "Mapping the repo constellation…" | |
| "Shaking the directory tree…" | |
| "Dusting off forgotten repos…" | |
| "Whispering to the file system…" | |
| "Untangling the folder spaghetti…" | |
| "Consulting the git oracle…" | |
| "Gathering the usual suspects…" | |
| "Repo roll call in progress…" | |
| "Spelunking through subdirectories…" | |
| ) | |
| spinner_pid="" | |
| start_spinner() { | |
| [[ ! -t 1 ]] && return | |
| ( | |
| local f=0 p=0 t=0 | |
| local nf=${#SPINNER_FRAMES[@]} np=${#DISCOVERY_PHRASES[@]} | |
| while true; do | |
| (( t % 20 == 0 )) && p=$(( RANDOM % np )) | |
| printf "\r\033[K ${C_CYAN}%s${C_RESET} ${C_DIM}%s${C_RESET}" \ | |
| "${SPINNER_FRAMES[f]}" "${DISCOVERY_PHRASES[p]}" | |
| f=$(( (f + 1) % nf )) | |
| (( t++ )) || true | |
| sleep 0.1 | |
| done | |
| ) & | |
| spinner_pid=$! | |
| } | |
| stop_spinner() { | |
| [[ -z "${spinner_pid:-}" ]] && return | |
| kill "$spinner_pid" 2>/dev/null | |
| wait "$spinner_pid" 2>/dev/null || true | |
| spinner_pid="" | |
| printf "\r\033[K" | |
| } | |
| # ── Summary file & cleanup ─────────────────────────────────── | |
| # Format: STATUS repo_name commits added modified deleted | |
| summary_file="$(mktemp)" | |
| trap 'stop_spinner; rm -f "$summary_file"' EXIT INT TERM | |
| # ── Discover repos ──────────────────────────────────────────── | |
| start_spinner | |
| repos=() | |
| while IFS= read -r -d '' entry; do | |
| repos+=("$entry") | |
| done < <(find "$GITROOT" -name .git -type d -print0 | sort -z) | |
| repo_total=${#repos[@]} | |
| stop_spinner | |
| if (( repo_total == 0 )); then | |
| printf " ${C_DIM}No git repos found under '%s'.${C_RESET}\n\n" "$GITROOT" | |
| exit 0 | |
| fi | |
| printf " ${C_GREEN}${C_BOLD}Found %d repo(s)${C_RESET} ${C_DIM}under %s${C_RESET}\n" "$repo_total" "$GITROOT" | |
| # ── Main loop ───────────────────────────────────────────────── | |
| start_time="$SECONDS" | |
| for gitdir in "${repos[@]}"; do | |
| repo_dir="$(dirname "$gitdir")" | |
| ( | |
| cd "$repo_dir" || { | |
| err "Cannot cd to '$repo_dir'" | |
| echo "FAIL $(basename "$repo_dir") 0 0 0 0" >> "$summary_file" | |
| exit 1 | |
| } | |
| gitproj="$(pwd -P)" | |
| repo_name="$(basename "$gitproj")" | |
| current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")" | |
| # Per-repo stats | |
| r_commits=0 r_added=0 r_modified=0 r_deleted=0 | |
| repo_header "$repo_name" "$gitproj" "$current_branch" | |
| # ── Default branch detection ───────────────────────── | |
| if ! default_branch="$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null)"; then | |
| warn "origin/HEAD not set — run 'git remote set-head origin -a'. Skipping." | |
| echo "SKIP $repo_name 0 0 0 0" >> "$summary_file" | |
| exit 0 | |
| fi | |
| default_branch="${default_branch#refs/remotes/origin/}" | |
| # ── Snapshot user's untracked files before stash ───── | |
| # We record these so git clean only removes files that | |
| # were NOT part of the user's working tree. | |
| user_untracked="$(git ls-files --others --exclude-standard)" | |
| # ── Stash ──────────────────────────────────────────── | |
| stashed=false | |
| has_changes=false | |
| git diff --quiet HEAD -- 2>/dev/null || has_changes=true | |
| [[ "$has_changes" == false && -n "$user_untracked" ]] && has_changes=true | |
| if $has_changes; then | |
| phase "$ICO_STASH" "Stashing local changes…" | |
| print_stat_block "$(git diff --stat HEAD 2>/dev/null)" | |
| if [[ -n "$user_untracked" ]]; then | |
| detail "Untracked files preserved:" | |
| while IFS= read -r f; do | |
| detail " $f" | |
| done <<< "$user_untracked" | |
| fi | |
| git stash push -m "auto-stash by git-sweep" --include-untracked --quiet | |
| stashed=true | |
| else | |
| phase "$ICO_SKIP" "Working tree clean — nothing to stash." | |
| fi | |
| # ── Switch to default branch ───────────────────────── | |
| switched=false | |
| if [[ "$current_branch" != "$default_branch" ]]; then | |
| phase "$ICO_PULL" "Switching to ${C_YELLOW}$default_branch${C_RESET}" | |
| git checkout "$default_branch" --quiet | |
| switched=true | |
| fi | |
| # ── Pull ───────────────────────────────────────────── | |
| head_before="$(git rev-parse HEAD)" | |
| phase "$ICO_PULL" "Pulling latest (rebase)…" | |
| pull_ok=true | |
| if ! tgit pull --rebase --quiet 2>/dev/null; then | |
| warn "Pull timed out or failed after ${TIMEOUT}s." | |
| pull_ok=false | |
| git rebase --abort 2>/dev/null || true | |
| fi | |
| if $pull_ok; then | |
| head_after="$(git rev-parse HEAD)" | |
| if [[ "$head_before" != "$head_after" ]]; then | |
| r_commits="$(git rev-list --count "$head_before".."$head_after")" | |
| detail "$r_commits new commit(s):" | |
| git log --oneline --no-decorate "$head_before".."$head_after" | while IFS= read -r line; do | |
| detailx " ${C_DIM}${line}${C_RESET}" | |
| done | |
| r_added="$(git diff --diff-filter=A --name-only "$head_before".."$head_after" | wc -l | tr -d ' ')" | |
| r_modified="$(git diff --diff-filter=M --name-only "$head_before".."$head_after" | wc -l | tr -d ' ')" | |
| r_deleted="$(git diff --diff-filter=D --name-only "$head_before".."$head_after" | wc -l | tr -d ' ')" | |
| detail "Files: +${r_added} added, ~${r_modified} modified, -${r_deleted} deleted" | |
| print_stat_block "$(git diff --stat "$head_before".."$head_after")" | |
| else | |
| detail "Already up to date." | |
| fi | |
| fi | |
| # ── Clean (only non-user files) ────────────────────── | |
| # We only remove untracked files that were NOT in the | |
| # user's working tree before the stash. This prevents | |
| # loss of new files the user was working on. | |
| if $DO_CLEAN; then | |
| phase "$ICO_CLEAN" "Cleaning untracked files…" | |
| current_untracked="$(git ls-files --others --exclude-standard)" | |
| if [[ -n "$current_untracked" ]]; then | |
| cleaned_any=false | |
| while IFS= read -r f; do | |
| # Skip files that belonged to the user's working tree | |
| if [[ -n "$user_untracked" ]] && echo "$user_untracked" | grep -qxF "$f"; then | |
| continue | |
| fi | |
| git clean -df -- "$f" &>/dev/null | |
| detail "removed: $f" | |
| cleaned_any=true | |
| done <<< "$current_untracked" | |
| if ! $cleaned_any; then | |
| detail "Nothing to clean (user files preserved)." | |
| fi | |
| else | |
| detail "Nothing to clean." | |
| fi | |
| fi | |
| # ── Garbage collection ─────────────────────────────── | |
| if $DO_GC; then | |
| phase "$ICO_GC" "Running garbage collection…" | |
| git gc --auto --quiet 2>/dev/null | |
| fi | |
| # ── Prune ──────────────────────────────────────────── | |
| if $DO_PRUNE; then | |
| phase "$ICO_PRUNE" "Pruning stale remote branches…" | |
| if ! prune_output="$(tgit remote prune origin 2>&1)"; then | |
| warn "Prune timed out or failed after ${TIMEOUT}s — skipping." | |
| else | |
| pruned_lines="$(echo "$prune_output" | grep '\[pruned\]' || true)" | |
| if [[ -n "$pruned_lines" ]]; then | |
| while IFS= read -r line; do | |
| detail "removed → ${line##*origin/}" | |
| done <<< "$pruned_lines" | |
| else | |
| detail "Nothing to prune." | |
| fi | |
| fi | |
| fi | |
| # ── Restore original state ─────────────────────────── | |
| if $switched; then | |
| phase "$ICO_RESTORE" "Switching back to ${C_YELLOW}$current_branch${C_RESET}" | |
| git checkout "$current_branch" --quiet | |
| fi | |
| if $stashed; then | |
| phase "$ICO_RESTORE" "Restoring stashed changes…" | |
| if ! git stash pop --quiet 2>/dev/null; then | |
| warn "Stash pop conflict — changes preserved in 'git stash list'." | |
| else | |
| print_stat_block "$(git diff --stat HEAD 2>/dev/null)" | |
| fi | |
| fi | |
| phase "$ICO_DONE" "Sweep complete." | |
| echo "OK ${repo_name} ${r_commits} ${r_added} ${r_modified} ${r_deleted}" >> "$summary_file" | |
| ) | |
| done | |
| # ── Final summary ───────────────────────────────────────────── | |
| elapsed=$(( SECONDS - start_time )) | |
| mins=$(( elapsed / 60 )) | |
| secs=$(( elapsed % 60 )) | |
| elapsed_str=$(( mins > 0 )) && elapsed_str="${mins}m ${secs}s" || elapsed_str="${secs}s" | |
| # Parse summary file | |
| ok_count=0 skip_count=0 fail_count=0 | |
| ok_repos=() skip_repos=() fail_repos=() | |
| total_commits=0 total_added=0 total_modified=0 total_deleted=0 | |
| while IFS=' ' read -r status name commits added modified deleted; do | |
| case "$status" in | |
| OK) | |
| (( ok_count++ )) || true | |
| ok_repos+=("$name") | |
| (( total_commits += ${commits:-0} )) || true | |
| (( total_added += ${added:-0} )) || true | |
| (( total_modified += ${modified:-0} )) || true | |
| (( total_deleted += ${deleted:-0} )) || true | |
| ;; | |
| SKIP) (( skip_count++ )) || true; skip_repos+=("$name") ;; | |
| FAIL) (( fail_count++ )) || true; fail_repos+=("$name") ;; | |
| esac | |
| done < "$summary_file" | |
| total=$(( ok_count + skip_count + fail_count )) | |
| printf "\n${C_GREEN}╭────────────────────────────────────────────────────────────╮${C_RESET}\n" | |
| printf "${C_GREEN}│${C_RESET} ${C_BOLD}Sweep Summary${C_RESET}%46s${C_GREEN}│${C_RESET}\n" "" | |
| printf "${C_GREEN}╰────────────────────────────────────────────────────────────╯${C_RESET}\n" | |
| printf " ${C_DIM}Duration:${C_RESET} %s\n" "$elapsed_str" | |
| if (( total > 1 )); then | |
| printf " ${C_DIM}Scanned:${C_RESET} %d repo(s)\n" "$total" | |
| fi | |
| # Aggregate stats | |
| if (( total_commits + total_added + total_modified + total_deleted > 0 )); then | |
| printf "\n ${C_BOLD}Changes across all repos:${C_RESET}\n" | |
| (( total_commits > 0 )) && printf " ${C_BLUE}↓${C_RESET} %d commit(s) pulled\n" "$total_commits" | |
| (( total_added > 0 )) && printf " ${C_GREEN}+${C_RESET} %d file(s) added\n" "$total_added" | |
| (( total_modified > 0 )) && printf " ${C_YELLOW}~${C_RESET} %d file(s) modified\n" "$total_modified" | |
| (( total_deleted > 0 )) && printf " ${C_RED}-${C_RESET} %d file(s) deleted\n" "$total_deleted" | |
| fi | |
| # Per-repo breakdown (only for multiple repos) | |
| if (( total > 1 )); then | |
| if (( ok_count > 0 )); then | |
| printf "\n ${C_GREEN}✅ Swept (%d):${C_RESET}\n" "$ok_count" | |
| while IFS=' ' read -r status name commits added modified deleted; do | |
| [[ "$status" != "OK" ]] && continue | |
| stats="" | |
| (( ${commits:-0} > 0 )) && stats+="${commits} commit(s)" | |
| (( ${added:-0} > 0 )) && { [[ -n "$stats" ]] && stats+=", "; stats+="+${added}"; } | |
| (( ${modified:-0}> 0 )) && { [[ -n "$stats" ]] && stats+=", "; stats+="~${modified}"; } | |
| (( ${deleted:-0} > 0 )) && { [[ -n "$stats" ]] && stats+=", "; stats+="-${deleted}"; } | |
| if [[ -n "$stats" ]]; then | |
| printf " ${C_DIM}•${C_RESET} %-30s ${C_DIM}%s${C_RESET}\n" "$name" "$stats" | |
| else | |
| printf " ${C_DIM}•${C_RESET} %-30s ${C_DIM}up to date${C_RESET}\n" "$name" | |
| fi | |
| done < "$summary_file" | |
| fi | |
| if (( skip_count > 0 )); then | |
| printf "\n ${C_YELLOW}⏭️ Skipped (%d):${C_RESET}\n" "$skip_count" | |
| for r in "${skip_repos[@]}"; do printf " ${C_DIM}•${C_RESET} %s\n" "$r"; done | |
| fi | |
| if (( fail_count > 0 )); then | |
| printf "\n ${C_RED}✖ Failed (%d):${C_RESET}\n" "$fail_count" | |
| for r in "${fail_repos[@]}"; do printf " ${C_DIM}•${C_RESET} %s\n" "$r"; done | |
| fi | |
| fi | |
| printf "\n" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment