Last active
June 17, 2026 12:56
-
-
Save awadhwana/b68dea145f50ff3e681a6f39a3362bc2 to your computer and use it in GitHub Desktop.
Keep a fleet of git repos up to date overnight: fast-forward base branches, stash-safe, locked, with macOS notifications
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 | |
| # | |
| # update-all-repos.sh — bring every git repo in a folder up to date, unattended. | |
| # | |
| # For each immediate subdirectory of REPOS_DIR that is a git repo: | |
| # - detached HEAD → skip | |
| # - on the base branch → git pull --rebase | |
| # - on a feature branch → fast-forward local base via `git fetch origin base:base` | |
| # (no checkout); falls back to stash/checkout/rebase if it diverged | |
| # | |
| # Configure via environment variables (no need to edit this file): | |
| # REPOS_DIR folder whose subdirs are git repos (default: this script's own directory) | |
| # PARALLEL number of repos to update at once (default: 1) | |
| # LOG_DIR where last-run.log / prev-run.log go (default: ~/.cache/update-all-repos) | |
| # | |
| # Examples: | |
| # REPOS_DIR=~/code ./update-all-repos.sh | |
| # PARALLEL=4 REPOS_DIR=~/code ./update-all-repos.sh | |
| # | |
| # Requires: macOS (osascript notifications), git, curl, bash 3.2+ (bash 4+ enables `wait -n`). | |
| # | |
| set -uo pipefail | |
| SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" | |
| REPOS_DIR="${REPOS_DIR:-$SCRIPT_DIR}" | |
| MAX_RETRIES=5 | |
| RETRY_INTERVAL=10 | |
| failures=() | |
| warnings=() | |
| LOG_DIR="${LOG_DIR:-$HOME/.cache/update-all-repos}" | |
| LOCK_DIR="$LOG_DIR/lock" | |
| mkdir -p "$LOG_DIR" || { echo "Cannot create $LOG_DIR"; exit 1; } | |
| # Acquire lock BEFORE log rotation/redirection so concurrent runs cannot clobber logs | |
| if ! mkdir "$LOCK_DIR" 2>/dev/null; then | |
| echo "Another run in progress (lock: $LOCK_DIR). Aborting." | |
| exit 1 | |
| fi | |
| cleanup() { rmdir "$LOCK_DIR" 2>/dev/null; } | |
| trap cleanup EXIT INT TERM | |
| # Rotate previous log, tee output to new log | |
| [ -f "$LOG_DIR/last-run.log" ] && mv "$LOG_DIR/last-run.log" "$LOG_DIR/prev-run.log" | |
| exec > >(tee "$LOG_DIR/last-run.log") 2>&1 | |
| # Validate PARALLEL | |
| PARALLEL="${PARALLEL:-1}" | |
| if ! [[ "$PARALLEL" =~ ^[0-9]+$ ]] || [ "$PARALLEL" -lt 1 ]; then | |
| echo "PARALLEL must be a positive integer (got: $PARALLEL). Defaulting to 1." | |
| PARALLEL=1 | |
| fi | |
| # Detect wait -n support (bash 4+) — avoids stderr spam on macOS default bash 3.2 | |
| HAVE_WAIT_N=0 | |
| if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then | |
| HAVE_WAIT_N=1 | |
| fi | |
| default_branch() { | |
| local ref | |
| ref="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)" || { | |
| git remote set-head origin --auto >/dev/null 2>&1 || return 1 | |
| ref="$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/dev/null)" || return 1 | |
| } | |
| echo "${ref#refs/remotes/origin/}" | |
| } | |
| notify() { | |
| local title="$1" message="$2" | |
| osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null | |
| } | |
| wait_for_network() { | |
| for i in $(seq 1 "$MAX_RETRIES"); do | |
| # HEAD only (-I): fetch headers, skip the ~500KB body so a cold/slow first | |
| # request doesn't blow the timeout and report a false "no network". | |
| if curl -sI --max-time 10 https://github.com > /dev/null 2>&1; then | |
| return 0 | |
| fi | |
| echo "Waiting for network... (attempt $i/$MAX_RETRIES)" | |
| sleep "$RETRY_INTERVAL" | |
| done | |
| return 1 | |
| } | |
| update_repo() ( | |
| # Subshell — cd does not leak to caller | |
| set -uo pipefail | |
| local repo="$1" | |
| local name; name="$(basename "$repo")" | |
| local current_branch base_branch stash_needed=false | |
| echo "━━━ $name ━━━" | |
| cd "$repo" || { echo " FAILED: cd $repo"; return 1; } | |
| current_branch="$(git rev-parse --abbrev-ref HEAD)" | |
| if [ "$current_branch" = "HEAD" ]; then | |
| echo " SKIP: detached HEAD" | |
| return 0 | |
| fi | |
| base_branch="$(default_branch)" || { echo " FAILED: cannot detect default branch"; return 1; } | |
| if [ "$current_branch" = "$base_branch" ]; then | |
| echo " On $base_branch — pulling with rebase" | |
| git pull --rebase || { echo " FAILED: rebase on $base_branch"; return 1; } | |
| echo "" | |
| return 0 | |
| fi | |
| # Fast path: fast-forward local base ref without working tree swap. | |
| # Works when local base has not diverged from origin (the common case). | |
| echo " Fast-forwarding $base_branch from origin (no checkout)" | |
| if git fetch origin "$base_branch:$base_branch"; then | |
| echo "" | |
| return 0 | |
| fi | |
| echo " Fast-forward refused (local $base_branch diverged) — falling back to checkout method" | |
| # Stash uncommitted changes (tracked + untracked) | |
| if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then | |
| stash_needed=true | |
| echo " Stashing changes on $current_branch" | |
| git stash push -u -m "auto-stash before rebase $(date +%F)" || { echo " FAILED: stash"; return 1; } | |
| fi | |
| echo " Switching to $base_branch" | |
| if ! git checkout "$base_branch" || ! git pull --rebase; then | |
| echo " FAILED: rebase on $base_branch — switching back to $current_branch" | |
| git checkout "$current_branch" 2>/dev/null | |
| [ "$stash_needed" = true ] && git stash pop 2>/dev/null | |
| return 1 | |
| fi | |
| echo " Switching back to $current_branch" | |
| git checkout "$current_branch" || { echo " FAILED: checkout $current_branch"; return 1; } | |
| if [ "$stash_needed" = true ]; then | |
| echo " Restoring stashed changes" | |
| if ! git stash pop; then | |
| echo " WARNING: stash pop failed — resolve manually (git stash list)" | |
| return 2 # distinct code: rebase OK, stash needs manual attention | |
| fi | |
| fi | |
| echo "" | |
| ) | |
| if ! wait_for_network; then | |
| notify "Daily Rebase" "No network — aborted." | |
| echo "No network after $((MAX_RETRIES * RETRY_INTERVAL))s — aborting." | |
| exit 1 | |
| fi | |
| repos=() | |
| for repo in "$REPOS_DIR"/*/; do | |
| [ -d "$repo/.git" ] && repos+=("$repo") | |
| done | |
| if [ "$PARALLEL" -le 1 ] || [ "${#repos[@]}" -le 1 ]; then | |
| for repo in "${repos[@]}"; do | |
| update_repo "$repo" | |
| case $? in | |
| 0) ;; | |
| 2) warnings+=("$(basename "$repo")") ;; | |
| *) failures+=("$(basename "$repo")") ;; | |
| esac | |
| done | |
| else | |
| echo "Running with PARALLEL=$PARALLEL" | |
| tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/update-all-repos.XXXXXX")" | |
| trap 'rm -rf "$tmpdir"; cleanup' EXIT INT TERM | |
| i=0 | |
| for repo in "${repos[@]}"; do | |
| i=$((i+1)) | |
| (update_repo "$repo" > "$tmpdir/$i.out" 2>&1; echo $? > "$tmpdir/$i.rc") & | |
| # Throttle to PARALLEL concurrent jobs | |
| while [ "$(jobs -rp | wc -l)" -ge "$PARALLEL" ]; do | |
| if [ "$HAVE_WAIT_N" = 1 ]; then | |
| wait -n | |
| else | |
| sleep 0.2 | |
| fi | |
| done | |
| done | |
| wait | |
| # Print captured output in original order, collect statuses | |
| i=0 | |
| for repo in "${repos[@]}"; do | |
| i=$((i+1)) | |
| cat "$tmpdir/$i.out" | |
| rc="$(cat "$tmpdir/$i.rc" 2>/dev/null || echo 1)" | |
| case "$rc" in | |
| 0) ;; | |
| 2) warnings+=("$(basename "$repo")") ;; | |
| *) failures+=("$(basename "$repo")") ;; | |
| esac | |
| done | |
| fi | |
| if [ ${#failures[@]} -gt 0 ] || [ ${#warnings[@]} -gt 0 ]; then | |
| msg="" | |
| [ ${#failures[@]} -gt 0 ] && msg="Failed: ${failures[*]}" | |
| [ ${#warnings[@]} -gt 0 ] && msg="${msg:+$msg | }Stash conflicts: ${warnings[*]}" | |
| notify "Daily Rebase" "$msg" | |
| echo "$msg" | |
| exit 1 | |
| fi | |
| notify "Daily Rebase" "All repos updated successfully." | |
| echo "Done — all repos updated." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment