#!/usr/bin/env bash # # sync-repos - Sync all GitHub organizations and their repos locally # # Clones new repos and pulls updates (ff-only) for existing ones. # Config: ~/.config/sync-repos/orgs.conf # set -uo pipefail CONFIG_FILE="${SYNC_REPOS_CONFIG:-$HOME/.config/sync-repos/orgs.conf}" GITHUB_ORGS_DIR="${GITHUB_ORGS_DIR:-$HOME/GithubOrgs}" CLONE_TIMEOUT="${CLONE_TIMEOUT:-300}" # seconds (5 min for large repos) # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[0;33m' BLUE='\033[0;34m' BOLD='\033[1m' NC='\033[0m' # Counters total_cloned=0 total_updated=0 total_skipped=0 total_failed=0 declare -a failed_repos=() log_info() { echo -e "${BLUE}→${NC} $*"; } log_success() { echo -e "${GREEN}✓${NC} $*"; } log_warn() { echo -e "${YELLOW}⚠${NC} $*"; } log_error() { echo -e "${RED}✗${NC} $*"; } log_header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════${NC}"; echo -e "${BOLD} $*${NC}"; echo -e "${BOLD}${BLUE}══════════════════════════════════════${NC}"; } check_requirements() { if ! command -v gh &> /dev/null; then log_error "gh CLI is not installed. Install from: https://cli.github.com/" exit 1 fi if ! gh auth status &> /dev/null; then log_error "Not authenticated with GitHub. Run: gh auth login" exit 1 fi if [[ ! -f "$CONFIG_FILE" ]]; then log_error "Config file not found: $CONFIG_FILE" exit 1 fi } sync_submodules() { local repo_path="$1" local repo="$2" [[ -f "$repo_path/.gitmodules" ]] || return 0 ( cd "$repo_path" 2>/dev/null || exit 0 # Prefix `-` = uninitialized, `+` = checked-out commit differs from index. # Either means `submodule update --init --recursive` has work to do. if git submodule status --recursive 2>/dev/null | grep -qE '^[-+]'; then log_info " $repo: syncing submodules..." if timeout "$CLONE_TIMEOUT" git submodule update --init --recursive 2>&1 >/dev/null; then log_success " $repo: submodules initialized" else log_warn " $repo: submodule update failed" fi fi ) } sync_repo() { local github_org="$1" local repo="$2" local org_path="$3" local repo_path="${org_path}/${repo}" # Case 1: Valid git repo exists - try to update if [[ -d "$repo_path/.git" ]]; then # Use subshell to avoid cd pollution, capture result local result result=$( cd "$repo_path" 2>/dev/null || { echo "invalid"; exit 0; } # Verify it's a valid git repo with a HEAD if ! git rev-parse HEAD &>/dev/null; then echo "invalid" exit 0 fi # Check for uncommitted changes (staged or unstaged) if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then echo "dirty" exit 0 fi # Try fast-forward pull if pull_output=$(timeout 30 git pull --ff-only 2>&1); then if [[ "$pull_output" == *"Already up to date"* ]]; then echo "uptodate" else echo "updated" fi else echo "diverged" fi ) case "$result" in invalid) log_warn "$repo (invalid git repo, skipping)" ((total_skipped++)) || true ;; dirty) log_warn "$repo (uncommitted changes, skipping)" ((total_skipped++)) || true ;; uptodate) log_success "$repo (up to date)" sync_submodules "$repo_path" "$repo" ;; updated) log_success "$repo (updated)" ((total_updated++)) || true sync_submodules "$repo_path" "$repo" ;; diverged) log_warn "$repo (can't fast-forward, skipping)" ((total_skipped++)) || true ;; *) log_warn "$repo (unknown state: $result)" ((total_skipped++)) || true ;; esac return 0 # Case 2: Directory exists but no .git elif [[ -d "$repo_path" ]]; then if [[ -z "$(ls -A "$repo_path" 2>/dev/null)" ]]; then log_info "$repo (empty dir, removing and cloning...)" rmdir "$repo_path" else log_warn "$repo (exists but not a git repo)" ((total_skipped++)) || true return 0 fi fi # Case 3: Clone new repo with retry logic local ssh_url="git@github.com:${github_org}/${repo}.git" local max_retries=3 local attempt=1 local clone_success=false while [[ $attempt -le $max_retries ]] && [[ "$clone_success" == "false" ]]; do # Clean up any partial clone [[ -d "$repo_path" ]] && rm -rf "$repo_path" if [[ $attempt -eq 1 ]]; then log_info "Cloning $repo (SSH)..." else log_info "Cloning $repo (retry $attempt/$max_retries)..." fi # Try full clone first, then shallow on final attempt local clone_args="--progress" if [[ $attempt -eq $max_retries ]]; then clone_args="--progress --depth 1" log_info "(trying shallow clone...)" fi if timeout "$CLONE_TIMEOUT" git clone $clone_args "$ssh_url" "$repo_path" 2>&1; then if [[ -d "$repo_path/.git" ]]; then if [[ $attempt -eq $max_retries ]]; then log_success "$repo (cloned shallow)" else log_success "$repo (cloned)" fi ((total_cloned++)) || true clone_success=true sync_submodules "$repo_path" "$repo" fi else local exit_code=$? if [[ -d "$repo_path/.git" ]]; then log_success "$repo (cloned)" ((total_cloned++)) || true clone_success=true sync_submodules "$repo_path" "$repo" elif [[ $exit_code -eq 124 ]]; then log_warn "$repo (attempt $attempt timed out)" else log_warn "$repo (attempt $attempt failed)" fi fi ((attempt++)) || true done if [[ "$clone_success" == "false" ]]; then log_error "$repo (clone failed after $max_retries attempts)" failed_repos+=("${github_org}/${repo}") ((total_failed++)) || true fi } sync_org() { local local_dir="$1" local org_spec="$2" local org_path="${GITHUB_ORGS_DIR}/${local_dir}" # Parse org:repo1,repo2 format local github_org specific_repos if [[ "$org_spec" == *:* ]]; then github_org="${org_spec%%:*}" specific_repos="${org_spec#*:}" else github_org="$org_spec" specific_repos="" fi log_header "$local_dir ← $github_org" # Create org directory if needed if [[ ! -d "$org_path" ]]; then log_info "Creating directory: $org_path" mkdir -p "$org_path" fi local repos if [[ -n "$specific_repos" ]]; then # Use specific repos from config repos=$(echo "$specific_repos" | tr ',' '\n') local repo_count repo_count=$(echo "$repos" | wc -l) log_info "Syncing $repo_count specific repositories" else # Fetch all repos from GitHub log_info "Fetching repository list..." if ! repos=$(gh repo list "$github_org" --limit 1000 --json name --jq '.[].name' 2>&1); then log_error "Failed to list repos for $github_org: $repos" return 1 fi if [[ -z "$repos" ]]; then log_warn "No repositories found for $github_org" return 0 fi local repo_count repo_count=$(echo "$repos" | wc -l) log_info "Found $repo_count repositories" fi echo "" local cloned=0 updated=0 skipped=0 failed=0 local before_cloned=$total_cloned local before_updated=$total_updated local before_skipped=$total_skipped local before_failed=$total_failed while IFS= read -r repo; do [[ -z "$repo" ]] && continue sync_repo "$github_org" "$repo" "$org_path" done <<< "$repos" cloned=$((total_cloned - before_cloned)) updated=$((total_updated - before_updated)) skipped=$((total_skipped - before_skipped)) failed=$((total_failed - before_failed)) echo "" echo " Cloned: $cloned | Updated: $updated | Skipped: $skipped | Failed: $failed" } main() { echo -e "${BOLD}${BLUE}" echo " ╔═══════════════════════════════════╗" echo " ║ GitHub Repos Sync ║" echo " ╚═══════════════════════════════════╝" echo -e "${NC}" check_requirements log_info "Config: $CONFIG_FILE" log_info "Target: $GITHUB_ORGS_DIR" # Create base directory if needed mkdir -p "$GITHUB_ORGS_DIR" # Read config and sync each org while IFS='=' read -r local_dir org_spec || [[ -n "$local_dir" ]]; do # Skip comments and empty lines [[ -z "$local_dir" || "$local_dir" =~ ^[[:space:]]*# ]] && continue # Trim whitespace local_dir="${local_dir// /}" org_spec="${org_spec// /}" sync_org "$local_dir" "$org_spec" done < "$CONFIG_FILE" # Summary log_header "Sync Complete" echo "" echo -e " ${GREEN}Cloned:${NC} $total_cloned" echo -e " ${BLUE}Updated:${NC} $total_updated" echo -e " ${YELLOW}Skipped:${NC} $total_skipped" echo -e " ${RED}Failed:${NC} $total_failed" echo "" if [[ ${#failed_repos[@]} -gt 0 ]]; then log_error "Failed repositories:" for repo in "${failed_repos[@]}"; do echo " - $repo" done fi } main "$@"