Skip to content

Instantly share code, notes, and snippets.

@psiborg
Last active July 14, 2026 18:35
Show Gist options
  • Select an option

  • Save psiborg/81bfdd9df33725592bbb39797bf76f32 to your computer and use it in GitHub Desktop.

Select an option

Save psiborg/81bfdd9df33725592bbb39797bf76f32 to your computer and use it in GitHub Desktop.
Update repos from GitHub
#!/usr/bin/env bash
#
# repos_update.sh — pull the latest from GitHub for every repo in this folder.
#
# Drop this in your GitHub root (e.g. /home/<user>/GitHub) and run it. It walks
# each immediate sub-directory and, for anything that is a git repository, brings
# the checked-out branch up to date with its upstream.
#
# SAFE BY DEFAULT: only fast-forwards, and SKIPS any repo with uncommitted
# changes instead of clobbering your work. Use --force to hard-reset to the
# remote (this DISCARDS local commits and uncommitted changes to tracked files).
#
# Usage:
# ./repos_update.sh [options] [directory]
#
# Options:
# -f, --force Hard-reset each repo to its upstream, discarding local work.
# -c, --clean With --force, also remove untracked files/dirs (git clean -fd).
# -n, --dry-run Show what would happen without changing anything.
# -h, --help Show this help and exit.
#
# Exits non-zero if any repo could not be updated.
set -uo pipefail
# --- Resolve the directory this script lives in (default target) -------------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- Options -----------------------------------------------------------------
FORCE=0
CLEAN=0
DRY_RUN=0
TARGET=""
usage() { sed -n '2,22p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
while [[ $# -gt 0 ]]; do
case "$1" in
-f|--force) FORCE=1 ;;
-c|--clean) CLEAN=1 ;;
-n|--dry-run) DRY_RUN=1 ;;
-h|--help) usage; exit 0 ;;
-*) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;;
*) TARGET="$1" ;;
esac
shift
done
GITHUB_DIR="${TARGET:-$SCRIPT_DIR}"
if [[ ! -d "$GITHUB_DIR" ]]; then
echo "Directory not found: $GITHUB_DIR" >&2
exit 2
fi
# --- Colors (only when writing to a real terminal) ---------------------------
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ -n "${TERM:-}" ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0); DIM=$(tput dim)
RED=$(tput setaf 1); GREEN=$(tput setaf 2)
YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4)
else
BOLD=""; RESET=""; DIM=""; RED=""; GREEN=""; YELLOW=""; BLUE=""
fi
# --- Counters ----------------------------------------------------------------
n_updated=0 n_uptodate=0 n_skipped=0 n_failed=0
# --- Report helper: status label + repo name + detail ------------------------
report() { # $1=color $2=label $3=name $4=detail
printf ' %s%-9s%s %s%-30s%s %s\n' \
"$1" "$2" "$RESET" "$BOLD" "$3" "$RESET" "${4:-}"
}
# --- Process a single repo ---------------------------------------------------
process_repo() {
local dir="$1"
local name; name="$(basename "$dir")"
local git=(git -C "$dir")
# Must be a git repo ROOT of its own, not a plain folder that merely sits
# inside some parent repo. Checking for a .git entry directly in the folder
# avoids git's "walk up the tree" behaviour, so a subfolder can never be
# misattributed to a repo higher up (e.g. a dotfiles repo over $HOME).
# -e covers both a normal repo (.git dir) and worktrees/submodules (.git file).
if [[ ! -e "$dir/.git" ]]; then
report "$DIM" "skip" "$name" "${DIM}not a git repo${RESET}"
((n_skipped++)); return
fi
# Sanity check: the .git exists but is it a valid repo?
if ! "${git[@]}" rev-parse --git-dir >/dev/null 2>&1; then
report "$YELLOW" "skip" "$name" "has a .git but isn't a valid repo"
((n_skipped++)); return
fi
# Current branch (fails on detached HEAD)
local branch
if ! branch="$("${git[@]}" symbolic-ref --quiet --short HEAD 2>/dev/null)"; then
report "$YELLOW" "skip" "$name" "detached HEAD — leaving as-is"
((n_skipped++)); return
fi
# Upstream tracking branch
local upstream
if ! upstream="$("${git[@]}" rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null)"; then
report "$YELLOW" "skip" "$name" "branch '$branch' has no upstream"
((n_skipped++)); return
fi
# Fetch latest refs
if ! "${git[@]}" fetch --quiet --prune 2>/dev/null; then
report "$RED" "error" "$name" "fetch failed (network/auth?)"
((n_failed++)); return
fi
local before remote
before="$("${git[@]}" rev-parse HEAD)"
remote="$("${git[@]}" rev-parse '@{u}')"
# Already current
if [[ "$before" == "$remote" ]]; then
report "$GREEN" "ok" "$name" "${DIM}up to date ($branch)${RESET}"
((n_uptodate++)); return
fi
local incoming
incoming="$("${git[@]}" rev-list --count "$before..$remote")"
# Dirty working tree? (tracked changes, staged or unstaged)
local dirty=0
if ! "${git[@]}" diff --quiet || ! "${git[@]}" diff --cached --quiet; then
dirty=1
fi
# --- Dry run: describe intent, change nothing ------------------------------
if (( DRY_RUN )); then
if (( FORCE )); then
report "$BLUE" "would" "$name" "reset $branch to $upstream ($incoming behind)$([[ $dirty == 1 ]] && echo ' — discards local changes')"
elif (( dirty )); then
report "$YELLOW" "would" "$name" "SKIP — $incoming behind but has local changes"
else
report "$BLUE" "would" "$name" "fast-forward $incoming commit(s)"
fi
return
fi
# --- Force: hard reset to upstream -----------------------------------------
if (( FORCE )); then
if "${git[@]}" reset --hard --quiet "$upstream"; then
(( CLEAN )) && "${git[@]}" clean -fd --quiet
report "$GREEN" "reset" "$name" "$upstream ($incoming applied)"
((n_updated++))
else
report "$RED" "error" "$name" "reset failed"
((n_failed++))
fi
return
fi
# --- Safe path: skip if dirty, else fast-forward only ----------------------
if (( dirty )); then
report "$YELLOW" "skip" "$name" "$incoming behind, but you have local changes (use --force to overwrite)"
((n_skipped++)); return
fi
if "${git[@]}" merge --ff-only --quiet "$upstream" 2>/dev/null; then
report "$GREEN" "pulled" "$name" "+$incoming commit(s) on $branch"
((n_updated++))
else
report "$RED" "diverged" "$name" "local & remote diverged — needs manual merge/rebase"
((n_failed++))
fi
}
# --- Main --------------------------------------------------------------------
printf '%sUpdating repos in%s %s\n' "$BOLD" "$RESET" "$GITHUB_DIR"
(( FORCE )) && printf '%s force mode: local changes will be discarded%s\n' "$RED" "$RESET"
(( DRY_RUN )) && printf '%s dry run: no changes will be made%s\n' "$BLUE" "$RESET"
echo
shopt -s nullglob
for dir in "$GITHUB_DIR"/*/; do
process_repo "${dir%/}"
done
shopt -u nullglob
# --- Summary -----------------------------------------------------------------
echo
printf '%sSummary:%s %s%d updated%s, %d up to date, %s%d skipped%s, %s%d failed%s\n' \
"$BOLD" "$RESET" \
"$GREEN" "$n_updated" "$RESET" \
"$n_uptodate" \
"$YELLOW" "$n_skipped" "$RESET" \
"$RED" "$n_failed" "$RESET"
(( n_failed > 0 )) && exit 1
exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment