| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # sync-repos - Sync all GitHub organizations and their repos locally |
| 4 | # |
| 5 | # Clones new repos and pulls updates (ff-only) for existing ones. |
| 6 | # Config: ~/.config/sync-repos/orgs.conf |
| 7 | # |
| 8 | |
| 9 | set -uo pipefail |
| 10 | |
| 11 | CONFIG_FILE="${SYNC_REPOS_CONFIG:-$HOME/.config/sync-repos/orgs.conf}" |
| 12 | GITHUB_ORGS_DIR="${GITHUB_ORGS_DIR:-$HOME/GithubOrgs}" |
| 13 | CLONE_TIMEOUT="${CLONE_TIMEOUT:-300}" # seconds (5 min for large repos) |
| 14 | |
| 15 | # Colors |
| 16 | RED='\033[0;31m' |
| 17 | GREEN='\033[0;32m' |
| 18 | YELLOW='\033[0;33m' |
| 19 | BLUE='\033[0;34m' |
| 20 | BOLD='\033[1m' |
| 21 | NC='\033[0m' |
| 22 | |
| 23 | # Counters |
| 24 | total_cloned=0 |
| 25 | total_updated=0 |
| 26 | total_skipped=0 |
| 27 | total_failed=0 |
| 28 | declare -a failed_repos=() |
| 29 | |
| 30 | log_info() { echo -e "${BLUE}→${NC} $*"; } |
| 31 | log_success() { echo -e "${GREEN}✓${NC} $*"; } |
| 32 | log_warn() { echo -e "${YELLOW}⚠${NC} $*"; } |
| 33 | log_error() { echo -e "${RED}✗${NC} $*"; } |
| 34 | log_header() { echo -e "\n${BOLD}${BLUE}══════════════════════════════════════${NC}"; echo -e "${BOLD} $*${NC}"; echo -e "${BOLD}${BLUE}══════════════════════════════════════${NC}"; } |
| 35 | |
| 36 | check_requirements() { |
| 37 | if ! command -v gh &> /dev/null; then |
| 38 | log_error "gh CLI is not installed. Install from: https://cli.github.com/" |
| 39 | exit 1 |
| 40 | fi |
| 41 | |
| 42 | if ! gh auth status &> /dev/null; then |
| 43 | log_error "Not authenticated with GitHub. Run: gh auth login" |
| 44 | exit 1 |
| 45 | fi |
| 46 | |
| 47 | if [[ ! -f "$CONFIG_FILE" ]]; then |
| 48 | log_error "Config file not found: $CONFIG_FILE" |
| 49 | exit 1 |
| 50 | fi |
| 51 | } |
| 52 | |
| 53 | sync_repo() { |
| 54 | local github_org="$1" |
| 55 | local repo="$2" |
| 56 | local org_path="$3" |
| 57 | local repo_path="${org_path}/${repo}" |
| 58 | |
| 59 | # Case 1: Valid git repo exists - try to update |
| 60 | if [[ -d "$repo_path/.git" ]]; then |
| 61 | # Use subshell to avoid cd pollution, capture result |
| 62 | local result |
| 63 | result=$( |
| 64 | cd "$repo_path" 2>/dev/null || { echo "invalid"; exit 0; } |
| 65 | |
| 66 | # Verify it's a valid git repo with a HEAD |
| 67 | if ! git rev-parse HEAD &>/dev/null; then |
| 68 | echo "invalid" |
| 69 | exit 0 |
| 70 | fi |
| 71 | |
| 72 | # Check for uncommitted changes (staged or unstaged) |
| 73 | if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then |
| 74 | echo "dirty" |
| 75 | exit 0 |
| 76 | fi |
| 77 | |
| 78 | # Try fast-forward pull |
| 79 | if pull_output=$(timeout 30 git pull --ff-only 2>&1); then |
| 80 | if [[ "$pull_output" == *"Already up to date"* ]]; then |
| 81 | echo "uptodate" |
| 82 | else |
| 83 | echo "updated" |
| 84 | fi |
| 85 | else |
| 86 | echo "diverged" |
| 87 | fi |
| 88 | ) |
| 89 | |
| 90 | case "$result" in |
| 91 | invalid) |
| 92 | log_warn "$repo (invalid git repo, skipping)" |
| 93 | ((total_skipped++)) || true |
| 94 | ;; |
| 95 | dirty) |
| 96 | log_warn "$repo (uncommitted changes, skipping)" |
| 97 | ((total_skipped++)) || true |
| 98 | ;; |
| 99 | uptodate) |
| 100 | log_success "$repo (up to date)" |
| 101 | ;; |
| 102 | updated) |
| 103 | log_success "$repo (updated)" |
| 104 | ((total_updated++)) || true |
| 105 | ;; |
| 106 | diverged) |
| 107 | log_warn "$repo (can't fast-forward, skipping)" |
| 108 | ((total_skipped++)) || true |
| 109 | ;; |
| 110 | *) |
| 111 | log_warn "$repo (unknown state: $result)" |
| 112 | ((total_skipped++)) || true |
| 113 | ;; |
| 114 | esac |
| 115 | return 0 |
| 116 | |
| 117 | # Case 2: Directory exists but no .git |
| 118 | elif [[ -d "$repo_path" ]]; then |
| 119 | if [[ -z "$(ls -A "$repo_path" 2>/dev/null)" ]]; then |
| 120 | log_info "$repo (empty dir, removing and cloning...)" |
| 121 | rmdir "$repo_path" |
| 122 | else |
| 123 | log_warn "$repo (exists but not a git repo)" |
| 124 | ((total_skipped++)) || true |
| 125 | return 0 |
| 126 | fi |
| 127 | fi |
| 128 | |
| 129 | # Case 3: Clone new repo with retry logic |
| 130 | local ssh_url="git@github.com:${github_org}/${repo}.git" |
| 131 | local max_retries=3 |
| 132 | local attempt=1 |
| 133 | local clone_success=false |
| 134 | |
| 135 | while [[ $attempt -le $max_retries ]] && [[ "$clone_success" == "false" ]]; do |
| 136 | # Clean up any partial clone |
| 137 | [[ -d "$repo_path" ]] && rm -rf "$repo_path" |
| 138 | |
| 139 | if [[ $attempt -eq 1 ]]; then |
| 140 | log_info "Cloning $repo (SSH)..." |
| 141 | else |
| 142 | log_info "Cloning $repo (retry $attempt/$max_retries)..." |
| 143 | fi |
| 144 | |
| 145 | # Try full clone first, then shallow on final attempt |
| 146 | local clone_args="--progress" |
| 147 | if [[ $attempt -eq $max_retries ]]; then |
| 148 | clone_args="--progress --depth 1" |
| 149 | log_info "(trying shallow clone...)" |
| 150 | fi |
| 151 | |
| 152 | if timeout "$CLONE_TIMEOUT" git clone $clone_args "$ssh_url" "$repo_path" 2>&1; then |
| 153 | if [[ -d "$repo_path/.git" ]]; then |
| 154 | if [[ $attempt -eq $max_retries ]]; then |
| 155 | log_success "$repo (cloned shallow)" |
| 156 | else |
| 157 | log_success "$repo (cloned)" |
| 158 | fi |
| 159 | ((total_cloned++)) || true |
| 160 | clone_success=true |
| 161 | fi |
| 162 | else |
| 163 | local exit_code=$? |
| 164 | if [[ -d "$repo_path/.git" ]]; then |
| 165 | log_success "$repo (cloned)" |
| 166 | ((total_cloned++)) || true |
| 167 | clone_success=true |
| 168 | elif [[ $exit_code -eq 124 ]]; then |
| 169 | log_warn "$repo (attempt $attempt timed out)" |
| 170 | else |
| 171 | log_warn "$repo (attempt $attempt failed)" |
| 172 | fi |
| 173 | fi |
| 174 | |
| 175 | ((attempt++)) || true |
| 176 | done |
| 177 | |
| 178 | if [[ "$clone_success" == "false" ]]; then |
| 179 | log_error "$repo (clone failed after $max_retries attempts)" |
| 180 | failed_repos+=("${github_org}/${repo}") |
| 181 | ((total_failed++)) || true |
| 182 | fi |
| 183 | } |
| 184 | |
| 185 | sync_org() { |
| 186 | local local_dir="$1" |
| 187 | local org_spec="$2" |
| 188 | local org_path="${GITHUB_ORGS_DIR}/${local_dir}" |
| 189 | |
| 190 | # Parse org:repo1,repo2 format |
| 191 | local github_org specific_repos |
| 192 | if [[ "$org_spec" == *:* ]]; then |
| 193 | github_org="${org_spec%%:*}" |
| 194 | specific_repos="${org_spec#*:}" |
| 195 | else |
| 196 | github_org="$org_spec" |
| 197 | specific_repos="" |
| 198 | fi |
| 199 | |
| 200 | log_header "$local_dir ← $github_org" |
| 201 | |
| 202 | # Create org directory if needed |
| 203 | if [[ ! -d "$org_path" ]]; then |
| 204 | log_info "Creating directory: $org_path" |
| 205 | mkdir -p "$org_path" |
| 206 | fi |
| 207 | |
| 208 | local repos |
| 209 | if [[ -n "$specific_repos" ]]; then |
| 210 | # Use specific repos from config |
| 211 | repos=$(echo "$specific_repos" | tr ',' '\n') |
| 212 | local repo_count |
| 213 | repo_count=$(echo "$repos" | wc -l) |
| 214 | log_info "Syncing $repo_count specific repositories" |
| 215 | else |
| 216 | # Fetch all repos from GitHub |
| 217 | log_info "Fetching repository list..." |
| 218 | if ! repos=$(gh repo list "$github_org" --limit 1000 --json name --jq '.[].name' 2>&1); then |
| 219 | log_error "Failed to list repos for $github_org: $repos" |
| 220 | return 1 |
| 221 | fi |
| 222 | |
| 223 | if [[ -z "$repos" ]]; then |
| 224 | log_warn "No repositories found for $github_org" |
| 225 | return 0 |
| 226 | fi |
| 227 | |
| 228 | local repo_count |
| 229 | repo_count=$(echo "$repos" | wc -l) |
| 230 | log_info "Found $repo_count repositories" |
| 231 | fi |
| 232 | echo "" |
| 233 | |
| 234 | local cloned=0 updated=0 skipped=0 failed=0 |
| 235 | local before_cloned=$total_cloned |
| 236 | local before_updated=$total_updated |
| 237 | local before_skipped=$total_skipped |
| 238 | local before_failed=$total_failed |
| 239 | |
| 240 | while IFS= read -r repo; do |
| 241 | [[ -z "$repo" ]] && continue |
| 242 | sync_repo "$github_org" "$repo" "$org_path" |
| 243 | done <<< "$repos" |
| 244 | |
| 245 | cloned=$((total_cloned - before_cloned)) |
| 246 | updated=$((total_updated - before_updated)) |
| 247 | skipped=$((total_skipped - before_skipped)) |
| 248 | failed=$((total_failed - before_failed)) |
| 249 | |
| 250 | echo "" |
| 251 | echo " Cloned: $cloned | Updated: $updated | Skipped: $skipped | Failed: $failed" |
| 252 | } |
| 253 | |
| 254 | main() { |
| 255 | echo -e "${BOLD}${BLUE}" |
| 256 | echo " ╔═══════════════════════════════════╗" |
| 257 | echo " ║ GitHub Repos Sync ║" |
| 258 | echo " ╚═══════════════════════════════════╝" |
| 259 | echo -e "${NC}" |
| 260 | |
| 261 | check_requirements |
| 262 | |
| 263 | log_info "Config: $CONFIG_FILE" |
| 264 | log_info "Target: $GITHUB_ORGS_DIR" |
| 265 | |
| 266 | # Create base directory if needed |
| 267 | mkdir -p "$GITHUB_ORGS_DIR" |
| 268 | |
| 269 | # Read config and sync each org |
| 270 | while IFS='=' read -r local_dir org_spec || [[ -n "$local_dir" ]]; do |
| 271 | # Skip comments and empty lines |
| 272 | [[ -z "$local_dir" || "$local_dir" =~ ^[[:space:]]*# ]] && continue |
| 273 | |
| 274 | # Trim whitespace |
| 275 | local_dir="${local_dir// /}" |
| 276 | org_spec="${org_spec// /}" |
| 277 | |
| 278 | sync_org "$local_dir" "$org_spec" |
| 279 | done < "$CONFIG_FILE" |
| 280 | |
| 281 | # Summary |
| 282 | log_header "Sync Complete" |
| 283 | echo "" |
| 284 | echo -e " ${GREEN}Cloned:${NC} $total_cloned" |
| 285 | echo -e " ${BLUE}Updated:${NC} $total_updated" |
| 286 | echo -e " ${YELLOW}Skipped:${NC} $total_skipped" |
| 287 | echo -e " ${RED}Failed:${NC} $total_failed" |
| 288 | echo "" |
| 289 | |
| 290 | if [[ ${#failed_repos[@]} -gt 0 ]]; then |
| 291 | log_error "Failed repositories:" |
| 292 | for repo in "${failed_repos[@]}"; do |
| 293 | echo " - $repo" |
| 294 | done |
| 295 | fi |
| 296 | } |
| 297 | |
| 298 | main "$@" |