master
sh 546 lines 19.3 KB
Raw
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 "$@"