#!/usr/bin/env bash # # Update one git repository per line in a list file. # Prompts once for a username and password and uses them for HTTPS remotes. # Continues after a failure and prints a summary when the batch finishes. # # The list path is required (--repos). Relative paths are resolved against # the directory that contains that file, not the caller's cwd. set -euo pipefail IFS=$'\n\t' REPOS_FILE="" GIT_USER="" GIT_PASS="" ASKPASS_FILE="" # ---------- logging ---------- if [[ -t 1 ]]; then readonly C_INFO=$'\033[0;36m' readonly C_OK=$'\033[0;32m' readonly C_WARN=$'\033[0;33m' readonly C_ERR=$'\033[0;31m' readonly C_RESET=$'\033[0m' else readonly C_INFO="" C_OK="" C_WARN="" C_ERR="" C_RESET="" fi log() { printf '%s[INFO]%s %s\n' "$C_INFO" "$C_RESET" "$*"; } ok() { printf '%s[OK]%s %s\n' "$C_OK" "$C_RESET" "$*"; } warn() { printf '%s[WARN]%s %s\n' "$C_WARN" "$C_RESET" "$*" >&2; } err() { printf '%s[ERROR]%s %s\n' "$C_ERR" "$C_RESET" "$*" >&2; } die() { err "$*"; exit 1; } cleanup() { GIT_USER="" GIT_PASS="" unset GIT_USER GIT_PASS if [[ -n "${ASKPASS_FILE:-}" && -f "$ASKPASS_FILE" ]]; then rm -f "$ASKPASS_FILE" fi ASKPASS_FILE="" } on_error() { local exit_code=$? err "Script aborted on line ${BASH_LINENO[0]} (exit ${exit_code})." exit "$exit_code" } trap cleanup EXIT trap on_error ERR # ---------- helpers ---------- usage() { cat </dev/tty [[ -n "$user" && -n "$pass" ]] || die "Username and password are required." GIT_USER="$user" GIT_PASS="$pass" } write_askpass() { ASKPASS_FILE="$(mktemp "${TMPDIR:-/tmp}/git-update-askpass.XXXXXX")" cat >"$ASKPASS_FILE" <<'EOF' #!/bin/sh prompt=$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]') case "$prompt" in *username*) printf '%s\n' "${GIT_UPDATE_USER:-}" ;; *password*) printf '%s\n' "${GIT_UPDATE_PASS:-}" ;; *) exit 1 ;; esac EOF chmod 700 "$ASKPASS_FILE" } remote_uses_http() { local repo="$1" local upstream="" remote="" url="" upstream="$(git -C "$repo" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true)" [[ -n "$upstream" && "$upstream" == */* ]] || return 1 remote="${upstream%%/*}" url="$(git -C "$repo" remote get-url "$remote" 2>/dev/null || true)" [[ "$url" == http://* || "$url" == https://* ]] } pull_repo() { local repo="$1" if remote_uses_http "$repo"; then GIT_ASKPASS="$ASKPASS_FILE" \ GIT_TERMINAL_PROMPT=0 \ GIT_UPDATE_USER="$GIT_USER" \ GIT_UPDATE_PASS="$GIT_PASS" \ git -C "$repo" -c credential.helper= pull else git -C "$repo" pull fi } # ---------- arg parsing ---------- while [[ $# -gt 0 ]]; do case "$1" in --repos) require_value "$@" REPOS_FILE="$2" shift 2 ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1 (use --help)" ;; esac done [[ -n "$REPOS_FILE" ]] || die "Missing --repos FILE (use --help)." [[ -f "$REPOS_FILE" ]] || die "Repository list not found: ${REPOS_FILE}" REPOS_FILE="$(readlink -f "$REPOS_FILE")" REPOS_DIR="$(cd "$(dirname "$REPOS_FILE")" && pwd)" repos=() while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do line="$(trim "$raw_line")" [[ -z "$line" || "$line" == \#* ]] && continue line="${line/#\~/$HOME}" repos+=("$line") done < "$REPOS_FILE" [[ ${#repos[@]} -gt 0 ]] || die "Repository list is empty: ${REPOS_FILE}" prompt_credentials write_askpass total="${#repos[@]}" index=0 ok_count=0 fail_count=0 result_paths=() result_notes=() log "Repository list: ${REPOS_FILE}" log "Repositories: ${total}" for entry in "${repos[@]}"; do index=$((index + 1)) resolved="$(normalize_path "$(resolve_repo "$entry")")" if [[ -d "$resolved" ]]; then resolved="$(cd "$resolved" && pwd)" fi printf '\n%s========== [%s/%s] %s ==========%s\n' \ "$C_INFO" "$index" "$total" "$resolved" "$C_RESET" if [[ ! -d "$resolved" ]]; then err "Directory not found: ${resolved}" result_paths+=("$resolved") result_notes+=("not found") fail_count=$((fail_count + 1)) continue fi toplevel="$(git -C "$resolved" rev-parse --show-toplevel 2>/dev/null || true)" if [[ -z "$toplevel" || "$(cd "$resolved" && pwd -P)" != "$(cd "$toplevel" && pwd -P)" ]]; then err "Not a git work tree: ${resolved}" result_paths+=("$resolved") result_notes+=("not a git work tree") fail_count=$((fail_count + 1)) continue fi exit_code=0 if pull_repo "$resolved"; then ok "Updated ${resolved}" result_paths+=("$resolved") result_notes+=("0") ok_count=$((ok_count + 1)) else exit_code=$? err "Failed ${resolved} (exit ${exit_code})" result_paths+=("$resolved") result_notes+=("exit ${exit_code}") fail_count=$((fail_count + 1)) fi done printf '\n%s========== Summary ==========%s\n' "$C_INFO" "$C_RESET" summary_index=0 while [[ "$summary_index" -lt "$total" ]]; do note="${result_notes[$summary_index]}" path="${result_paths[$summary_index]}" if [[ "$note" == "0" ]]; then ok "${path}" else err "${path} (${note})" fi summary_index=$((summary_index + 1)) done if [[ "$fail_count" -gt 0 ]]; then err "${ok_count} updated, ${fail_count} failed." exit 1 fi ok "${ok_count} updated, 0 failed."