| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | # Don't use set -e to ensure script continues even on errors |
| 4 | set -uo pipefail |
| 5 | |
| 6 | # Color codes for output |
| 7 | RED='\033[0;31m' |
| 8 | GREEN='\033[0;32m' |
| 9 | YELLOW='\033[1;33m' |
| 10 | BLUE='\033[0;34m' |
| 11 | CYAN='\033[0;36m' |
| 12 | NC='\033[0m' # No Color |
| 13 | BOLD='\033[1m' |
| 14 | |
| 15 | # Configuration |
| 16 | ORG="netdata" |
| 17 | ACTIVITY_CACHE_FILE=".repo-activity-cache" |
| 18 | |
| 19 | # Arrays to track issues |
| 20 | declare -a REPOS_WITH_UNCOMMITTED=() |
| 21 | declare -a REPOS_WITH_UNPUSHED=() |
| 22 | declare -a REPOS_UPDATE_FAILED=() |
| 23 | declare -a REPOS_WRONG_BRANCH=() |
| 24 | declare -a REPOS_BRANCH_SWITCHED=() |
| 25 | |
| 26 | # Scoped subset of repos (--repo flag, repeatable). Empty => all repos. |
| 27 | declare -a SCOPE_REPOS=() |
| 28 | |
| 29 | usage() { |
| 30 | cat <<EOF |
| 31 | sync-netdata-repos.sh [--repo NAME ...] [-h|--help] |
| 32 | |
| 33 | Maintains a local mirror of Netdata-org source repositories at |
| 34 | \${NETDATA_REPOS_DIR} so that cross-repo grep / code review can run |
| 35 | locally without GitHub API round-trips and rate limits. |
| 36 | |
| 37 | Phase 1 (always): for each repo in scope, skip if there are staged or |
| 38 | modified changes; otherwise switch to the default branch (master/main/ |
| 39 | develop), pull, and recursively update submodules. |
| 40 | |
| 41 | Phase 2 (only when no --repo flags AND 'gh' is available + authed): |
| 42 | discover new netdata-org source repos via 'gh repo list netdata |
| 43 | --source --no-archived' and clone any that are missing. |
| 44 | |
| 45 | Options: |
| 46 | --repo NAME sync ONLY the named repo. Repeatable. Skips Phase 2. |
| 47 | -h, --help show this help. |
| 48 | |
| 49 | Required environment: |
| 50 | NETDATA_REPOS_DIR directory holding the mirror (must exist). |
| 51 | |
| 52 | Required tools: |
| 53 | git, jq always. |
| 54 | gh only for Phase 2; if missing or unauthed, Phase 2 |
| 55 | is skipped with a warning. |
| 56 | EOF |
| 57 | } |
| 58 | |
| 59 | # --------------------------------------------------------------- |
| 60 | # Early help (works without NETDATA_REPOS_DIR or any other env). |
| 61 | for _arg in "$@"; do |
| 62 | case "$_arg" in |
| 63 | -h|--help) usage; exit 0 ;; |
| 64 | *) ;; # ignore -- main parser handles all other flags |
| 65 | esac |
| 66 | done |
| 67 | |
| 68 | # --------------------------------------------------------------- |
| 69 | # Sanitization (runs before any work). |
| 70 | |
| 71 | # 1. NETDATA_REPOS_DIR set and points to an existing directory. |
| 72 | if [ -z "${NETDATA_REPOS_DIR:-}" ]; then |
| 73 | echo "ERROR: NETDATA_REPOS_DIR is not set." >&2 |
| 74 | echo " Set it in <repo>/.env (or your shell env) to the directory" >&2 |
| 75 | echo " that holds (or will hold) your Netdata-org repos mirror." >&2 |
| 76 | exit 2 |
| 77 | fi |
| 78 | MIRROR_DIR="$NETDATA_REPOS_DIR" |
| 79 | if [ ! -d "$MIRROR_DIR" ]; then |
| 80 | echo "ERROR: NETDATA_REPOS_DIR='$MIRROR_DIR' is not an existing directory." >&2 |
| 81 | echo " Create it first: mkdir -p \"\$NETDATA_REPOS_DIR\"" >&2 |
| 82 | exit 2 |
| 83 | fi |
| 84 | |
| 85 | # 2. Required tools. |
| 86 | for _cmd in git jq; do |
| 87 | if ! command -v "$_cmd" >/dev/null 2>&1; then |
| 88 | echo "ERROR: '$_cmd' is required but not found in PATH." >&2 |
| 89 | exit 2 |
| 90 | fi |
| 91 | done |
| 92 | |
| 93 | # 3. Optional: gh (Phase 2 discovery only). |
| 94 | GH_AVAILABLE=true |
| 95 | GH_REASON="" |
| 96 | if ! command -v gh >/dev/null 2>&1; then |
| 97 | GH_AVAILABLE=false |
| 98 | GH_REASON="'gh' is not installed" |
| 99 | elif ! gh auth status >/dev/null 2>&1; then |
| 100 | GH_AVAILABLE=false |
| 101 | GH_REASON="'gh' is not authenticated (run: gh auth login)" |
| 102 | fi |
| 103 | |
| 104 | cd "$MIRROR_DIR" || { echo "ERROR: cannot cd into NETDATA_REPOS_DIR=$MIRROR_DIR" >&2; exit 2; } |
| 105 | |
| 106 | # Function to print colored output |
| 107 | print_status() { |
| 108 | local msg="$1" |
| 109 | echo -e "$msg" |
| 110 | } |
| 111 | |
| 112 | # Function to check if directory is a git repository |
| 113 | is_git_repo() { |
| 114 | local repo="$1" |
| 115 | [ -d "$repo/.git" ] |
| 116 | } |
| 117 | |
| 118 | # Function to get last commit timestamp quickly (using filesystem heuristic) |
| 119 | get_last_activity() { |
| 120 | local repo="$1" |
| 121 | # Use the modification time of .git/logs/HEAD if it exists (fast heuristic) |
| 122 | # This file is updated on commits, pulls, etc. |
| 123 | if [ -f "$repo/.git/logs/HEAD" ]; then |
| 124 | stat -c %Y "$repo/.git/logs/HEAD" 2>/dev/null || echo "0" |
| 125 | elif [ -d "$repo/.git" ]; then |
| 126 | # Fallback to .git directory modification time |
| 127 | stat -c %Y "$repo/.git" 2>/dev/null || echo "0" |
| 128 | else |
| 129 | echo "0" |
| 130 | fi |
| 131 | } |
| 132 | |
| 133 | # Function to check for uncommitted changes (ignoring untracked files) |
| 134 | has_uncommitted_changes() { |
| 135 | local repo="$1" |
| 136 | # Safety: ensure we're in the right directory |
| 137 | if [ ! -d "$repo" ]; then |
| 138 | return 0 # Treat missing directory as "has changes" to skip it |
| 139 | fi |
| 140 | cd "$repo" || return 0 # If cd fails, treat as "has changes" |
| 141 | |
| 142 | # First check if we have a valid HEAD (repo might be empty or corrupted) |
| 143 | if ! git rev-parse HEAD >/dev/null 2>&1; then |
| 144 | cd "$MIRROR_DIR" 2>/dev/null || true |
| 145 | return 1 # No HEAD means no commits, so no uncommitted changes to worry about |
| 146 | fi |
| 147 | |
| 148 | # Refresh the index to avoid false positives from timestamp changes |
| 149 | git update-index --refresh >/dev/null 2>&1 || true |
| 150 | |
| 151 | # Check only for staged or modified files, not untracked files |
| 152 | # git diff-index checks for staged and modified files |
| 153 | # We ignore untracked files since they don't affect pulls |
| 154 | git diff-index --quiet HEAD -- 2>/dev/null |
| 155 | local diff_result=$? |
| 156 | |
| 157 | # git diff-index returns 0 if no changes, 1 if changes exist |
| 158 | # We want to return 0 (true) if changes exist, 1 (false) if no changes |
| 159 | if [ $diff_result -eq 1 ]; then |
| 160 | local result=0 # Has changes |
| 161 | else |
| 162 | local result=1 # No changes |
| 163 | fi |
| 164 | |
| 165 | cd "$MIRROR_DIR" 2>/dev/null || true # Try to return to script directory |
| 166 | return $result |
| 167 | } |
| 168 | |
| 169 | # Function to check for unpushed commits |
| 170 | has_unpushed_commits() { |
| 171 | local repo="$1" |
| 172 | # Safety: ensure we're in the right directory |
| 173 | if [ ! -d "$repo" ]; then |
| 174 | return 1 # No unpushed if directory doesn't exist |
| 175 | fi |
| 176 | cd "$repo" || return 1 |
| 177 | local branch |
| 178 | branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) |
| 179 | local result |
| 180 | if [ -n "$branch" ] && git rev-parse --verify "origin/$branch" >/dev/null 2>&1; then |
| 181 | [ -n "$(git log "origin/$branch..HEAD" --oneline 2>/dev/null)" ] |
| 182 | result=$? |
| 183 | else |
| 184 | result=1 |
| 185 | fi |
| 186 | cd "$MIRROR_DIR" 2>/dev/null || true # Try to return to script directory |
| 187 | return $result |
| 188 | } |
| 189 | |
| 190 | # Function to get uncommitted changes details |
| 191 | get_uncommitted_details() { |
| 192 | local repo="$1" |
| 193 | # Safety: ensure we're in the right directory |
| 194 | if [ ! -d "$repo" ]; then |
| 195 | echo "directory not found" |
| 196 | return |
| 197 | fi |
| 198 | cd "$repo" || { echo "cannot access"; return; } |
| 199 | local staged unstaged |
| 200 | staged=$(git diff --cached --numstat | wc -l) |
| 201 | unstaged=$(git diff --numstat | wc -l) |
| 202 | cd "$MIRROR_DIR" 2>/dev/null || true # Try to return to script directory |
| 203 | |
| 204 | # Report details - note that untracked files are shown but don't block updates |
| 205 | if [ "$staged" -gt 0 ] || [ "$unstaged" -gt 0 ]; then |
| 206 | echo "staged: $staged, modified: $unstaged" |
| 207 | else |
| 208 | # This can happen if git diff-index failed for other reasons |
| 209 | echo "git index issue or empty repository" |
| 210 | fi |
| 211 | } |
| 212 | |
| 213 | # Function to update repository activity cache |
| 214 | update_activity_cache() { |
| 215 | print_status "${CYAN}Updating repository activity cache...${NC}" |
| 216 | |
| 217 | # Safety: Use a unique temp file to avoid conflicts |
| 218 | local temp_file="$ACTIVITY_CACHE_FILE.tmp.$$" |
| 219 | : > "$temp_file" |
| 220 | |
| 221 | for dir in */; do |
| 222 | dir="${dir%/}" |
| 223 | # Only process actual directories that are git repos |
| 224 | if [ -d "$dir" ] && is_git_repo "$dir"; then |
| 225 | local timestamp |
| 226 | timestamp=$(get_last_activity "$dir") |
| 227 | echo "$timestamp $dir" >> "$temp_file" |
| 228 | fi |
| 229 | done |
| 230 | |
| 231 | # Sort by timestamp (descending) and keep only the repo names |
| 232 | if [ -s "$temp_file" ]; then |
| 233 | sort -rn "$temp_file" | cut -d' ' -f2 > "$ACTIVITY_CACHE_FILE" |
| 234 | fi |
| 235 | rm -f "$temp_file" |
| 236 | } |
| 237 | |
| 238 | # Function to get sorted repo list |
| 239 | get_sorted_repos() { |
| 240 | if [ -f "$ACTIVITY_CACHE_FILE" ]; then |
| 241 | cat "$ACTIVITY_CACHE_FILE" |
| 242 | else |
| 243 | # If no cache exists, create one |
| 244 | update_activity_cache |
| 245 | cat "$ACTIVITY_CACHE_FILE" |
| 246 | fi |
| 247 | } |
| 248 | |
| 249 | # Function to get default branch (assumes we're already in the repo directory) |
| 250 | get_default_branch() { |
| 251 | # Try to get from remote |
| 252 | local default_branch |
| 253 | default_branch=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@') |
| 254 | |
| 255 | # If that fails, try common defaults |
| 256 | if [ -z "$default_branch" ]; then |
| 257 | if git show-ref --verify --quiet refs/remotes/origin/master; then |
| 258 | default_branch="master" |
| 259 | elif git show-ref --verify --quiet refs/remotes/origin/main; then |
| 260 | default_branch="main" |
| 261 | elif git show-ref --verify --quiet refs/remotes/origin/develop; then |
| 262 | default_branch="develop" |
| 263 | fi |
| 264 | fi |
| 265 | |
| 266 | echo "$default_branch" |
| 267 | } |
| 268 | |
| 269 | # Function to update a single repository |
| 270 | update_repo() { |
| 271 | local repo="$1" |
| 272 | local current_num="$2" |
| 273 | local total_num="$3" |
| 274 | |
| 275 | # Safety: validate repo directory exists and is a git repo |
| 276 | if [ ! -d "$repo" ]; then |
| 277 | print_status "${RED}[${current_num}/${total_num}] Skipping $repo - directory not found${NC}" |
| 278 | return 1 |
| 279 | fi |
| 280 | |
| 281 | if ! is_git_repo "$repo"; then |
| 282 | print_status "${YELLOW}[${current_num}/${total_num}] Skipping $repo - not a git repository${NC}" |
| 283 | return 1 |
| 284 | fi |
| 285 | |
| 286 | print_status "${BLUE}[${current_num}/${total_num}]${NC} ${BOLD}Updating $repo...${NC}" |
| 287 | |
| 288 | # Check for uncommitted changes (staged or modified files only) |
| 289 | if has_uncommitted_changes "$repo"; then |
| 290 | local details |
| 291 | details=$(get_uncommitted_details "$repo") |
| 292 | print_status " ${YELLOW}⚠️ Skipping - uncommitted changes (${details})${NC}" |
| 293 | REPOS_WITH_UNCOMMITTED+=("$repo: $details") |
| 294 | return 1 |
| 295 | fi |
| 296 | |
| 297 | cd "$repo" || { print_status " ${RED}✗ Cannot access directory${NC}"; return 1; } |
| 298 | |
| 299 | # Check for untracked files (informational only - doesn't block update) |
| 300 | local untracked_count |
| 301 | untracked_count=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l) |
| 302 | if [ "$untracked_count" -gt 0 ]; then |
| 303 | print_status " ${CYAN}ℹ️ Note: ${untracked_count} untracked file(s) present${NC}" |
| 304 | fi |
| 305 | |
| 306 | # Get current and default branches |
| 307 | local current_branch default_branch |
| 308 | current_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) |
| 309 | default_branch=$(get_default_branch) |
| 310 | |
| 311 | # Check for unpushed commits (we're already in the repo directory) |
| 312 | if [ -n "$current_branch" ] && git rev-parse --verify "origin/$current_branch" >/dev/null 2>&1; then |
| 313 | if [ -n "$(git log "origin/$current_branch..HEAD" --oneline 2>/dev/null)" ]; then |
| 314 | local unpushed_count |
| 315 | unpushed_count=$(git log "origin/${current_branch}..HEAD" --oneline 2>/dev/null | wc -l) |
| 316 | print_status " ${YELLOW}⚠️ Warning: ${unpushed_count} unpushed commit(s) on ${current_branch}${NC}" |
| 317 | REPOS_WITH_UNPUSHED+=("$repo: $unpushed_count commits on $current_branch") |
| 318 | fi |
| 319 | fi |
| 320 | |
| 321 | # Switch to default branch if needed |
| 322 | if [ -n "$default_branch" ] && [ "$current_branch" != "$default_branch" ]; then |
| 323 | print_status " ${CYAN}→ Switching from ${current_branch} to ${default_branch}${NC}" |
| 324 | if git checkout "$default_branch" >/dev/null 2>&1; then |
| 325 | REPOS_BRANCH_SWITCHED+=("$repo: $current_branch → $default_branch") |
| 326 | current_branch="$default_branch" |
| 327 | else |
| 328 | print_status " ${RED}✗ Failed to switch to ${default_branch}${NC}" |
| 329 | REPOS_WRONG_BRANCH+=("$repo: stuck on $current_branch, default is $default_branch") |
| 330 | fi |
| 331 | fi |
| 332 | |
| 333 | # Fetch and pull |
| 334 | print_status " → Fetching..." |
| 335 | if git fetch origin >/dev/null 2>&1; then |
| 336 | print_status " → Pulling ${current_branch}..." |
| 337 | if git pull origin "$current_branch" >/dev/null 2>&1; then |
| 338 | # Update submodules |
| 339 | print_status " → Updating submodules..." |
| 340 | if git submodule update --init --force --recursive >/dev/null 2>&1; then |
| 341 | print_status " ${GREEN}✓ Updated successfully${NC}" |
| 342 | else |
| 343 | # Don't fail the whole update if submodules have issues |
| 344 | print_status " ${YELLOW}⚠️ Updated but submodule update had issues${NC}" |
| 345 | fi |
| 346 | else |
| 347 | print_status " ${RED}✗ Pull failed${NC}" |
| 348 | REPOS_UPDATE_FAILED+=("$repo") |
| 349 | cd "$MIRROR_DIR" 2>/dev/null || true |
| 350 | return 1 |
| 351 | fi |
| 352 | else |
| 353 | print_status " ${RED}✗ Fetch failed${NC}" |
| 354 | REPOS_UPDATE_FAILED+=("$repo") |
| 355 | cd "$MIRROR_DIR" 2>/dev/null || true |
| 356 | return 1 |
| 357 | fi |
| 358 | |
| 359 | cd "$MIRROR_DIR" 2>/dev/null || true # Try to return to script directory |
| 360 | return 0 |
| 361 | } |
| 362 | |
| 363 | # Function to clone new repositories |
| 364 | clone_new_repos() { |
| 365 | print_status "\n${BOLD}Checking for new repositories to clone...${NC}" |
| 366 | |
| 367 | local new_repos_count=0 |
| 368 | |
| 369 | # Get list of all repos from GitHub |
| 370 | print_status "Fetching repository list from GitHub..." |
| 371 | local repos_list |
| 372 | repos_list=$(gh repo list "$ORG" --limit 1000 --json name,sshUrl,defaultBranchRef --source --no-archived) |
| 373 | |
| 374 | echo "$repos_list" | jq -r '.[] | "\(.name) \(.sshUrl) \(.defaultBranchRef.name)"' | while read -r name url default_branch; do |
| 375 | if [ ! -d "$name" ]; then |
| 376 | new_repos_count=$((new_repos_count + 1)) |
| 377 | print_status "${GREEN}→ Cloning new repo: $name (default branch: $default_branch, with submodules)${NC}" |
| 378 | if git clone --quiet --recursive "$url" "$name" 2>/dev/null; then |
| 379 | # Set up tracking for default branch |
| 380 | # Safety: validate we can enter the directory |
| 381 | if cd "$name" 2>/dev/null; then |
| 382 | git symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$default_branch" 2>/dev/null || true |
| 383 | cd "$MIRROR_DIR" 2>/dev/null || true |
| 384 | else |
| 385 | print_status " ${YELLOW}⚠️ Warning: Could not enter cloned directory${NC}" |
| 386 | fi |
| 387 | print_status " ${GREEN}✓ Cloned successfully${NC}" |
| 388 | else |
| 389 | print_status " ${RED}✗ Clone failed${NC}" |
| 390 | fi |
| 391 | fi |
| 392 | done |
| 393 | |
| 394 | if [ $new_repos_count -eq 0 ]; then |
| 395 | print_status "No new repositories to clone." |
| 396 | fi |
| 397 | } |
| 398 | |
| 399 | # Function to print summary |
| 400 | print_summary() { |
| 401 | print_status "\n${BOLD}═══════════════════════════════════════════════════════════${NC}" |
| 402 | print_status "${BOLD}Summary Report${NC}" |
| 403 | print_status "${BOLD}═══════════════════════════════════════════════════════════${NC}" |
| 404 | |
| 405 | if [ ${#REPOS_BRANCH_SWITCHED[@]} -gt 0 ]; then |
| 406 | print_status "\n${CYAN}📌 Branches switched to default:${NC}" |
| 407 | for repo in "${REPOS_BRANCH_SWITCHED[@]}"; do |
| 408 | print_status " • $repo" |
| 409 | done |
| 410 | fi |
| 411 | |
| 412 | if [ ${#REPOS_WITH_UNCOMMITTED[@]} -gt 0 ]; then |
| 413 | print_status "\n${YELLOW}⚠️ Repositories with uncommitted changes (skipped):${NC}" |
| 414 | for repo in "${REPOS_WITH_UNCOMMITTED[@]}"; do |
| 415 | print_status " • $repo" |
| 416 | done |
| 417 | fi |
| 418 | |
| 419 | if [ ${#REPOS_WITH_UNPUSHED[@]} -gt 0 ]; then |
| 420 | print_status "\n${YELLOW}📤 Repositories with unpushed commits:${NC}" |
| 421 | for repo in "${REPOS_WITH_UNPUSHED[@]}"; do |
| 422 | print_status " • $repo" |
| 423 | done |
| 424 | fi |
| 425 | |
| 426 | if [ ${#REPOS_WRONG_BRANCH[@]} -gt 0 ]; then |
| 427 | print_status "\n${YELLOW}🔀 Repositories on wrong branch:${NC}" |
| 428 | for repo in "${REPOS_WRONG_BRANCH[@]}"; do |
| 429 | print_status " • $repo" |
| 430 | done |
| 431 | fi |
| 432 | |
| 433 | if [ ${#REPOS_UPDATE_FAILED[@]} -gt 0 ]; then |
| 434 | print_status "\n${RED}✗ Repositories that failed to update:${NC}" |
| 435 | for repo in "${REPOS_UPDATE_FAILED[@]}"; do |
| 436 | print_status " • $repo" |
| 437 | done |
| 438 | fi |
| 439 | |
| 440 | if [ ${#REPOS_WITH_UNCOMMITTED[@]} -eq 0 ] && \ |
| 441 | [ ${#REPOS_WITH_UNPUSHED[@]} -eq 0 ] && \ |
| 442 | [ ${#REPOS_WRONG_BRANCH[@]} -eq 0 ] && \ |
| 443 | [ ${#REPOS_UPDATE_FAILED[@]} -eq 0 ]; then |
| 444 | print_status "\n${GREEN}✅ All repositories are clean and up to date!${NC}" |
| 445 | fi |
| 446 | } |
| 447 | |
| 448 | # Main execution |
| 449 | main() { |
| 450 | # Parse CLI flags. |
| 451 | while [ $# -gt 0 ]; do |
| 452 | local arg="$1" |
| 453 | case "$arg" in |
| 454 | --repo) |
| 455 | if [ $# -lt 2 ]; then |
| 456 | echo "ERROR: --repo requires a repository name" >&2 |
| 457 | exit 2 |
| 458 | fi |
| 459 | local val="$2" |
| 460 | SCOPE_REPOS+=("$val") |
| 461 | shift 2 |
| 462 | ;; |
| 463 | -h|--help) |
| 464 | usage |
| 465 | exit 0 |
| 466 | ;; |
| 467 | *) |
| 468 | echo "ERROR: Unknown option: $arg" >&2 |
| 469 | usage >&2 |
| 470 | exit 2 |
| 471 | ;; |
| 472 | esac |
| 473 | done |
| 474 | |
| 475 | print_status "${BOLD}═══════════════════════════════════════════════════════════${NC}" |
| 476 | print_status "${BOLD}Netdata Repository Sync Tool${NC}" |
| 477 | print_status "${BOLD}═══════════════════════════════════════════════════════════${NC}" |
| 478 | print_status "${CYAN}Mirror: ${MIRROR_DIR}${NC}" |
| 479 | if [ ${#SCOPE_REPOS[@]} -gt 0 ]; then |
| 480 | print_status "${CYAN}Scope: --repo flags ->${NC} ${SCOPE_REPOS[*]}" |
| 481 | fi |
| 482 | |
| 483 | # Phase 1: Update repositories. |
| 484 | if [ ${#SCOPE_REPOS[@]} -gt 0 ]; then |
| 485 | print_status "\n${BOLD}Phase 1: Updating scoped repositories${NC}" |
| 486 | else |
| 487 | print_status "\n${BOLD}Phase 1: Updating existing repositories${NC}" |
| 488 | print_status "Sorting repositories by last activity..." |
| 489 | fi |
| 490 | |
| 491 | # Build the working list. |
| 492 | local sorted_repos=() |
| 493 | if [ ${#SCOPE_REPOS[@]} -gt 0 ]; then |
| 494 | # Scoped run: validate each --repo entry exists locally. |
| 495 | for repo in "${SCOPE_REPOS[@]}"; do |
| 496 | if [ -d "$repo" ] && is_git_repo "$repo"; then |
| 497 | sorted_repos+=("$repo") |
| 498 | else |
| 499 | print_status "${YELLOW}⚠️ Skipping --repo $repo: not found at $MIRROR_DIR/$repo${NC}" |
| 500 | fi |
| 501 | done |
| 502 | else |
| 503 | # Default: activity-cache-sorted full set. |
| 504 | while IFS= read -r repo; do |
| 505 | sorted_repos+=("$repo") |
| 506 | done < <(get_sorted_repos) |
| 507 | fi |
| 508 | |
| 509 | local total_repos=${#sorted_repos[@]} |
| 510 | |
| 511 | if [ "$total_repos" -eq 0 ]; then |
| 512 | print_status "No repositories to update." |
| 513 | else |
| 514 | print_status "Found ${total_repos} repositories to update.\n" |
| 515 | |
| 516 | local current=0 |
| 517 | for repo in "${sorted_repos[@]}"; do |
| 518 | current=$((current + 1)) |
| 519 | if [ -d "$repo" ] && is_git_repo "$repo"; then |
| 520 | update_repo "$repo" "$current" "$total_repos" || true # Continue even if update fails |
| 521 | fi |
| 522 | done |
| 523 | fi |
| 524 | |
| 525 | # Phase 2: Clone new repositories. |
| 526 | if [ ${#SCOPE_REPOS[@]} -gt 0 ]; then |
| 527 | print_status "\n${CYAN}Skipping Phase 2 (discovery): --repo flags scoped this run.${NC}" |
| 528 | elif ! $GH_AVAILABLE; then |
| 529 | print_status "\n${YELLOW}⚠️ Skipping Phase 2 (discovery): ${GH_REASON}.${NC}" |
| 530 | else |
| 531 | print_status "\n${BOLD}Phase 2: Checking for new repositories${NC}" |
| 532 | clone_new_repos || true |
| 533 | fi |
| 534 | |
| 535 | # Update activity cache for next run. |
| 536 | print_status "\n${CYAN}Updating activity cache for next run...${NC}" |
| 537 | update_activity_cache || true |
| 538 | |
| 539 | # Print summary. |
| 540 | print_summary || true |
| 541 | |
| 542 | print_status "\n${GREEN}${BOLD}✅ Sync complete!${NC}" |
| 543 | } |
| 544 | |
| 545 | # Run main function with all CLI args. |
| 546 | main "$@" |