V2EX《请问大家,如果自建 Git 服务器,有什么要注意的、踩坑的》里提到了一个很具体的场景:甲骨文 ARM 4 核 24G 配一台 GreenCloud,想尽可能保存多个 GitHub 账户下的所有仓库和所有数据。可长期运行的做法分为四步:用机器身份生成仓库清单,首次执行 git clone --mirror,定时对已有镜像执行 remote update --prune,最后用 fsck、refs 对账和隔离恢复证明备份可用。
两台服务器不要简单理解成“两份活镜像”。甲骨文可以作为持续从 GitHub 拉取的主镜像,GreenCloud 再保留带版本、不可覆盖的异地副本;否则上游强推、删分支或删标签时,第二台活镜像也会跟着变化。不要把“镜像同步成功”当成“GitHub 全站已经备份”。
先划清“所有仓库”的边界
git clone --mirror 隐含 --bare,没有工作区;它会复制远端通过 Git 协议公开的 refs,并配置上游,让后续 fetch 可以持续更新。 这里所谓“全部 refs”,指 GitHub 实际向当前凭据公开的引用,不包括 GitHub 没有通过普通 Git 传输公开的服务端内部数据。
“所有 GitHub 仓库”首先受身份权限限制。机器账户只能枚举其作为 owner、collaborator 或 organization member 有权访问的仓库。它看不到另一个个人账户的私有仓库,也看不到已经删除、已经失去访问权或由其他 GitHub App 管理且未授权给该身份的仓库。因此,每个 GitHub 身份都要单独扫描并生成清单,凭据身份也要记在清单里。
仓库名还要用 owner/repo 组合。仅用 repo.git 会让不同 owner 下的同名仓库互相覆盖。多凭据同时看到同一个 owner/repo 时,通常是同一个仓库,不要并发更新同一目录;不同凭据看到不同 owner 下的同名仓库,则不会因为嵌套路径而冲突。
“所有数据”也要拆开理解。Git refs、分支、标签以及可传输的可达历史可以由 mirror 保存;LFS 文件、Wiki、Issues、Pull Requests、Release 附件、Actions 产物、Secrets 和子模块内容都需要另外处理。GitHub 账户级设置、组织成员关系、Webhook 和仓库保护规则也不是一条 --mirror 命令能覆盖的。
生成仓库清单
先在实际运行同步任务的 Unix 用户下确认身份:
gh auth status
gh api user --jq .login
有多个 GitHub 身份时,逐个切换并分别扫描:
gh auth switch --user <github-login>
gh api user --jq .login
自动化环境不建议让多个任务同时修改 gh 的全局登录状态。更稳妥的做法是使用独立 Unix 用户、独立 HOME 和独立 GH_TOKEN,或者让 API Token 与 SSH Key 分别归属于对应的机器身份。
GitHub CLI 的 REST 接口可以分页列出当前身份可访问的仓库:
gh api --paginate --method GET /user/repos \
-f affiliation=owner,collaborator,organization_member \
-f type=all \
-f visibility=all \
-f per_page=100
--paginate 会沿分页链接继续请求,type=all 用于包含 Fork。公开、Private、Fork 和 Archived 仓库都可以出现在清单里;Empty 仓库也可能存在,只是没有可检出的分支。
如果清单必须精确记录 empty,REST 列表对象不直接提供这个字段,可以用 GraphQL。下面的查询同时记录 owner、仓库名、HTTPS 与 SSH 地址、可见性、Fork、Archived 和 Empty 状态:
gh api graphql --paginate \
-f query='
query($endCursor: String) {
viewer {
repositories(
first: 100
after: $endCursor
affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]
) {
nodes {
nameWithOwner
owner { login }
sshUrl
visibility
isFork
isArchived
isEmpty
}
pageInfo {
hasNextPage
endCursor
}
}
}
}' \
--jq '.data.viewer.repositories.nodes[] | [
.owner.login,
(.nameWithOwner | split("/")[1]),
("https://github.com/" + .nameWithOwner + ".git"),
.sshUrl,
.visibility,
(.isFork | tostring),
(.isArchived | tostring),
(.isEmpty | tostring)
] | @tsv'
建议把结果保存成 TSV,至少保留 credential_id、github_login、owner、repo、clone URL、可见性、Fork、Archived 和 Empty。私有仓库名称本身也属于敏感信息,清单不要放进公开仓库。
认证方式最好固定一种。HTTPS 模式可以在任务账户下配置 GitHub CLI 的凭据助手:
gh auth setup-git
git ls-remote <repo-url>
Token 应按实际需要授予读取仓库元数据和内容的权限,并设置过期时间。组织启用 SSO 时,还要确认 Token 或机器身份已完成组织授权。Token 不应写进 clone URL、脚本正文或日志,否则很容易进入 shell history、进程参数和备份文件。
SSH 模式则要确认密钥确实属于清单对应的账户:
ssh -vvv -T git@github.com
ssh-add -l
GIT_SSH_COMMAND='ssh -o IdentitiesOnly=yes -i /path/to/key' git ls-remote <repo-url>
组织 Deploy Key 通常只覆盖指定仓库;要覆盖机器身份有权访问的多个仓库,机器账户的 SSH Key 加组织 Team 权限通常更直接。API Token 和 SSH Key 如果属于两个不同身份,清单可能生成成功,但 clone 会返回仓库不存在或无权限。
首次 clone
目录结构先固定为 owner 和 repo 两层:
sudo install -d -m 0750 -o backup -g gitmirror \
/srv/git-mirrors \
/srv/git-mirrors/github
sudo -u backup -H git clone --mirror \
<repo-url> \
/srv/git-mirrors/github/<owner>/<repo>.git
目标目录必须不存在,或者是空目录。已有迁移实践也把空目标作为后续持续同步的前提。 重复执行 clone 时,如果目标不是空目录,Git 会报:
fatal: destination path '...' already exists and is not an empty directory.
这通常不代表备份损坏,而是把“首次创建”命令误用成了“每次同步”命令。先确认现有目录是不是预期的 mirror:
git -C /srv/git-mirrors/github/<owner>/<repo>.git \
rev-parse --is-bare-repository
git -C /srv/git-mirrors/github/<owner>/<repo>.git \
config --get remote.origin.mirror
git -C /srv/git-mirrors/github/<owner>/<repo>.git \
config --get-all remote.origin.fetch
第一个检查应确认是裸仓库,第二个应确认 remote.origin.mirror 已启用,第三个应能看到覆盖全部 refs 的 refspec。
如果它确实是 mirror,后续只做增量更新:
git -C /srv/git-mirrors/github/<owner>/<repo>.git \
remote update --prune
mirror 的更新 refspec 会用远端状态覆盖目标 refs,因此强推、删分支和删标签都可能传播到备份。 这正是 live mirror 的代价:它忠实反映当前上游,却不一定保留历史上曾经存在过的分支。
Empty 仓库即使没有 refs,也应保留这个裸仓库和它的 remote 配置。后续第一次提交出现后,同一个 remote update --prune 就能继续更新,不需要重新创建目录。
服务权限
这里至少有三种身份,不能混在一起:GitHub 凭据负责访问上游,执行 clone/fetch 的 Unix 用户负责写镜像,Web 服务或 Git 平台进程负责读取镜像。
一个常见权限模型是:同步用户拥有仓库,共享组只有读取和目录遍历权限,Web 服务通过共享组读取。上游更新仍由同步用户完成。
sudo usermod -g gitmirror backup
sudo usermod -aG gitmirror <web-service-user>
sudo install -d -m 0750 -o backup -g gitmirror \
/srv/git-mirrors \
/srv/git-mirrors/github
sudo chown -R backup:gitmirror /srv/git-mirrors
sudo chmod -R u+rwX,g+rX,o-rwx /srv/git-mirrors
u+rwX,g+rX,o-rwx 的含义是:所有者可读写,共享组可读,目录对共享组保留遍历权限,其他用户无权限。不要为了省事使用 777。如果 Web 服务直接暴露私有仓库,文件权限不能代替 HTTP、SSH 或平台层的身份认证。
所有权限检查都要使用定时任务的实际运行账户:
sudo -u backup -H git -C \
/srv/git-mirrors/github/<owner>/<repo>.git \
remote update --prune
sudo -u backup -H git -C \
/srv/git-mirrors/github/<owner>/<repo>.git \
fsck --full
如果 Git 报 detected dubious ownership,先检查目录所有者和实际执行用户,不要直接把 safe.directory=* 加进全局配置。更合理的做法是让同步账户真正拥有目录;只有无法调整所有权时,才针对明确路径配置受控的 safe.directory。
如果“自建 Git 服务器”实际是 GitLab、Gitea 等平台,走平台官方的 Import 或 Mirror 流程。不要把裸仓库直接复制进平台内部仓库目录,否则仓库 ID、所有权、权限、Hook 和数据库记录可能不一致。平台自身的配置、数据库和必要数据目录也要单独备份;不同安装方式和版本的数据位置可能不同,应以对应版本的官方备份方式为准。
定时 update
下面的脚本按 Linux、Bash、Git、GitHub CLI 和 GraphQL API 编写。它处理一个 GitHub 身份,每次运行都会重新生成清单:新仓库执行 clone --mirror,已有裸仓库执行 remote update --prune,单个仓库失败不会阻止其他仓库继续处理,最终用非零退出码报告失败。
#!/usr/bin/env bash
set -Eeuo pipefail
umask 0027
export LC_ALL=C
BACKUP_ROOT="${BACKUP_ROOT:-/srv/git-mirrors/github}"
STATE_DIR="${STATE_DIR:-/var/lib/git-mirror}"
LOG_DIR="${LOG_DIR:-/var/log/git-mirror}"
REMOTE_URL_FIELD="${REMOTE_URL_FIELD:-ssh_url}"
for cmd in gh git; do
command -v "$cmd" >/dev/null || {
printf 'required command not found: %s\n' "$cmd" >&2
exit 127
}
done
case "$REMOTE_URL_FIELD" in
clone_url|ssh_url) ;;
*)
printf 'invalid REMOTE_URL_FIELD: %s\n' "$REMOTE_URL_FIELD" >&2
exit 2
;;
esac
mkdir -p "$BACKUP_ROOT" "$STATE_DIR" "$LOG_DIR"
GITHUB_LOGIN="$(gh api user --jq .login)"
CREDENTIAL_ID="${CREDENTIAL_ID:-$GITHUB_LOGIN}"
raw="$(mktemp "$STATE_DIR/repos.raw.XXXXXX")"
manifest="$(mktemp "$STATE_DIR/repos.manifest.XXXXXX")"
incoming=""
cleanup() {
rm -f -- "$raw" "$manifest"
if [[ -n "${incoming:-}" ]]; then
rm -rf -- "$incoming"
fi
}
trap cleanup EXIT
gh api graphql --paginate \
-f query='
query($endCursor: String) {
viewer {
repositories(
first: 100
after: $endCursor
affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]
) {
nodes {
nameWithOwner
owner { login }
sshUrl
visibility
isFork
isArchived
isEmpty
}
pageInfo {
hasNextPage
endCursor
}
}
}
}' \
--jq '.data.viewer.repositories.nodes[] | [
.owner.login,
(.nameWithOwner | split("/")[1]),
("https://github.com/" + .nameWithName + ".git"),
.sshUrl,
.visibility,
(.isFork | tostring),
(.isArchived | tostring),
(.isEmpty | tostring)
] | @tsv' > "$raw"
上面命令中的 .nameWithName 应改为 .nameWithOwner。下面是修正后的完整查询:
gh api graphql --paginate \
-f query='
query($endCursor: String) {
viewer {
repositories(
first: 100
after: $endCursor
affiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]
) {
nodes {
nameWithOwner
owner { login }
sshUrl
visibility
isFork
isArchived
isEmpty
}
pageInfo {
hasNextPage
endCursor
}
}
}
}' \
--jq '.data.viewer.repositories.nodes[] | [
.owner.login,
(.nameWithOwner | split("/")[1]),
("https://github.com/" + .nameWithOwner + ".git"),
.sshUrl,
.visibility,
(.isFork | tostring),
(.isArchived | tostring),
(.isEmpty | tostring)
] | @tsv' > "$raw"
sort -u -o "$raw" "$raw"
printf 'credential_id\tgithub_login\towner\trepo\tclone_url\tssh_url\tvisibility\tfork\tarchived\tempty\n' \
> "$manifest"
failed=0
while IFS=$'\t' read -r owner repo clone_url ssh_url visibility fork archived is_empty; do
if [[ ! "$owner" =~ ^[A-Za-z0-9_.-]+$ ]] ||
[[ ! "$repo" =~ ^[A-Za-z0-9_.-]+$ ]]; then
printf 'invalid owner or repo name: %s/%s\n' "$owner" "$repo" >&2
failed=1
continue
fi
if [[ "$REMOTE_URL_FIELD" == "clone_url" ]]; then
url="$clone_url"
else
url="$ssh_url"
fi
target="$BACKUP_ROOT/$owner/$repo.git"
repo_log="$LOG_DIR/${owner}--${repo}.log"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$CREDENTIAL_ID" "$GITHUB_LOGIN" \
"$owner" "$repo" "$clone_url" "$ssh_url" \
"$visibility" "$fork" "$archived" "$is_empty" \
>> "$manifest"
if [[ -L "$target" ]]; then
printf 'refusing symlink target: %s\n' "$target" >&2
failed=1
continue
fi
if [[ -d "$target" ]] && [[ -z "$(ls -A "$target")" ]]; then
rmdir "$target"
fi
mkdir -p "$(dirname "$target")"
if [[ -e "$target" ]]; then
bare="$(git -C "$target" rev-parse --is-bare-repository 2>/dev/null || true)"
mirror="$(git -C "$target" config --get remote.origin.mirror 2>/dev/null || true)"
if [[ "$bare" != "true" || "$mirror" != "true" ]]; then
printf 'target is not a valid mirror; inspect before replacing: %s\n' \
"$target" >&2
failed=1
continue
fi
if git -C "$target" remote update --prune >>"$repo_log" 2>&1; then
printf 'updated %s/%s\n' "$owner" "$repo"
else
rc=$?
printf 'update failed for %s/%s, exit=%s, log=%s\n' \
"$owner" "$repo" "$rc" "$repo_log" >&2
failed=1
fi
else
incoming="$(mktemp -d "${target}.incoming.XXXXXX")"
if git clone --mirror "$url" "$incoming" >>"$repo_log" 2>&1; then
if mv -- "$incoming" "$target"; then
incoming=""
chmod -R u+rwX,g+rX,o-rwx "$target"
printf 'cloned %s/%s\n' "$owner" "$repo"
else
printf 'failed to move clone into place: %s\n' "$target" >&2
failed=1
fi
else
rc=$?
rm -rf -- "$incoming"
incoming=""
printf 'clone failed for %s/%s, exit=%s, log=%s\n' \
"$owner" "$repo" "$rc" "$repo_log" >&2
failed=1
fi
fi
done < "$raw"
if [[ -f "$STATE_DIR/repos.tsv" ]]; then
missing="$(
comm -23 \
<(awk -F '\t' 'NR > 1 {print $3 "/" $4}' "$STATE_DIR/repos.tsv" | sort -u) \
<(awk -F '\t' 'NR > 1 {print $3 "/" $4}' "$manifest" | sort -u)
)"
if [[ -n "$missing" ]]; then
printf 'inventory shrank; review credential scope or repository deletion:\n%s\n' \
"$missing" >&2
failed=1
fi
fi
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
cp -- "$manifest" "$STATE_DIR/repos.tsv"
cp -- "$manifest" "$STATE_DIR/repos.$stamp.tsv"
chmod 0600 "$STATE_DIR/repos.tsv" "$STATE_DIR/repos.$stamp.tsv"
printf 'sync finished, failed=%s\n' "$failed"
exit "$failed"
脚本故意不会因为某个仓库从清单中消失就删除本地目录。凭据权限被误改、仓库被删除、仓库改名或转移,在 API 层面可能表现相似。脚本会报告清单缩小,但保留目录供人工检查。
默认使用 SSH URL。改成 HTTPS 时,需要让同步账户下的 Git 凭据助手可用,并设置:
REMOTE_URL_FIELD=clone_url
多个身份应分别生成清单,再按 owner/repo 合并处理;不要让两个进程同时更新同一个本地裸仓库。
定时任务可以交给 systemd timer。下面假设脚本位于 /usr/local/sbin/sync-github-mirrors,flock 路径以当前系统的 command -v flock 结果为准:
[Unit]
Description=Synchronize GitHub mirrors
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=backup
Group=gitmirror
WorkingDirectory=/var/lib/git-mirror
Environment=HOME=/var/lib/git-mirror
Environment=PATH=/usr/local/bin:/usr/bin:/bin
UMask=0027
ExecStart=/usr/bin/flock -n /srv/git-mirrors/.sync.lock /usr/local/sbin/sync-github-mirrors
TimeoutStartSec=12h
NoNewPrivileges=true
PrivateTmp=true
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
对应的 timer:
[Unit]
Description=Run GitHub mirror synchronization
[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=15m
Unit=git-mirror-sync.service
[Install]
WantedBy=timers.target
启用并检查:
sudo systemctl daemon-reload
sudo systemctl enable --now git-mirror-sync.timer
systemctl list-timers git-mirror-sync.timer
journalctl -u git-mirror-sync.service
Persistent=true 只能在机器恢复后补跑错过的 calendar 事件,不能恢复停机期间每一个计划周期。调度间隔必须与可接受的 RPO 对齐。
不使用 systemd 时,cron 也要使用绝对路径和同一把锁:
15 3 * * * backup HOME=/var/lib/git-mirror /usr/bin/flock -n /srv/git-mirrors/.sync.lock /usr/local/sbin/sync-github-mirrors >> /var/log/git-mirror/cron.log 2>&1
API 扫描遇到 403 会直接失败,不会拿一份残缺清单继续覆盖现有状态。Token 过期、权限变化、组织 SSO 授权和限流都要进入告警。单个仓库失败时,脚本会继续处理其他仓库,但最终仍返回非零退出码。
按报错排查
下面的检查都要用定时任务的实际 Unix 用户执行。sudo -u root 成功,不能证明 backup 用户也能访问 SSH Key、凭据助手和仓库目录。
| 报错 | 先查什么 | 怎么处理 |
|---|---|---|
fatal: destination path '...' already exists and is not an empty directory. |
目标是不是裸仓库,remote.origin.mirror 是否启用 |
是 mirror 就改用 git -C ... remote update --prune;不是则先人工检查,不要直接删除 |
Permission denied (publickey) |
ssh -vvv -T git@github.com、ssh-add -l、实际使用的 Key 和 GitHub 账户 |
补回正确账户的 Key,检查 Key 是否过期、IdentitiesOnly 是否排除了目标 Key,不要关闭主机指纹检查 |
remote: Repository not found. |
URL 中的 owner/repo、机器身份是否加入组织或协作者、SSH Key 与 API Token 是否属于同一身份 | Private 仓库可能用 404 隐藏未授权状态;用 gh repo view owner/repo 交叉验证 |
API 返回 403 |
gh api rate_limit、响应正文、Token 权限和过期时间、组织 SSO 状态 |
区分限流与授权错误;不要在定时任务中无限快速重试 |
fatal: detected dubious ownership in repository at ... |
stat 查看所有者和权限,当前执行用户是谁 |
修正所有权,或仅对明确路径配置受控的 safe.directory |
fatal: cannot lock ref ... |
df -h、df -i、目录写权限、是否还有另一个 Git 进程 |
先解决磁盘、权限或并发问题;不要在进程仍运行时删除锁文件 |
几个诊断命令可以单独使用:
gh api rate_limit
ssh -vvv -T git@github.com
stat -c '%U:%G %a %n' /srv/git-mirrors/github/<owner>/<repo>.git
df -h
df -i
flock 只能协调同一台机器、同一文件系统上的进程。如果甲骨文和 GreenCloud 同时写一份 NFS 共享目录,不能假设两边的锁一定互相可见。更稳妥的结构是每台机器维护本地裸仓库,再通过不可变快照传递。
Git 之外的数据
GitHub 仓库备份和 GitHub 服务备份需要分开处理。Git 平台自身的配置、数据库和必要数据目录也应纳入备份,并定期验证恢复。
| 数据 | 默认 mirror 是否覆盖 | 额外处理 |
|---|---|---|
| Git refs、分支、标签、可达历史 | 覆盖,但只限远端公开的 refs | 定期做 refs 对账和对象检查 |
| Git LFS | 通常只保存指针,不包含完整 LFS 对象 | 使用 Git LFS 支持的拉取方式保存对象,并单独验证 |
| Wiki | 不包含在主仓库 mirror 中 | Wiki 启用时,把 <owner>/<repo>.wiki.git 当独立 Git 仓库镜像 |
| Issues、PR、评论、标签 | 不包含 | 使用 REST 或 GraphQL 分页导出,并保存附件 |
| Release 元数据和附件 | Git tag 可能已镜像,附件不在 Git 中 | 导出 Release,并单独保存资产 |
| Actions 运行、日志、产物 | 不包含;产物还可能过期 | 在有效期内通过 API 或平台备份功能导出 |
| Secrets、Environment Secrets | 不能靠普通 API 读回明文 | 保留独立的秘密管理系统或加密备份来源 |
| 子模块 | 超级项目只保存子模块 commit 指针 | 根据 .gitmodules 单独镜像子模块,并处理私有子模块权限 |
| 仓库设置、Protection、Webhook、Deploy Keys | 不包含 | 使用 API 或平台配置导出功能单独保存 |
启用的 Wiki 可以这样建立独立镜像:
git clone --mirror \
git@github.com:<owner>/<repo>.wiki.git \
/srv/git-mirrors/github/<owner>/<repo>.wiki.git
LFS 需要单独拉取对象,例如:
git -C /srv/git-mirrors/github/<owner>/<repo>.git lfs fetch --all
git -C /srv/git-mirrors/github/<owner>/<repo>.git lfs fsck
不同 Git LFS 版本对 bare 仓库的处理方式可能不同,应先在测试仓库确认,再用于生产目录。恢复时还要验证 LFS 对象,而不能只看 Git pointer 是否存在。
两台服务器保留不可变副本
甲骨文作为主镜像时,GreenCloud 可以先建立第二份 live mirror:
git clone --mirror \
backup@oracle:/srv/git-mirrors/github/<owner>/<repo>.git \
/srv/git-mirrors/github/<owner>/<repo>.git
git -C /srv/git-mirrors/github/<owner>/<repo>.git \
remote update --prune
但这只能应对单机故障,不能保留已经被上游删除的 refs。删分支、删标签和强推会随着 mirror 更新传播;删除整个 GitHub 仓库则是另一种情况:远端 URL 变得不可访问,ls-remote 和 fetch 会失败,本地目录通常不会被自动删除,但也不能因此宣称它仍在持续备份。
GreenCloud 应该在每次成功同步后生成一个带时间、不可覆盖的版本。例如使用 Git 原生 bundle 保存某个时点的 refs 与可达历史:
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
git -C /srv/git-mirrors/github/<owner>/<repo>.git \
bundle create \
"/srv/git-snapshots/${owner}--${repo}--${stamp}.bundle" \
--all
Empty 仓库不能按普通非空仓库生成 bundle,需要连同裸仓库配置、清单和 Empty 状态单独保存。bundle 也不包含 LFS、Issues、Release 附件和 Secrets。
更直接的方案是使用文件系统版本快照、对象存储版本控制或 WORM 存储,但必须保证 refs 与 packfiles 来自同一个一致时点。不要用持续覆盖旧对象的同步方式冒充不可变副本。
GreenCloud 还要保存 SSH 主机密钥、服务配置、同步脚本、systemd unit、仓库清单和恢复说明。Token、SSH 私钥与秘密管理系统需要加密备份,并限制读取权限。 如果担心甲骨文主机被入侵后污染 GreenCloud,可以让 GreenCloud 直接从 GitHub 拉取;代价是需要在两处维护 Token。无论采用哪种拓扑,都需要保留不可变版本。
refs 对账
目录存在只能证明同步脚本跑过,不能证明 refs 和对象完整。先在镜像目录执行:
repo="${REPO:?set REPO to the bare repository path}"
git -C "$repo" fsck --full
git -C "$repo" count-objects -vH
git fsck --full 用于检查对象连通性和仓库完整性。Empty 仓库可能表现为未出生的 HEAD,这与空仓库状态一致;其他错误则需要处理。
然后用同一身份的 URL 读取远端 refs,并与本地 refs 规范化后比较:
repo="${REPO:?set REPO to the bare repository path}"
url="${REPO_URL:?set REPO_URL to the authenticated clone URL}"
audit="$(mktemp -d)"
git ls-remote --refs "$url" |
awk '{print $1, $2}' |
LC_ALL=C sort > "$audit/remote.refs"
git -C "$repo" for-each-ref \
--format='%(objectname) %(refname)' |
LC_ALL=C sort > "$audit/local.refs"
diff -u "$audit/remote.refs" "$audit/local.refs"
--refs 不包含 HEAD,默认分支应另外检查:
git ls-remote --symref "$url" HEAD
git -C "$repo" symbolic-ref HEAD
如果 diff 有差异,先确认是不是对账期间上游又发生了更新,再重新执行一次 remote update --prune。本地存在远端没有的 refs 时,也不要立即删除:可能是额外维护的恢复引用,也可能是上游刚删掉的分支,需要结合快照决定。
refs 对账证明“当前上游指向的提交在备份里”,fsck 检查对象完整性;真正的恢复能力还要靠下一步实际克隆。
实际恢复
本地路径 clone 默认可能使用硬链接复用对象文件,这会掩盖某些缺失对象问题。恢复演练使用 --no-local,强制通过传输重新组装对象:
repo="${REPO:?set REPO to the bare repository path}"
restore_root="${RESTORE_ROOT:-$(mktemp -d)}"
git clone --mirror --no-local \
"$repo" \
"$restore_root/repo.git"
git -C "$restore_root/repo.git" fsck --full
git clone --no-local \
"$restore_root/repo.git" \
"$restore_root/worktree"
git -C "$restore_root/worktree" checkout "${CHECKOUT_REF:?set CHECKOUT_REF to a branch or tag}"
git -C "$restore_root/worktree" branch --all
git -C "$restore_root/worktree" tag --list
git -C "$restore_root/worktree" submodule status --recursive
除了默认分支,还应选择几个代表性分支、Tag 和较老提交做检出。带子模块的仓库要单独确认 .gitmodules 中的 URL 仍可访问;LFS 仓库还要验证大文件对象。恢复到 GitLab、Gitea 等平台时,继续使用官方导入流程,避免直接修改平台内部仓库所有权。
演练时要记录两个数字:
RPO 是允许丢失多长时间的数据,主要由同步周期和凭据失效后的发现速度决定。RTO 是从宣布恢复到仓库重新可用所需的时间,包括准备环境、传输对象、检出分支和验证业务构建。每次演练都记录实际耗时、服务账号权限问题以及缺失的 Secret 或附件;只检查 git clone 返回成功,不能代表完整恢复。
实际落地时,先完成以下三项:
- 分别用每个 GitHub 身份导出仓库清单,确认
owner/repo、可见性、Fork、Archived 和 Empty 状态。 - 选一个 Private 仓库,以定时任务账户完成首次
clone --mirror,再手动运行一次同步脚本并检查权限和退出码。 - 在隔离目录执行一次
--no-local恢复,完成fsck、refs 对账和分支检出,记录这次恢复的 RPO 与 RTO。












暂无评论内容