Skip to content

Instantly share code, notes, and snippets.

@awadhwana
Last active June 17, 2026 12:56
Show Gist options
  • Select an option

  • Save awadhwana/b68dea145f50ff3e681a6f39a3362bc2 to your computer and use it in GitHub Desktop.

Select an option

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