get_group_src.sh
· 7.8 KiB · Bash
Raw
#!/bin/bash
set -euo pipefail
# ── Colors ───────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
# ── Logging ──────────────────────────────────────────────────────────
log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$*"; }
log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
log_ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$*"; }
log_error() { printf "${RED}[ERR]${NC} %s\n" "$*" >&2; }
# ── Usage ────────────────────────────────────────────────────────────
usage() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Clone all projects from a GitLab group (including subgroups) preserving
the path_with_namespace directory structure.
Options:
-h, --help Show this help
-t, --token-file=FILE File containing the GitLab personal access token
-g, --gitlab-url=URL GitLab instance URL (default: https://git.mws-team.ru)
-i, --group-id=ID GitLab group ID to clone
Examples:
$(basename "$0") -t ~/.gitlab_token -i 42
$(basename "$0") --token-file=token.txt --gitlab-url=https://gitlab.com --group-id=123
EOF
exit 1
}
# ── Dependency check ─────────────────────────────────────────────────
for cmd in curl jq git; do
if ! command -v "$cmd" &>/dev/null; then
log_error "Required command not found: ${cmd}"
exit 1
fi
done
# ── Parse arguments ──────────────────────────────────────────────────
GITLAB_URL="https://git.mws-team.ru"
TOKEN_FILE=""
GROUP=""
translated=()
while (( $# )); do
case "$1" in
--help) translated+=("-h"); shift ;;
--token-file=*) translated+=("-t" "${1#*=}"); shift ;;
--gitlab-url=*) translated+=("-g" "${1#*=}"); shift ;;
--group-id=*) translated+=("-i" "${1#*=}"); shift ;;
--) translated+=("--"); shift; translated+=("$@"); break ;;
*) translated+=("$1"); shift ;;
esac
done
set -- "${translated[@]}"
while getopts ":ht:g:i:" opt; do
case "$opt" in
h) usage ;;
t) TOKEN_FILE="$OPTARG" ;;
g) GITLAB_URL="$OPTARG" ;;
i) GROUP="$OPTARG" ;;
:) log_error "Missing argument for -${OPTARG}"; usage ;;
\?) log_error "Invalid option: -${OPTARG}"; usage ;;
esac
done
shift $((OPTIND - 1))
# ── Validate inputs ─────────────────────────────────────────────────
[[ -z "$TOKEN_FILE" ]] && { log_error "Missing --token-file"; usage; }
[[ -z "$GITLAB_URL" ]] && { log_error "Missing --gitlab-url"; usage; }
[[ -z "$GROUP" ]] && { log_error "Missing --group-id"; usage; }
[[ ! -f "$TOKEN_FILE" ]] && { log_error "Token file not found: ${TOKEN_FILE}"; exit 1; }
# Strip trailing slash from URL
GITLAB_URL="${GITLAB_URL%/}"
TOKEN="$(<"$TOKEN_FILE")"
readonly TOKEN GITLAB_URL GROUP
# ── GitLab API helpers ───────────────────────────────────────────────
# Perform an authenticated GET request and return the JSON body.
gitlab_get() {
local url="$1"
curl --silent --fail --header "Authorization: Bearer ${TOKEN}" "$url"
}
# Return total number of pages for a paginated endpoint.
# Usage: gitlab_total_pages "https://gitlab.com/api/v4/..."
gitlab_total_pages() {
local url="$1"
local headers
headers="$(curl -X HEAD --silent -w '%{header_json}' \
--header "Authorization: Bearer ${TOKEN}" \
-o /dev/null "$url" 2>/dev/null)" || true
local pages
pages="$(echo "$headers" | jq -r '."x-total-pages"[0] // "1"')"
# Fallback to 1 if empty or null
[[ -z "$pages" || "$pages" == "null" ]] && pages=1
echo "$pages"
}
# Iterate over all pages of a paginated endpoint, streaming JSON arrays.
# Calls a callback function with each item's specified jq field.
# Usage: gitlab_paginate "/api/v4/groups/42/projects?per_page=100" ".[].id" callback_fn
gitlab_paginate() {
local endpoint="$1"
local jq_filter="$2"
local callback="$3"
local extra_params="${4:-}"
local base_url="${GITLAB_URL}${endpoint}"
[[ -n "$extra_params" ]] && base_url="${base_url}&${extra_params}"
local total_pages
total_pages="$(gitlab_total_pages "$base_url")"
local page
for (( page = 1; page <= total_pages; page++ )); do
local response
response="$(gitlab_get "${base_url}&page=${page}")" || {
log_warn "API request failed: ${base_url}&page=${page}"
continue
}
local item
while IFS= read -r item; do
[[ -z "$item" ]] && continue
"$callback" "$item"
done < <(echo "$response" | jq -r "$jq_filter")
done
}
# ── Clone a single project ──────────────────────────────────────────
clone_project() {
local project_id="$1"
local project_json
project_json="$(gitlab_get "${GITLAB_URL}/api/v4/projects/${project_id}")" || {
log_warn "Failed to fetch project ${project_id}"
return
}
local path_ns ssh_url
path_ns="$(echo "$project_json" | jq -r '.path_with_namespace')"
ssh_url="$(echo "$project_json" | jq -r '.ssh_url_to_repo')"
if [[ -z "$path_ns" || "$path_ns" == "null" ]]; then
log_warn "Could not determine path for project ${project_id} — skipping"
return
fi
if [[ -d "$path_ns" ]]; then
log_info "Already cloned: ${path_ns}"
return
fi
log_info "Cloning: ${path_ns}"
local parent_dir
parent_dir="$(dirname "$path_ns")"
mkdir -p "$parent_dir"
git clone "$ssh_url" "$path_ns" --quiet || log_warn "git clone failed for ${path_ns}"
}
# ── Clone all projects in a group ────────────────────────────────────
clone_group_projects() {
local group_id="$1"
log_info "Cloning projects in group ${group_id}"
gitlab_paginate "/api/v4/groups/${group_id}/projects?per_page=100" \
'.[].id' clone_project "archived=no"
}
# ── Process a group and all its subgroups recursively ────────────────
process_group() {
local group_id="$1"
# Clone direct projects
clone_group_projects "$group_id"
# Recurse into subgroups
local subgroup_ids=()
while IFS= read -r sid; do
[[ -z "$sid" ]] && continue
subgroup_ids+=("$sid")
done < <(
local total
total="$(gitlab_total_pages "${GITLAB_URL}/api/v4/groups/${group_id}/subgroups?per_page=100")"
for (( p = 1; p <= total; p++ )); do
gitlab_get "${GITLAB_URL}/api/v4/groups/${group_id}/subgroups?per_page=100&page=${p}" \
| jq -r '.[].id'
done
)
for sid in "${subgroup_ids[@]}"; do
log_info "Entering subgroup ${sid}"
process_group "$sid"
done
}
# ── Main ─────────────────────────────────────────────────────────────
main() {
log_info "GitLab URL: ${GITLAB_URL}"
log_info "Group ID: ${GROUP}"
log_info "Token file: ${TOKEN_FILE}"
echo
process_group "$GROUP"
echo
log_ok "Done"
}
main
| 1 | #!/bin/bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | # ── Colors ─────────────────────────────────────────────────────────── |
| 5 | RED='\033[0;31m' |
| 6 | GREEN='\033[0;32m' |
| 7 | YELLOW='\033[1;33m' |
| 8 | CYAN='\033[0;36m' |
| 9 | NC='\033[0m' |
| 10 | |
| 11 | # ── Logging ────────────────────────────────────────────────────────── |
| 12 | log_info() { printf "${CYAN}[INFO]${NC} %s\n" "$*"; } |
| 13 | log_warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; } |
| 14 | log_ok() { printf "${GREEN}[ OK ]${NC} %s\n" "$*"; } |
| 15 | log_error() { printf "${RED}[ERR]${NC} %s\n" "$*" >&2; } |
| 16 | |
| 17 | # ── Usage ──────────────────────────────────────────────────────────── |
| 18 | usage() { |
| 19 | cat <<EOF |
| 20 | Usage: $(basename "$0") [OPTIONS] |
| 21 | |
| 22 | Clone all projects from a GitLab group (including subgroups) preserving |
| 23 | the path_with_namespace directory structure. |
| 24 | |
| 25 | Options: |
| 26 | -h, --help Show this help |
| 27 | -t, --token-file=FILE File containing the GitLab personal access token |
| 28 | -g, --gitlab-url=URL GitLab instance URL (default: https://git.mws-team.ru) |
| 29 | -i, --group-id=ID GitLab group ID to clone |
| 30 | |
| 31 | Examples: |
| 32 | $(basename "$0") -t ~/.gitlab_token -i 42 |
| 33 | $(basename "$0") --token-file=token.txt --gitlab-url=https://gitlab.com --group-id=123 |
| 34 | EOF |
| 35 | exit 1 |
| 36 | } |
| 37 | |
| 38 | # ── Dependency check ───────────────────────────────────────────────── |
| 39 | for cmd in curl jq git; do |
| 40 | if ! command -v "$cmd" &>/dev/null; then |
| 41 | log_error "Required command not found: ${cmd}" |
| 42 | exit 1 |
| 43 | fi |
| 44 | done |
| 45 | |
| 46 | # ── Parse arguments ────────────────────────────────────────────────── |
| 47 | GITLAB_URL="https://git.mws-team.ru" |
| 48 | TOKEN_FILE="" |
| 49 | GROUP="" |
| 50 | |
| 51 | translated=() |
| 52 | while (( $# )); do |
| 53 | case "$1" in |
| 54 | --help) translated+=("-h"); shift ;; |
| 55 | --token-file=*) translated+=("-t" "${1#*=}"); shift ;; |
| 56 | --gitlab-url=*) translated+=("-g" "${1#*=}"); shift ;; |
| 57 | --group-id=*) translated+=("-i" "${1#*=}"); shift ;; |
| 58 | --) translated+=("--"); shift; translated+=("$@"); break ;; |
| 59 | *) translated+=("$1"); shift ;; |
| 60 | esac |
| 61 | done |
| 62 | set -- "${translated[@]}" |
| 63 | |
| 64 | while getopts ":ht:g:i:" opt; do |
| 65 | case "$opt" in |
| 66 | h) usage ;; |
| 67 | t) TOKEN_FILE="$OPTARG" ;; |
| 68 | g) GITLAB_URL="$OPTARG" ;; |
| 69 | i) GROUP="$OPTARG" ;; |
| 70 | :) log_error "Missing argument for -${OPTARG}"; usage ;; |
| 71 | \?) log_error "Invalid option: -${OPTARG}"; usage ;; |
| 72 | esac |
| 73 | done |
| 74 | shift $((OPTIND - 1)) |
| 75 | |
| 76 | # ── Validate inputs ───────────────────────────────────────────────── |
| 77 | [[ -z "$TOKEN_FILE" ]] && { log_error "Missing --token-file"; usage; } |
| 78 | [[ -z "$GITLAB_URL" ]] && { log_error "Missing --gitlab-url"; usage; } |
| 79 | [[ -z "$GROUP" ]] && { log_error "Missing --group-id"; usage; } |
| 80 | [[ ! -f "$TOKEN_FILE" ]] && { log_error "Token file not found: ${TOKEN_FILE}"; exit 1; } |
| 81 | |
| 82 | # Strip trailing slash from URL |
| 83 | GITLAB_URL="${GITLAB_URL%/}" |
| 84 | |
| 85 | TOKEN="$(<"$TOKEN_FILE")" |
| 86 | readonly TOKEN GITLAB_URL GROUP |
| 87 | |
| 88 | # ── GitLab API helpers ─────────────────────────────────────────────── |
| 89 | |
| 90 | # Perform an authenticated GET request and return the JSON body. |
| 91 | gitlab_get() { |
| 92 | local url="$1" |
| 93 | curl --silent --fail --header "Authorization: Bearer ${TOKEN}" "$url" |
| 94 | } |
| 95 | |
| 96 | # Return total number of pages for a paginated endpoint. |
| 97 | # Usage: gitlab_total_pages "https://gitlab.com/api/v4/..." |
| 98 | gitlab_total_pages() { |
| 99 | local url="$1" |
| 100 | local headers |
| 101 | headers="$(curl -X HEAD --silent -w '%{header_json}' \ |
| 102 | --header "Authorization: Bearer ${TOKEN}" \ |
| 103 | -o /dev/null "$url" 2>/dev/null)" || true |
| 104 | |
| 105 | local pages |
| 106 | pages="$(echo "$headers" | jq -r '."x-total-pages"[0] // "1"')" |
| 107 | # Fallback to 1 if empty or null |
| 108 | [[ -z "$pages" || "$pages" == "null" ]] && pages=1 |
| 109 | echo "$pages" |
| 110 | } |
| 111 | |
| 112 | # Iterate over all pages of a paginated endpoint, streaming JSON arrays. |
| 113 | # Calls a callback function with each item's specified jq field. |
| 114 | # Usage: gitlab_paginate "/api/v4/groups/42/projects?per_page=100" ".[].id" callback_fn |
| 115 | gitlab_paginate() { |
| 116 | local endpoint="$1" |
| 117 | local jq_filter="$2" |
| 118 | local callback="$3" |
| 119 | local extra_params="${4:-}" |
| 120 | |
| 121 | local base_url="${GITLAB_URL}${endpoint}" |
| 122 | [[ -n "$extra_params" ]] && base_url="${base_url}&${extra_params}" |
| 123 | |
| 124 | local total_pages |
| 125 | total_pages="$(gitlab_total_pages "$base_url")" |
| 126 | |
| 127 | local page |
| 128 | for (( page = 1; page <= total_pages; page++ )); do |
| 129 | local response |
| 130 | response="$(gitlab_get "${base_url}&page=${page}")" || { |
| 131 | log_warn "API request failed: ${base_url}&page=${page}" |
| 132 | continue |
| 133 | } |
| 134 | |
| 135 | local item |
| 136 | while IFS= read -r item; do |
| 137 | [[ -z "$item" ]] && continue |
| 138 | "$callback" "$item" |
| 139 | done < <(echo "$response" | jq -r "$jq_filter") |
| 140 | done |
| 141 | } |
| 142 | |
| 143 | # ── Clone a single project ────────────────────────────────────────── |
| 144 | clone_project() { |
| 145 | local project_id="$1" |
| 146 | |
| 147 | local project_json |
| 148 | project_json="$(gitlab_get "${GITLAB_URL}/api/v4/projects/${project_id}")" || { |
| 149 | log_warn "Failed to fetch project ${project_id}" |
| 150 | return |
| 151 | } |
| 152 | |
| 153 | local path_ns ssh_url |
| 154 | path_ns="$(echo "$project_json" | jq -r '.path_with_namespace')" |
| 155 | ssh_url="$(echo "$project_json" | jq -r '.ssh_url_to_repo')" |
| 156 | |
| 157 | if [[ -z "$path_ns" || "$path_ns" == "null" ]]; then |
| 158 | log_warn "Could not determine path for project ${project_id} — skipping" |
| 159 | return |
| 160 | fi |
| 161 | |
| 162 | if [[ -d "$path_ns" ]]; then |
| 163 | log_info "Already cloned: ${path_ns}" |
| 164 | return |
| 165 | fi |
| 166 | |
| 167 | log_info "Cloning: ${path_ns}" |
| 168 | local parent_dir |
| 169 | parent_dir="$(dirname "$path_ns")" |
| 170 | mkdir -p "$parent_dir" |
| 171 | git clone "$ssh_url" "$path_ns" --quiet || log_warn "git clone failed for ${path_ns}" |
| 172 | } |
| 173 | |
| 174 | # ── Clone all projects in a group ──────────────────────────────────── |
| 175 | clone_group_projects() { |
| 176 | local group_id="$1" |
| 177 | log_info "Cloning projects in group ${group_id}" |
| 178 | gitlab_paginate "/api/v4/groups/${group_id}/projects?per_page=100" \ |
| 179 | '.[].id' clone_project "archived=no" |
| 180 | } |
| 181 | |
| 182 | # ── Process a group and all its subgroups recursively ──────────────── |
| 183 | process_group() { |
| 184 | local group_id="$1" |
| 185 | |
| 186 | # Clone direct projects |
| 187 | clone_group_projects "$group_id" |
| 188 | |
| 189 | # Recurse into subgroups |
| 190 | local subgroup_ids=() |
| 191 | while IFS= read -r sid; do |
| 192 | [[ -z "$sid" ]] && continue |
| 193 | subgroup_ids+=("$sid") |
| 194 | done < <( |
| 195 | local total |
| 196 | total="$(gitlab_total_pages "${GITLAB_URL}/api/v4/groups/${group_id}/subgroups?per_page=100")" |
| 197 | for (( p = 1; p <= total; p++ )); do |
| 198 | gitlab_get "${GITLAB_URL}/api/v4/groups/${group_id}/subgroups?per_page=100&page=${p}" \ |
| 199 | | jq -r '.[].id' |
| 200 | done |
| 201 | ) |
| 202 | |
| 203 | for sid in "${subgroup_ids[@]}"; do |
| 204 | log_info "Entering subgroup ${sid}" |
| 205 | process_group "$sid" |
| 206 | done |
| 207 | } |
| 208 | |
| 209 | # ── Main ───────────────────────────────────────────────────────────── |
| 210 | main() { |
| 211 | log_info "GitLab URL: ${GITLAB_URL}" |
| 212 | log_info "Group ID: ${GROUP}" |
| 213 | log_info "Token file: ${TOKEN_FILE}" |
| 214 | echo |
| 215 | |
| 216 | process_group "$GROUP" |
| 217 | |
| 218 | echo |
| 219 | log_ok "Done" |
| 220 | } |
| 221 | |
| 222 | main |