Skip to content

Instantly share code, notes, and snippets.

@adrianoluis
Last active March 27, 2026 21:36
Show Gist options
  • Select an option

  • Save adrianoluis/5b07cbf5bbc3679efff0b45b9d98c1cd to your computer and use it in GitHub Desktop.

Select an option

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