1. 为什么我会把这件小事做成脚本
先说场景。做鸿蒙开发或者研究操作系统的同学,应该都体会过这种状态:本地仓库里只有一个默认分支,但远程仓库明明维护着几十个分支,不同的分支对应不同的版本演进、芯片适配和特性组合。你需要对比某个历史分支的行为差异,或者要验证某个功能在不同分支上的表现,于是就得挨个git clone。第一次手动拉三五个分支还能接受,等你发现分支列表里有几十个条目、每个分支拉下来都要跑几分钟甚至十几分钟的时候,你一定会和我一样,想把这件重复劳动用脚本化掉。
我当时做这个“一键自动化下载鸿蒙各个分支代码”的项目,出发点非常简单:把“列出远程所有分支、逐个创建本地工作目录、逐个克隆、及时跳过已存在的目录、失败后能续跑”这一串动作,封装成一个不需要反复确认的脚本,扔到终端里执行,人就可以去干别的事。脚本跑完后,每个分支都有自己独立的工作目录,切分支、对比代码、单独编译都不会互相污染。
这个需求适合谁?第一类是做系统定制和版本对比的开发者,他们需要同时维护多份代码做差异分析;第二类是学习鸿蒙整体架构的人,想看清不同分支里厂商适配层的演化;第三类是CI基础设施维护者,需要定期把远端分支同步到本地镜像环境。无论哪一种,脚本的核心逻辑都相通,只是扩展点不同。下面我把整个设计思路和踩过的坑完整写出来,你可以直接拿去用。
2. 两条主线思路:镜像仓库 vs 分支工作树
动手写脚本之前,我先把技术路线想清楚了。这个项目表面上是“下载多个分支”,但实现路径至少有三种,而且差别很大,选错了后面会非常痛苦。
2.1 路线一:整仓镜像,只拉一次
一条路是git clone --mirror。这个命令会把远程仓库的所有分支、所有标签、所有历史打包成一份裸仓库。优点非常明显:一次网络传输搞定全部内容,之后想看哪个分支都行,git branch -a能直接列出来。缺点也很明显:裸仓库不等于可以编辑的工作目录,要真正看代码、编译,还得从这份裸仓库里git checkout出工作树,而有些分支本身构建依赖特定环境,这样来回折腾反而绕路。
另外,如果远程仓库体积很大,比如包含了几百MB甚至上GB的二进制产物和历史对象,那即使你只需要其中一个分支的最新代码,也不得不把全量历史都搬回来。对鸿蒙这种体量偏大的代码仓库来说,网络开销相当可观。所以镜像路线适合“必需完整历史”的场景,不适合只想快速拿到每个分支快照的场景。
2.2 路线二:逐分支克隆,各占一摊
另一条路就是逐分支克隆,也就是我最开始采用的方案。对远程仓库的每个refs/heads/*分支,单独执行一次git clone --branch 分支名 --single-branch --depth 1。这样每个分支就是一个独立工作树,编译、修改、对比都很直白。代价是:如果有N个分支,就要重复下载N次共享的历史对象,本质上是拿存储空间和重复网络流量换使用方便。
实际操作中我一般会用--depth 1做浅克隆,这样每个分支只保存一条最新提交,不下载历史,网络开销能小一大截。对只需查看和编译最新代码的场景完全够用,缺点是无法看历史提交记录,需要时再单独git fetch --unshallow补全即可。这两个方案之间可以根据自己的真实需求切换,而不是非黑即白。
2.3 路线三:克隆一次,多份工作树
还有一条值得单独说的路线:git worktree add。思路是只克隆一份完整仓库(或浅克隆一份),然后利用git worktree add在同一仓库下挂出多个分支的工作目录。这样做的好处非常明显:对象存储只有一份,工作目录却可以随意切换不同的分支,而且互不影响。非常适合“同一个仓库需要同时打开多个分支做代码对比”的场景。
但git worktree也有它的繁琐之处:每新加一个分支,要手动执行一次git worktree add命令;长期不用还要记得git worktree prune清理;和一次性脚本相比,它更像一个“持续管理的工具”,而不是“一次拿完所有分支的批处理任务”。我最终做的脚本把主流程定位在逐分支克隆,但在脚本里预留了切换到worktree模式的参数,两种场景都覆盖到了。
3. 核心脚本实现与关键参数
这一节我直接给出脚本主体,然后拆开讲每一步为什么这样写。整个项目我建议用 Bash 实现,原因很朴素:git本身就是命令行工具,Bash 对管道、文本处理、后台任务支持得最自然,不依赖额外的解释器环境;如果后续要加更复杂的并发调度,可以再迁移到 Python。
3.1 第一版顺序克隆脚本
先看第一版脚本骨架,功能就是“列出所有分支,挨个克隆”:
#!/usr/bin/env bash set -euo pipefail REMOTE="https://your-git-host.example/OpenHarmony/manifest.git" BASE_DIR="${HOME}/harmony-branches" mkdir -p "${BASE_DIR}" # 取远端所有分支名 BRANCHES=$(git ls-remote --heads "${REMOTE}" | awk '{print $2}' | sed 's#refs/heads/##') for branch in ${BRANCHES}; do target="${BASE_DIR}/${branch}" if [[ -d "${target}/.git" ]]; then echo "[skip] ${branch} already exists" continue fi echo "[clone] ${branch}" git clone \ --branch "${branch}" \ --single-branch \ --depth 1 \ "${REMOTE}" "${target}" done这段脚本里值得留意的细节有这些:
set -euo pipefail必须放在开头。它保证脚本在遇到 Git 命令失败、管道中间出错、变量未定义时都能及时退出,而不是带着错误继续往下跑。尤其是批量拉取场景,一旦远端某个分支被删除或网络抖动导致第10个分支克隆失败了,你不希望第11到第40个分支还在傻傻地重试。git ls-remote --heads只取分支引用,不带标签。如果直接用git ls-remote不带参数,会连几十个标签一起返回,白白增加网络请求,也容易把分支列表和标签列表混在一起。加上--heads之后,输出格式就是两列:第一列是 commit SHA,第二列是refs/heads/xxx。sed 's#refs/heads/##'把refs/heads/前缀剥掉,得到干干净净的分支名。这里我用#作为sed的分隔符,而不是默认的/,因为分支名里可能含有/,如果用/做分隔符,遇到feature/xxx这种分支名时替换逻辑就会出错。这个坑我踩过一次。
3.2 关键参数的选择逻辑
有几个git clone参数,我在不同阶段做了不同取舍,单独拿出来说一下:
--single-branch加上之后,克隆只拉指定分支,其他分支的引用不会下载,节省的对象数据量很可观。如果仓库启用了默认的“拉全部分支”策略,那逐分支克隆本身就失去了空间优势。
--depth 1的作用是只拉取最新一条提交。对一个历史动辄几千次提交的仓库来说,这个参数能省掉绝大部分历史对象。我第一次跑的时候没加这个参数,结果一个仓库的全量历史占了接近两个G,加了--depth 1之后降到了三百多M,效果立竿见影。代价刚才也说了,拿不到历史,不过需要用历史时随时可以git fetch --unshallow单独补,不会造成永久性损失。
--no-tags这个参数我一开始没加,后来发现每次克隆默认会顺带把所有可达的标签都拉下来。逐分支克隆的场景下,标签几乎是冗余的,因为每个标签我都无法在某一个具体分支上下文中使用。加上--no-tags进一步压缩传输量。
3.3 给脚本加入分支过滤配置
鸿蒙仓库里的分支不是每一个都需要拉。有些是内部开发分支,有些是废弃的试验分支,还有一些命名规则特殊,比如以release/开头的是发布分支,以dev/开头的是开发分支。为了不把整个分支列表都拷下来,我在脚本里加了包含和排除两个过滤区域:
INCLUDE_PATTERNS="^release/ ^feature/" EXCLUDE_PATTERNS="deprecated old-" BRANCHES=$(git ls-remote --heads "${REMOTE}" | awk '{print $2}' | sed 's#refs/heads/##') filtered="" for b in ${BRANCHES}; do skip=0 for pat in ${EXCLUDE_PATTERNS}; do if [[ "${b}" == ${pat}* ]]; then skip=1 break fi done if [[ ${skip} -eq 1 ]]; then continue fi if [[ -z "${INCLUDE_PATTERNS}" ]]; then filtered+="${b}\n" continue fi for pat in ${INCLUDE_PATTERNS}; do if [[ "${b}" == ${pat}* ]]; then filtered+="${b}\n" break fi done done过滤逻辑本身不复杂,但关键作用很大:少拉一个废弃分支,这几天就能省下好几G流量和磁盘。而且对分支名的匹配我是用通配符前缀匹配,而不是纯正则,因为分支名的实际命名风格更接近前缀分组,release/1.0、release/2.0这种模式用前缀匹配一眼就能看懂,也避免把正则写复杂。
4. 工程化改造:并发拉取、日志与断点续传
顺序克隆写起来容易,但实际体验很糟糕。几十个分支挨个拉,每个分支少则一两分钟,多则十几分钟,全部跑完可能要一两个小时。为了把时长压缩进去,我做三轮工程化改造:并发执行、实时日志、断点续传。
4.1 并发拉取的控制策略
最简单的并发做法是用xargs -P,指定同时跑几个克隆任务。我最初写的是:
printf "%s\n" "${BRANCHES}" | xargs -P 4 -I {} \ bash -c '... git clone --branch "$1" --single-branch --depth 1 "${REMOTE}" "$2" ...' _ {} "${BASE_DIR}/{}"但这里有一个必须注意的坑:xargs对含空格的参数处理不友好,而分支名里偶尔会有空格或特殊字符。更稳妥的方案是用while read配合后台子进程,或者用xargs -0处理。我最终改成显式控制并发数的写法,用一个简单的计数变量限制后台任务数量:
MAX_JOBS=4 jobs=0 while read -r branch; do [[ -z "${branch}" ]] && continue ( target="${BASE_DIR}/${branch}" if [[ ! -d "${target}/.git" ]]; then git clone --branch "${branch}" --single-branch --depth 1 "${REMOTE}" "${target}" >/dev/null 2>&1 fi ) & jobs=$((jobs + 1)) if [[ ${jobs} -ge ${MAX_JOBS} ]]; then wait jobs=0 fi done <<< "${BRANCHES}" waitwait在这里的作用是等当前这一批后台任务全部结束,再启动下一批。MAX_JOBS建议设在 2 到 6 之间。太小等于没并发,太大容易触发远端服务器限流,甚至因为同时打开太多 SSH 连接导致被短暂封禁。我在一个中等规模的仓库上测过,-P 4和-P 8的耗时差距并不大,瓶颈最后都落在带宽上,而并发过高时的失败率反而明显上升,所以 4 是甜点值。
并发还有一个容易忽略的副作用:多个git clone同时启动,如果中途一个失败,set -e在子进程里不会自动终止整个脚本,因为wait的返回值不会触发退出。这就需要配合下面的日志和失败统计来处理。
4.2 日志拆分为成功与失败两种
批量任务的日志不适合一股脑全塞到终端里。我的做法是在BASE_DIR下建一个logs目录,每个分支单独一个日志文件,然后脚本结束时汇总失败列表:
LOG_DIR="${BASE_DIR}/logs" mkdir -p "${LOG_DIR}" while read -r branch; do ... ( if git clone ... >"${LOG_DIR}/${branch}.log" 2>&1; then echo "[ok] ${branch}" echo "${branch}" >> "${LOG_DIR}/success.txt" else echo "[fail] ${branch}" echo "${branch}" >> "${LOG_DIR}/fail.txt" fi ) & done <<< "${BRANCHES}" wait if [[ -f "${LOG_DIR}/fail.txt" ]]; then echo "以下分支拉取失败:" cat "${LOG_DIR}/fail.txt" fi这样设计的价值在于:失败时你不用翻几百行滚动日志找是哪个分支挂了,直接看fail.txt就能知道要重跑哪几个;重跑时只要再次执行脚本,脚本会自动跳过已经存在的目录并重试失败分支。这个闭环是我用了很多次之后才打磨出来的,比一次性把所有输出糊到屏幕上实用得多。
4.3 断点续传的边界情况处理
我用[[ -d "${target}/.git" ]]来判断一个分支是否已经克隆成功,正常情况没问题。但如果上次克隆在中途被 Ctrl-C 打断,target/.git目录其实已经创建了,目录存在这个条件就失效。为了防止这种情况,我在目标目录里放一个锁文件,克隆成功后删除:
LOCK_FILE="${target}/.clone-incomplete" if [[ -d "${target}/.git" && ! -f "${LOCK_FILE}" ]]; then echo "[skip] ${branch} exists, no lock file" continue fi if [[ ! -d "${target}/.git" ]]; then mkdir -p "$(dirname "${target}")" touch "${LOCK_FILE}" git clone --branch "${branch}" --single-branch --depth 1 "${REMOTE}" "${target}" rm -f "${LOCK_FILE}" fi补充一句,--depth 1的仓库是这个判断逻辑的前提:浅克隆的仓库目录里一定有.git目录。如果哪天你把--depth参数去掉,判断条件要同步调整,改成检查.git/HEAD存在且git -C "$target" rev-parse HEAD能正常输出版本号,那就代表克隆成功了。
5. 常见问题与排查技巧实录
这部分我结合实际操作中反复遇到的坑整理成表,按发生率从高到低排列,每一行都是我自己或者同事实测过的真实情况。
| 问题现象 | 根本原因 | 解决办法 |
|---|---|---|
| 克隆过程中终端卡在密码输入,脚本整体停住 | SSH 或 HTTPS 需要交互认证,而git clone在非交互环境下会等待输入 | 脚本开头设置export GIT_TERMINAL_PROMPT=0,一旦需要交互直接失败而不是挂起;或在本地配置好 SSH key / credential helper |
| 某个分支克隆成功后,工作目录里大量文件显示已修改 | core.autocrlf在 Windows 下自动转换换行符,代码风格差异被当成改动 | 克隆时增加git config core.autocrlf input,或在仓库根目录放.gitattributes固定换行规则 |
| 分支名太长,Windows 下报文件路径过长 | 分支名加本地路径超过路径长度限制 | 执行git config --global core.longpaths true,并避免用完整分支名做多级目录 |
| 拉取完成后仓库里缺少 LFS 二进制文件,编译报错 | Git LFS 指针被正常拉取,但大文件没有回填 | 克隆后每个目录执行git lfs pull,或者在脚本中对每个分支统一执行一遍 |
| 子模块目录为空 | 主仓库没有递归拉取子模块 | 在git clone后执行git submodule update --init --recursive,或直接配合--recurse-submodules使用 |
远端分支列表里有HEAD相关的符号引用,脚本误当分支处理 | git ls-remote不加参数时会把符号引用也输出 | 坚持用--heads,只取refs/heads/前缀的行 |
同一个分支用不同命名方式重复出现,比如master和main | 仓库维护者做了分支迁移但没删旧引用 | 建议在过滤规则里显式排除master,统一以main为准 |
5.1 认证问题是最隐蔽的坑
逐一克隆和单个克隆在认证上的体验完全不同。单个克隆时,你输一次账号密码就完事了;批量克隆时,每个分支都可能触发一次新的认证请求,尤其在不带凭据管理的 CI 环境里,脚本很容易卡在某个分支的密码输入界面。这里我强烈建议优先用 SSH 方式:
REMOTE="git@your-git-host.example:OpenHarmony/manifest.git"SSH 只需要本地配置好一次密钥,之后所有分支都能静默拉取,再配合GIT_TERMINAL_PROMPT=0,最大程度避免交互卡顿。如果只能用 HTTPS,就要提前执行git config --global credential.helper store,让 Git 把认证凭据写到本地文件,第一次手动输一次,之后复用。
5.2 分支列表里“看不见的分支”怎么处理
有少量分支不会被git ls-remote --heads列出来,但它们确实存在于远端。常见情况是仓库配置了hideRefs,或者分支被保护后只允许特定角色看到。处理方法是绕过分支列表,直接用已知的分支名调用克隆命令,脚本里保留一个EXTRA_BRANCHES变量来补充:
EXTRA_BRANCHES="private/ci-support" ALL_BRANCHES="${BRANCHES} ${EXTRA_BRANCHES}"实际使用中这个功能对于“我知道这分支存在,但列表看不到”的私密分支特别管用。
5.3 磁盘空间估算与预检
逐分支克隆的存储开销很容易被低估。一个代码仓库如果每个分支占 300M,拉 20 个分支就是 6G,看起来不多;但鸿蒙这类大型项目,一个分支包含工具链和预编译产物后可能轻松上 G,20 个分支就是 20G 起步。我在脚本里加了一段磁盘预检:
# 粗略估算:每个分支按已克隆分支的平均大小计算 if [[ -d "${BASE_DIR}" ]]; then avg_size=$(du -sm "${BASE_DIR}"/*/ 2>/dev/null | awk '{sum+=$1; count++} END {if (count>0) print sum/count; else print 0}') need_gb=$(( (avg_size * remaining_branches) / 1024 )) avail_gb=$(df -m "${BASE_DIR}" | tail -1 | awk '{print $4}') if (( need_gb * 1024 > avail_gb )); then echo "磁盘空间可能不足:预计需要 ${need_gb}GB,实际可用 ${avail_gb}MB" exit 1 fi fi这个预检不是精确计算,主要作用是“提前把明显会失败的大批量任务拦住”。实际跑的时候,如果空间不够,Git 往往拉了一半才报错,那时网络流量已经浪费了,所以预检很有必要。
6. 后续优化:worktree 模式与增量同步
脚本做到第 5 节的程度,已经能解决“一次性下载所有分支”的问题了。但用了几个月后,我发现仓库的分支数量会持续增长,每次重新全量拉一遍非常不划算,于是给脚本加了两个升级方向,你可以根据自己的实际场景选择。
6.1 将多个分支映射成多个 worktree
刚才提到git worktree方案,我把第二个模式合进来了。基本思路是:先克隆一个不带--single-branch的完整仓库(但配合--depth 1控制体积),随后为每个过滤后的分支执行git worktree add:
# 模式二:worktree if [[ "${MODE}" == "worktree" ]]; then MAIN_DIR="${BASE_DIR}/_main" git clone --depth 1 --no-tags "${REMOTE}" "${MAIN_DIR}" while read -r branch; do if git -C "${MAIN_DIR}" rev-parse --verify "refs/remotes/origin/${branch}" >/dev/null 2>&1; then echo "[worktree] ${branch}" git -C "${MAIN_DIR}" worktree add "${BASE_DIR}/${branch}" "origin/${branch}" else echo "[skip] ${branch} not found in origin" fi done <<< "${BRANCHES}" fi这个模式的核心理念是“不重复存储对象”,所有工作目录共享同一个对象数据库。对同一个仓库的 30 个分支,逐分支克隆可能要占用 30 份完整对象,而 worktree 模式几乎只占一份半,磁盘空间节省非常可观。缺点是第一次克隆完整分支引用时的耗时会变长,因为不能再用--single-branch切断其他分支。整体来说,如果你长期维护这些分支,worktree 模式是更好的方向。
6.2 增量同步单个移动分支
还有一种更省事的场景:你只是想在某个固定时间点把最新的分支状态同步下来,而不是重新全量拉取。这时可以直接对已存在的目录做git fetch && git reset --hard origin/<branch>:
for branch in "${branches[@]}"; do target="${BASE_DIR}/${branch}" if [[ -d "${target}/.git" ]]; then git -C "${target}" fetch origin "${branch}:${branch}" git -C "${target}" reset --hard "${branch}" fi done注意这里的fetch语法:git fetch origin branch:branch表示把远端branch更新到本地同名分支上。如果本地分支名和远端不一致,冒号左边是远端名,右边是本地名,写反了会把本地分支推送到远端,极其危险。这个方向性错误我在第一次写自动同步脚本时差点犯过。
6.3 分支存在性校验的细节
上面rev-parse --verify refs/remotes/origin/${branch}的写法,依赖于克隆时把远端分支完整映射到origin/*的本地引用。如果某些远程分支名和本地已有分支名冲突,或者分支里含特殊字符,rev-parse可能解析失败。更保险的做法是直接用git ls-remote --heads "$REMOTE" | grep "refs/heads/${branch}$"来判断,虽然多一次网络请求,但准确度更高。批量场景下建议把分支列表一次性拉出来存到内存数组里再判断,不要每次都跑ls-remote。
7. 落地后的使用建议
脚本写完后,我的日常使用方式很固定:每天早上到公司,先运行一次“一键下载全部历史分支”,然后去吃早饭,回来之后日志文件已经在logs/目录里躺着,成功列表和失败列表一目了然。对于失败列表里的分支,不用着急手动处理,可能是远端临时抖动,等两分钟再单独跑一次只针对该分支的重试脚本即可。
关于分支保留策略,我强烈建议不要长期囤积所有分支。每多保存一个分支,就是多一份磁盘和同步成本。按我的习惯,脚本里配一个BRANCH_RETENTION_DAYS参数,定期清理 30 天以上没有更新的分支目录,只保留活跃分支和当前正在用到的发布分支。这个清理动作可以用find "${BASE_DIR}" -maxdepth 1 -type d -mtime +30快速统计,但要注意别误删主目录。
最后再分享一个小技巧:给脚本加一个--dry-run参数,只打印将要做的事情而不真正执行。加这个参数花不了多少代码量,但在分支数量很多、你要先确认过滤规则是否正确时,价值非常大。我第一次配置过滤正则时,就因为漏掉了release/前缀,导致手动跑了两次全量克隆才意识到忘加排除规则。有了--dry-run,这种低级失误就再没发生过。