git clone –mirror 怎么备份 GitHub?

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 返回成功,不能代表完整恢复。

实际落地时,先完成以下三项:

  1. 分别用每个 GitHub 身份导出仓库清单,确认 owner/repo、可见性、Fork、Archived 和 Empty 状态。
  2. 选一个 Private 仓库,以定时任务账户完成首次 clone --mirror,再手动运行一次同步脚本并检查权限和退出码。
  3. 在隔离目录执行一次 --no-local 恢复,完成 fsck、refs 对账和分支检出,记录这次恢复的 RPO 与 RTO。
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容