Text · 9374 bytes Raw Blame History
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 "$@"