
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 REMOTEhttps://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_PATTERNSdeprecated old- BRANCHES$(git ls-remote --heads ${REMOTE} | awk {print $2} | sed s#refs/heads/##) filtered for b in ${BRANCHES}; do skip0 for pat in ${EXCLUDE_PATTERNS}; do if [[ ${b} ${pat}* ]]; then skip1 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_JOBS4 jobs0 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 21 fi ) jobs$((jobs 1)) if [[ ${jobs} -ge ${MAX_JOBS} ]]; then wait jobs0 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 21; 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_PROMPT0一旦需要交互直接失败而不是挂起或在本地配置好 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 方式REMOTEgityour-git-host.example:OpenHarmony/manifest.gitSSH 只需要本地配置好一次密钥之后所有分支都能静默拉取再配合GIT_TERMINAL_PROMPT0最大程度避免交互卡顿。如果只能用 HTTPS就要提前执行git config --global credential.helper store让 Git 把认证凭据写到本地文件第一次手动输一次之后复用。5.2 分支列表里“看不见的分支”怎么处理有少量分支不会被git ls-remote --heads列出来但它们确实存在于远端。常见情况是仓库配置了hideRefs或者分支被保护后只允许特定角色看到。处理方法是绕过分支列表直接用已知的分支名调用克隆命令脚本里保留一个EXTRA_BRANCHES变量来补充EXTRA_BRANCHESprivate/ci-support ALL_BRANCHES${BRANCHES} ${EXTRA_BRANCHES}实际使用中这个功能对于“我知道这分支存在但列表看不到”的私密分支特别管用。5.3 磁盘空间估算与预检逐分支克隆的存储开销很容易被低估。一个代码仓库如果每个分支占 300M拉 20 个分支就是 6G看起来不多但鸿蒙这类大型项目一个分支包含工具链和预编译产物后可能轻松上 G20 个分支就是 20G 起步。我在脚本里加了一段磁盘预检# 粗略估算每个分支按已克隆分支的平均大小计算 if [[ -d ${BASE_DIR} ]]; then avg_size$(du -sm ${BASE_DIR}/*/ 2/dev/null | awk {sum$1; count} END {if (count0) 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 21; 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/branchfor 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这种低级失误就再没发生过。