Last active
July 14, 2026 18:35
-
-
Save psiborg/81bfdd9df33725592bbb39797bf76f32 to your computer and use it in GitHub Desktop.
Update repos from GitHub
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 | |
| # | |
| # 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