-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackup_github_full.sh
More file actions
320 lines (287 loc) · 12.2 KB
/
backup_github_full.sh
File metadata and controls
320 lines (287 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
#!/usr/bin/env bash
# =============================================================================
# GitHub Full/Lite Backup (Bash)
# - 메시지는 영어 출력, 주석은 한국어
# - 토큰 없으면 LITE: 코드 + Wiki 백업만 수행
# - 토큰 있으면 FULL: LITE + 메타데이터(issues/comments, pulls/comments/reviews,
# labels, milestones, releases+assets, (옵션) discussions)까지 백업
# - 실행 전 모드 안내 및 y/N 확인, 백업 폴더 존재 시 덮어쓰기 y/N 확인
# - 의존성: git, curl (FULL 모드: jq 필요), zip 또는 tar (압축 옵션 사용 시)
# 사용법:
# ./backup_github_full.sh <RepoUrl> [--out-dir DIR] [--token TOKEN] [--zip] [--include-discussions]
# 예:
# ./backup_github_full.sh https://github.com/user/repo.git
# ./backup_github_full.sh https://github.com/user/repo.git --out-dir /data/gh
# ./backup_github_full.sh https://github.com/user/repo.git --token ghp_xxx --zip --include-discussions
# GITHUB_TOKEN=ghp_xxx ./backup_github_full.sh https://github.com/user/repo.git
# =============================================================================
set -u # 정의 안 된 변수를 오류로 처리
# set -e # 치명적 오류 시 즉시 종료 (프롬프트/분기 흐름을 위해 생략)
# ---------- 공통 유틸 (한국어 주석) -------------------------------------------
say_section() { # 구분선과 섹션 타이틀 출력
echo "--------------------------------------------------"
echo "$1"
echo "--------------------------------------------------"
}
need_cmd() { # 명령 유무 확인
if ! command -v "$1" >/dev/null 2>&1; then
echo "[ERROR] '$1' is not installed or not in PATH." >&2
exit 1
fi
}
url_basename_no_git() { # URL에서 .git 제거하고 마지막 이름 추출
local u="$1"
u="${u%.git}"
# 마지막 / 이후 이름
basename "$u"
}
build_wiki_url() { # Wiki URL 생성
local u="$1"
if [[ "$u" == *.git ]]; then
echo "${u%.git}.wiki.git"
else
echo "${u%.git}.wiki.git"
fi
}
join_path() { # 간단한 경로 결합
local base="$1"; shift
local leaf="$1"
if [[ "$base" == */ ]]; then
echo "${base}${leaf}"
else
echo "${base}/${leaf}"
fi
}
ensure_clean_dir_with_prompt() { # 폴더 존재 시 덮어쓰기 여부 묻고 정리
local dir="$1"
if [[ -d "$dir" ]]; then
echo "The backup folder already exists: \"$dir\""
read -r -p "Do you want to OVERWRITE it? [y/N]: " ans
case "$ans" in
y|Y|yes|YES) ;;
*) echo "Aborted. Nothing was changed."; exit 0 ;;
esac
echo "Removing existing folder..."
rm -rf -- "$dir" || { echo "[ERROR] Failed to remove the folder."; exit 1; }
fi
mkdir -p -- "$dir" || { echo "[ERROR] Failed to create the backup folder."; exit 1; }
}
rest_paged_to_file() { # REST 페이지네이션 처리 → 하나의 JSON 배열 파일로 저장 (jq 필요)
# 인자: $1=url (쿼리 유무 무관), $2=token, $3=outfile
local base_url="$1" token="$2" out="$3"
local page=1 tmpdir
tmpdir="$(mktemp -d)"
local files=()
while :; do
local sep='?'
[[ "$base_url" == *\?* ]] && sep='&'
local u="${base_url}${sep}per_page=100&page=${page}"
# 헤더: Authorization / User-Agent
if ! curl -fsSL -H "Authorization: token ${token}" -H "User-Agent: backup-script" \
"$u" -o "${tmpdir}/p${page}.json"; then
break
fi
# 빈 배열/객체인지 판단 (길이가 매우 짧으면 종료 가정)
if [[ ! -s "${tmpdir}/p${page}.json" ]]; then
break
fi
files+=("${tmpdir}/p${page}.json")
# Link 헤더 파싱 위해 -I만 별도 호출 → rel="next" 없으면 종료
local headers
headers="$(curl -fsSIL -H "Authorization: token ${token}" -H "User-Agent: backup-script" "$u" 2>/dev/null || true)"
if ! grep -qi 'rel="next"' <<<"$headers"; then
break
fi
page=$((page+1))
done
if [[ "${#files[@]}" -eq 0 ]]; then
echo "[]" >"$out"
else
# 여러 페이지 배열을 하나로 합치기
jq -s 'add' "${files[@]}" >"$out"
fi
rm -rf -- "$tmpdir"
}
download_asset() { # 릴리스 asset 다운로드
local url="$1" token="$2" dst="$3"
curl -fsSL -H "Authorization: token ${token}" -H "User-Agent: backup-script" \
"$url" -o "$dst"
}
zip_folder() { # 백업 폴더 압축: zip 있으면 zip, 없으면 tar.gz
local src="$1" zipname="$2"
if command -v zip >/dev/null 2>&1; then
(cd "$(dirname "$src")" && zip -r "$zipname" "$(basename "$src")" >/dev/null)
echo "ZIP created: $(join_path "$(dirname "$src")" "$zipname")"
else
(cd "$(dirname "$src")" && tar -czf "${zipname%.zip}.tar.gz" "$(basename "$src")")
echo "TAR.GZ created: $(join_path "$(dirname "$src")" "${zipname%.zip}.tar.gz")"
fi
}
# ---------- 인자 파싱 (한국어 주석) --------------------------------------------
REPO_URL=""
OUT_DIR="."
TOKEN="${GITHUB_TOKEN:-}"
ZIP_FLAG="no"
INCLUDE_DISCUSSIONS="no"
while [[ $# -gt 0 ]]; do
case "$1" in
--out-dir|-o) OUT_DIR="$2"; shift 2 ;;
--token|-t) TOKEN="$2"; shift 2 ;;
--zip) ZIP_FLAG="yes"; shift ;;
--include-discussions) INCLUDE_DISCUSSIONS="yes"; shift ;;
-h|--help)
echo "Usage: $0 <RepoUrl> [--out-dir DIR] [--token TOKEN] [--zip] [--include-discussions]"
exit 0 ;;
*)
if [[ -z "$REPO_URL" ]]; then
REPO_URL="$1"; shift
else
echo "[ERROR] Unknown argument: $1" >&2; exit 1
fi
;;
esac
done
if [[ -z "$REPO_URL" ]]; then
echo "Usage: $0 <RepoUrl> [--out-dir DIR] [--token TOKEN] [--zip] [--include-discussions]"
exit 1
fi
# ---------- 사전 점검 (한국어 주석) --------------------------------------------
need_cmd git
need_cmd curl
REPO_NAME="$(url_basename_no_git "$REPO_URL")"
BACKUP_DIR="$(join_path "$OUT_DIR" "${REPO_NAME}_backup")"
CODE_BARE="$(join_path "$BACKUP_DIR" "${REPO_NAME}.git")"
WIKI_BARE="$(join_path "$BACKUP_DIR" "${REPO_NAME}.wiki.git")"
MODE="LITE"
if [[ -n "$TOKEN" ]]; then
MODE="FULL"
fi
# 모드 안내 및 실행 확인
say_section "Detected mode: ${MODE}"
if [[ "$MODE" == "FULL" ]]; then
echo "This will back up: Code+Wiki+Metadata (issues, issue comments, PRs, PR comments/reviews, labels, milestones, releases+assets)."
# FULL 모드에는 jq 필요
if ! command -v jq >/dev/null 2>&1; then
echo "[ERROR] 'jq' is required in FULL mode. Please install jq (e.g., apt install jq, brew install jq)."
exit 1
fi
else
echo "This will back up: Code+Wiki only (no metadata). Set GITHUB_TOKEN or --token to enable FULL mode."
fi
read -r -p "Proceed with this mode? [y/N]: " CONFIRM
case "$CONFIRM" in
y|Y|yes|YES) ;;
*) echo "Aborted. Nothing was changed."; exit 0 ;;
esac
# 백업 폴더 준비
say_section "[1/6] Preparing backup folder: ${BACKUP_DIR}"
ensure_clean_dir_with_prompt "$BACKUP_DIR"
# 코드 미러
say_section "[2/6] Backing up main repository (mirror: all branches/tags)"
if ! git clone --mirror "$REPO_URL" "$CODE_BARE"; then
echo "[ERROR] Failed to back up the main repository."
exit 1
fi
# 위키 확인/백업
say_section "[3/6] Checking Wiki"
WIKI_URL="$(build_wiki_url "$REPO_URL")"
echo "Wiki URL: ${WIKI_URL}"
if git ls-remote "$WIKI_URL" >/dev/null 2>&1; then
echo "Wiki repository found. Backing up Wiki..."
if ! git clone --mirror "$WIKI_URL" "$WIKI_BARE"; then
echo "[WARNING] Failed to back up Wiki repository. Main repository backup succeeded."
fi
else
echo "Wiki repository not found or inaccessible. Skipping..."
fi
# 메타데이터 (FULL 모드)
if [[ "$MODE" == "FULL" ]]; then
META_DIR="$(join_path "$BACKUP_DIR" "meta")"
ISSUES_DIR="$(join_path "$META_DIR" "issues")"
PULLS_DIR="$(join_path "$META_DIR" "pulls")"
RELS_DIR="$(join_path "$META_DIR" "releases")"
DISC_DIR="$(join_path "$META_DIR" "discussions")"
mkdir -p "$META_DIR" "$ISSUES_DIR/comments" "$PULLS_DIR/comments" "$PULLS_DIR/reviews" "$RELS_DIR/assets"
# 소유자/저장소 파싱
# 예: https://github.com/owner/repo(.git)
TMP="${REPO_URL%.git}"
OWNER="$(echo "$TMP" | awk -F'github.com/' '{print $2}' | awk -F'/' '{print $1}')"
REPO="$(echo "$TMP" | awk -F'github.com/' '{print $2}' | awk -F'/' '{print $2}')"
say_section "[4/6] Exporting repository metadata (basic, labels, milestones)"
curl -fsSL -H "Authorization: token ${TOKEN}" -H "User-Agent: backup-script" \
"https://api.github.com/repos/${OWNER}/${REPO}" -o "${META_DIR}/repository.json"
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/labels" "$TOKEN" "${META_DIR}/labels.json"
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/milestones?state=all" "$TOKEN" "${META_DIR}/milestones.json"
say_section "[5/6] Exporting issues and pull requests (with comments/reviews)"
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/issues?state=all&filter=all" "$TOKEN" "${ISSUES_DIR}/issues.json"
# 이슈 번호 중 PR이 아닌 것만 추출하여 코멘트 저장
mapfile -t ISSUE_NUMS < <(jq -r '.[] | select(.pull_request|not) | .number' "${ISSUES_DIR}/issues.json")
for num in "${ISSUE_NUMS[@]}"; do
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/issues/${num}/comments" "$TOKEN" "${ISSUES_DIR}/comments/${num}.comments.json"
done
# PR 본문/코멘트/리뷰
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/pulls?state=all" "$TOKEN" "${PULLS_DIR}/pulls.json"
mapfile -t PR_NUMS < <(jq -r '.[].number' "${PULLS_DIR}/pulls.json")
for num in "${PR_NUMS[@]}"; do
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/pulls/${num}/comments" "$TOKEN" "${PULLS_DIR}/comments/${num}.comments.json"
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/issues/${num}/comments" "$TOKEN" "${PULLS_DIR}/comments/${num}.issue-comments.json"
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/pulls/${num}/reviews" "$TOKEN" "${PULLS_DIR}/reviews/${num}.reviews.json"
done
say_section "[6/6] Exporting releases (and assets)"
rest_paged_to_file "https://api.github.com/repos/${OWNER}/${REPO}/releases" "$TOKEN" "${RELS_DIR}/releases.json"
# 릴리스 자산 다운로드
COUNT_ASSETS=$(jq 'map(.assets // []) | flatten | length' "${RELS_DIR}/releases.json")
if [[ "$COUNT_ASSETS" -gt 0 ]]; then
# 릴리스별 자산 저장 폴더 생성 후 다운로드
mapfile -t REL_IDS < <(jq -r '.[].id' "${RELS_DIR}/releases.json")
for rid in "${REL_IDS[@]}"; do
mkdir -p "${RELS_DIR}/assets/${rid}"
# 해당 릴리스의 자산만 추출
mapfile -t ASSET_NAMES < <(jq -r ".[] | select(.id==${rid}) | .assets[]?.name" "${RELS_DIR}/releases.json")
mapfile -t ASSET_URLS < <(jq -r ".[] | select(.id==${rid}) | .assets[]?.browser_download_url" "${RELS_DIR}/releases.json")
for ((i=0; i<${#ASSET_URLS[@]}; i++)); do
an="${ASSET_NAMES[$i]}"; du="${ASSET_URLS[$i]}"
[[ -z "$du" ]] && continue
echo "Downloading asset: $an"
download_asset "$du" "$TOKEN" "${RELS_DIR}/assets/${rid}/${an}"
done
done
fi
# Discussions (옵션)
if [[ "$INCLUDE_DISCUSSIONS" == "yes" ]]; then
say_section "[Extra] Exporting discussions (experimental)"
mkdir -p "$DISC_DIR"
read -r -d '' GQL <<'EOF'
{
"query": "query($owner:String!,$repo:String!){ repository(owner:$owner,name:$repo){ discussions(first:100, orderBy:{field:CREATED_AT, direction:ASC}){ nodes{ id number title body createdAt author { login } url } pageInfo{ hasNextPage endCursor }}}}",
"variables": { "owner": "__OWNER__", "repo": "__REPO__" }
}
EOF
# 변수 치환
GQL_PAYLOAD="${GQL/__OWNER__/${OWNER}}"
GQL_PAYLOAD="${GQL_PAYLOAD/__REPO__/${REPO}}"
curl -fsSL -H "Authorization: bearer ${TOKEN}" -H "User-Agent: backup-script" \
-H "Content-Type: application/json" \
-d "$GQL_PAYLOAD" \
https://api.github.com/graphql > "${DISC_DIR}/discussions.json"
fi
else
say_section "[Info] Running in LITE mode (Code+Wiki only). Metadata is skipped."
fi
# 압축 옵션
if [[ "$ZIP_FLAG" == "yes" ]]; then
zip_folder "$BACKUP_DIR" "$(basename "$BACKUP_DIR").zip"
fi
say_section "[Done] Backup finished (${MODE} mode)"
echo "Backup location: ${BACKUP_DIR}"
echo " - Repository (mirror): ${CODE_BARE}"
if [[ -d "$WIKI_BARE" ]]; then
echo " - Wiki (mirror): ${WIKI_BARE}"
else
echo " - Wiki: (none)"
fi
if [[ "$MODE" == "FULL" ]]; then
echo " - Metadata: ${BACKUP_DIR}/meta"
else
echo " - Metadata: (skipped)"
fi