每次新环境部署最烦的不是写业务代码而是那些掰着手指头数不过来的 Git 操作先 clone 仓库再切分支然后拉代码、改代码、提交、推送发布前还要合并分支、清理分支。单看每一条命令都不难难的是在多个仓库、多个分支之间来回切换时保持思路清晰、操作不失误。这个系列写到了第 68 篇前面讲过 Shell 脚本的基础语法也单独梳理过 Git 的核心命令这次就把两样东西合在一起聊一聊怎么用 Shell 把 Git 的高频操作串成自动化脚本让代码拉取、提交、分支管理这些动作从手动敲变成一键执行。先泼一盆冷水脚本不是万能的也不是所有 Git 操作都适合自动化。像代码冲突的合并决策、需要人工 review 的提交内容这些场景强行脚本化反而添乱。真正适合脚本化的是那些规则固定、重复度高、出错代价小的操作。比如每天早上同步所有仓库、按固定格式生成提交信息、批量清理已合并的分支。把这类动作用脚本封装好你省下的不只是敲键盘的时间更是从反复确认下一步做什么的脑力消耗里解脱出来。1. 被重复操作逼出来的自动化先说清楚脚本要解决的三个痛点我刚开始用 Git 的时候习惯跟大多数人一样直接敲命令。项目少、分支简单倒也不觉得有什么问题。但仓库数量一多痛点就暴露得很明显。第一个痛点是多仓库同步的重复劳动。我手里有十几个项目有个人玩具项目也有跟着团队维护的业务代码。每天早上打开电脑要把这些仓库全部拉到最新就得挨个进目录、敲git pull。如果某个仓库还停留在旧分支上还得先切分支再拉。这套动作闭着眼都能做但每天十几遍重复下来人很容易变得麻木。麻木的结果就是失误——要么在错误的仓库里执行了命令要么漏掉了某个需要同步的仓库。第二个痛点是提交信息格式参差不齐。团队协作时提交信息是 Code Review 和回溯变更的重要依据。如果没有统一约束就会出现更新代码、改了 bug、提交一下这类完全无法定位问题的提交信息。用脚本把提交信息的规范固化下来让每次 commit 之前都必须传入结构化的消息比在团队群里反复强调格式要可靠得多。第三个痛点是分支管理操作的连锁反应。分支操作不是单条命令就能完成的。比如发一个版本通常要经历切回主干分支、拉取最新代码、创建 release 分支、推送到远程、合并到主干、删除远程分支和本地分支。这一串操作少说五六条命令中间任何一步出错后续就全乱了。把连锁操作封装成一个脚本本质上是在用程序逻辑保证操作顺序和前置条件让人为失误没有机会发生。当然自动化不等于盲目封装。我在写脚本之前给自己定了几条原则只在操作可逆、或操作前有明确检查的场景使用凡是需要人工判断的内容比如冲突怎么解、提交要不要拆分成多个脚本只做提示和辅助不做决定每个脚本都必须有清晰的输出日志出错时能快速定位。这个边界很重要后面每个脚本我都会提到在哪里手动介入。2. 开工之前的环境准备Git 配置、SSH 密钥与 Shell 脚本的防御骨架写脚本和写业务代码一样环境不对后面全是坑。这一节把准备工作一次性说透。2.1 Git 安装与全局配置让脚本知道你是谁不管你是哪类系统Git 的安装本身不复杂。Linux 下用包管理器装macOS 推荐先装 Homebrew 再brew install gitWindows 上最省事的是装 Git for Windows它会顺带提供一个 Git Bash 终端环境。装完之后有两条全局配置是脚本自动化之前必须做的git config --global user.name Your Name git config --global user.email your_emailexample.com不要小看这两条。很多脚本在git commit时失败报错信息是Please tell me who you are就是因为没配身份。脚本环境下没有交互式输入的机会这些信息必须在全局配置里提前就位。至于用个人邮箱还是公司邮箱、要不要开启commit.gpgsign签名按你团队的规定来就行。还有一个容易被忽视的全局配置是默认分支名。Git 的老版本默认创建master分支新版本默认main。如果你不想每次建仓库时都手动改可以执行git config --global init.defaultBranch main这点在自动化脚本里影响不大但如果你要让脚本自动创建新仓库统一分支名能少写很多判断逻辑。2.2 把远程认证配好为什么脚本环境必须用 SSH 而非 HTTPS脚本要执行git pull、git push这类需要远程认证的操作认证方式直接决定了脚本能不能非交互地运行。HTTPS 方式每次推送都可能弹出用户名密码输入框虽然配置了 credential helper 之后会记住凭据但在纯脚本环境下仍是不稳定因素。我的建议是一律换成 SSH 密钥认证。步骤如下# 生成密钥一路回车即可 ssh-keygen -t ed25519 -C your_emailexample.com # 启动 ssh-agent 并添加密钥 eval $(ssh-agent -s) ssh-add ~/.ssh/id_ed25519然后把~/.ssh/id_ed25519.pub的内容添加到 Git 托管平台GitHub、GitLab、Gitee 都支持的 SSH Keys 设置里。Windows 用户用的是 Git Bash命令完全一致。这里有个小技巧如果你的脚本要在多台机器上跑或者有多个托管平台的账号最好在~/.ssh/config里写上 Host 别名配置区分不同平台的密钥。2.3 Shell 脚本的防御骨架set -euo pipefail 到底做了什么预告一下后续所有脚本我都建议用同一个骨架开头。#!/usr/bin/env bash set -euo pipefail # 脚本所在目录后续所有路径都基于它 SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) log() { local level$1 shift echo [$(date %Y-%m-%d %H:%M:%S)] [$level] $* }set -euo pipefail这一行很多人见过但不一定清楚每块的作用set -e脚本中任何一条命令返回非零状态码立即退出。避免命令已经失败脚本还继续往下跑的雪崩式错误。set -u凡是引用未定义的变量直接报错退出。脚本里最怕变量名拼错还浑然不觉。set -o pipefail管道命令的返回状态取最后一个非零状态。比如git pull | tee logpull 失败了 tee 可能还是成功的没有 pipefail 你根本发现不了问题。SCRIPT_DIR这个变量是另一个实战经验。脚本如果用相对路径在哪个目录执行bash script.sh结果可能完全不同。把脚本所在目录显式取出来后续所有文件路径都基于它拼接脚本就具备了在哪里都能运行的稳定性。3. 代码拉取自动化从批量 clone 到一键同步所有本地仓库拉取类操作最值得自动化因为它规则最简单。但简单不等于没有坑批量拉取的实际难点在于不同仓库的分支状态差异。3.1 场景一按仓库清单批量 clone团队里新人入职或者你在新电脑上初始化开发环境经常需要一次把多个仓库克隆下来。手动十几条git clone敲完还要逐个进目录。换个方式维护一个仓库清单文件让脚本去读。假设清单文件repos.txt每行一个 Git 地址gitgithub.com:example/api-server.git gitgithub.com:example/web-console.git gitgithub.com:example/data-sync.git脚本内容#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) REPO_LIST$SCRIPT_DIR/repos.txt DEST_DIR${1:-$SCRIPT_DIR/repos} # 支持传入目标目录默认在脚本同级的 repos 下 mkdir -p $DEST_DIR while IFS read -r url; do # 跳过空行和注释 [[ -z $url || $url \#* ]] continue repo_name$(basename $url .git) if [[ -d $DEST_DIR/$repo_name/.git ]]; then log INFO $repo_name 已存在跳过 clone continue fi log INFO 开始 clone: $url git clone $url $DEST_DIR/$repo_name done $REPO_LIST这段脚本有几个值得说的点。第一用while read而不是for循环读文件避免仓库地址里出现空格时被拆成多个字段。第二basename $url .git可以从 URL 里提取仓库名这个比手写正则靠谱。第三clone 前检查.git目录是否存在避免重复执行时重新拉一遍。3.2 场景二一键 pull 所有本地仓库这应该是我日常使用频率最高的一个脚本。它要处理的是这样一个场景~/projects目录下有十几个子目录每个都是一个 Git 仓库希望一次把它们全部同步到最新。#!/usr/bin/env bash set -euo pipefail BASE_DIR${1:-$HOME/projects} for dir in $BASE_DIR/*/; do [[ -d $dir/.git || -f $dir/.git ]] || continue repo_name$(basename $dir) cd $dir # 检查是否有未提交的修改 if [[ -n $(git status --porcelain) ]]; then log WARN $repo_name 有未提交修改跳过避免直接覆盖你的工作 continue fi # 获取当前分支名 current_branch$(git rev-parse --abbrev-ref HEAD) log INFO 同步 $repo_name (分支: $current_branch) git pull --ff-only --prune done这里有两个关键设计。第一git status --porcelain检查工作区是否干净——这个命令的输出专为脚本设计稳定且不带任何人类阅读的修饰。凡是工作区有未提交修改的仓库直接跳过这是为了让脚本足够安全优先。宁可少同步一个仓库也不能把别人正在改的代码搞乱。第二git pull --ff-only是刻意的选择。--ff-only要求只能快进合并如果本地与远程出现分叉比如本地有独有的提交命令会直接失败而不是自动生成一个合并提交。自动生成的合并提交会把仓库历史搞得乱七八糟脚本里宁可失败让操作者人工介入。3.3 一个扩展指定分支拉取与 submodule 同步有团队会用 monorepo 结构配合 submodule 管理多子项目。这种场景下git pull默认不会自动更新子模块需要在拉取命令后追加git submodule update --init --recursive另外还可以通过脚本参数控制要拉取的远程分支。例如./sync-repos.sh feature/xxx脚本内部把git pull --ff-only换成先git fetch origin再执行git checkout -B feature/xxx origin/feature/xxx。这种以远程分支为唯一基准的方式适合那些需要频繁重建本地分支来对齐远程状态的场景。不过要提醒一句-B会强制移动本地分支并丢弃本地独有提交使用前一定确保工作区是干净的。4. 提交自动化把提交信息规范变成脚本强制执行的逻辑提交操作比拉取更敏感因为提交会产生新的历史节点。我的原则是脚本负责流程内容由人主导。所以下面的脚本都在提交信息层面做规范化而不是自动把工作区所有改动一股脑提交。4.1 用脚本统一提交信息格式先看最常用的单仓库提交脚本#!/usr/bin/env bash set -euo pipefail usage() { echo 用法: $0 类型(范围): 描述 echo 示例: $0 feat(user): 增加用户列表导出功能 exit 1 } if [[ $# -ne 1 ]]; then usage fi msg$1 # 校验提交信息格式符合 Conventional Commits 规范 if [[ ! $msg ~ ^(feat|fix|refactor|docs|test|chore|perf)\([^)]\):.*$ ]]; then echo 错误提交信息不符合规范。 usage fi # 展示当前改动让操作者确认 git status --short echo ---------------------------------------- read -r -p 确认以上改动输入 y 继续: confirm [[ $confirm y ]] || exit 0 git add -A git commit -m $msg git push这脚本里最值得拎出来讲的是正则校验。^(feat|fix|refactor|docs|test|chore|perf)\([^)]\):.*$约束提交信息必须是类型(范围): 描述的结构。比如fix(order): 修复订单重复提交问题是合法的update code会在第一步就被拦下。团队规范这东西写在文档里靠自觉写在脚本里靠强制后者明显更有效。read -r -p那一步是特意加的人类确认点。自动化不是无人化提交这种会改动历史记录的操作多一次人工确认不是效率损失是安全意识。4.2 多仓库批量提交的思路另一种提交场景是同时改了好几个相关仓库需要分别提交。思路和批量 pull 类似遍历目录但提交信息要从命令行传入#!/usr/bin/env bash set -euo pipefail BASE_DIR${1:-$HOME/projects} COMMIT_MSG${2:-} if [[ -z $COMMIT_MSG ]]; then echo 用法: $0 [目录] 提交信息 exit 1 fi for dir in $BASE_DIR/*/; do [[ -d $dir/.git || -f $dir/.git ]] || continue cd $dir # 跳过没有改动的仓库 if [[ -z $(git status --porcelain) ]]; then log INFO $(basename $dir) 无变动跳过 continue fi log INFO 提交 $(basename $dir) git add -A git commit -m $COMMIT_MSG done这里我没有在脚本里加git push。原因是批量场景下push 失败率比单仓库高得多——某个仓库的远程分支可能已经领先本地或者权限不足。如果边提交边 push一个仓库失败会导致脚本中断后面仓库全部停摆。更好的做法是让脚本只管提交push 交给批量推送脚本或者干脆人工检查后再推。分离操作能显著降低出错的连锁反应。4.3 提交之前先给脚本加一个安全检查写提交脚本时我踩过一个印象深刻的坑git add -A把所有改动都加进来了其中包括不该提交的临时文件、日志文件。后来在批量提交脚本里加了一段排除保护名单的逻辑# 在 git add 之前检查是否有敏感文件被改动 PROTECTED_FILES(config/production.yml *.env *.log) for pattern in ${PROTECTED_FILES[]}; do if git status --porcelain | grep -qE $pattern; then echo 警告检测到受保护文件变动 ($pattern)已中止 exit 1 fi done保护名单的粒度可以根据项目实际情况调整。这个习惯养成之后我再也没有担心过把密钥/环境配置误提交上去这类事故。5. 分支管理自动化创建、合并、清理的三种典型场景分支操作是最能体现脚本化思维的场景。每一条命令都不难难的是顺序和前置条件。写成脚本后这些前置条件变成自动检查。5.1 基于最新主干创建功能分支一个规范的功能分支创建流程应该是先切到主干拉取最新再从最新主干创建分支。很多团队会用git checkout -b feature/xxx直接建分支但如果你当时基于的本地主干已经落后远程好几个版本这个分支就从起点开始带着旧历史。脚本化之后这个流程被固化为不可跳过的步骤#!/usr/bin/env bash set -euo pipefail BRANCH_NAME${1:-} MAIN_BRANCH${2:-main} if [[ -z $BRANCH_NAME ]]; then echo 用法: $0 新分支名 [主干分支名] exit 1 fi # 校验分支命名规范feature/xxx, fix/xxx, hotfix/xxx 等 if [[ ! $BRANCH_NAME ~ ^(feature|fix|hotfix|release|chore)/[a-z0-9-]$ ]]; then echo 分支名不符合规范示例feature/user-login-api exit 1 fi git checkout $MAIN_BRANCH git pull --ff-only if git rev-parse --verify $BRANCH_NAME /dev/null 21; then echo 分支 $BRANCH_NAME 已存在直接切换 git checkout $BRANCH_NAME else git checkout -b $BRANCH_NAME fi git push -u origin $BRANCH_NAME这个脚本的核心价值是和主干同步强制绑定。我在组里推这个脚本之前每周都有几次我明明基于最新代码开发怎么合并时全是冲突。原因是开发时和切分支时之间隔着几天主干早就往前跑了。把这套流程写成脚本相当于每次建分支前自动完成主干更新。5.2 批量清理已合并的分支分支清理是最适合无脑自动化的场景。Git 提供了判断分支是否已合并的命令只需要写循环把列表逐条处理。#!/usr/bin/env bash set -euo pipefail MAIN_BRANCH${1:-main} # 切换到主干并同步 git checkout $MAIN_BRANCH git pull --ff-only echo 以下已合并到 $MAIN_BRANCH 的本地分支将被清理 git branch --merged $MAIN_BRANCH | grep -vE (^[* ]$MAIN_BRANCH$|HEAD) || true read -r -p 确认清理输入 y 继续: confirm if [[ $confirm ! y ]]; then exit 0 fi # 删除本地已合并分支 git branch --merged $MAIN_BRANCH \ | grep -vE (^[* ]$MAIN_BRANCH$|HEAD) \ | xargs -r git branch -d # 删除远程已合并分支需要先获取远程分支列表 git fetch --prune git branch -r --merged origin/$MAIN_BRANCH \ | grep origin/ \ | grep -vE origin/($MAIN_BRANCH|HEAD) \ | sed s#origin/## \ | xargs -r -I {} git push origin --delete {}我特别提醒两点。第一本地分支删除用的git branch -d是安全选项只有分支确实已合并才会删除遇到未合并分支会报错保护。但如果你的分支已经推到远程且被合并过本地这个分支曾经改过但 rebase 过-d可能也会拒绝要用-D强制。脚本里我坚持用-d保守一点。第二远程清理命令里的grep -vE origin/($MAIN_BRANCH|HEAD)是为了确保永远不会试图删除主干分支和 HEAD 指针这个过滤器是关键保护线。5.3 一键完成 release 分支的合并与删除发版本时最典型的一条龙操作把 release 分支合并回主干推进 tag删除 release 分支。脚本如下#!/usr/bin/env bash set -euo pipefail RELEASE_BRANCH${1:-} MAIN_BRANCH${2:-main} TAG_NAME${3:-} [[ -n $RELEASE_BRANCH ]] || { echo 用法: $0 release分支名 [主干名] [tag名]; exit 1; } # 前置检查工作区必须干净 if [[ -n $(git status --porcelain) ]]; then echo 工作区不干净先处理未提交改动再试 exit 1 fi git checkout $MAIN_BRANCH git pull --ff-only git merge --no-ff $RELEASE_BRANCH -m Merge $RELEASE_BRANCH into $MAIN_BRANCH if [[ -n $TAG_NAME ]]; then git tag -a $TAG_NAME -m Release $TAG_NAME git push origin $TAG_NAME fi git push origin $MAIN_BRANCH git branch -d $RELEASE_BRANCH git push origin --delete $RELEASE_BRANCH用--no-ff合并是因为它保留了一个明确的合并节点。对一个 release 分支来说这个节点就是版本发布的里程碑标记历史里一眼能定位。自动化覆盖的是确定无冲突的标准流程一旦git merge因为冲突失败脚本在set -e的控制下会当场退出你只需要人工解决冲突再跑一次即可。6. 实战避坑换行符、SSH 认证、重复执行与调试三板斧脚本写出来能跑只是开始真正决定体验的是遇到坑能不能快速定位。这一节把我连续踩了几个月的坑集中复盘全部来自真实操作。6.1 跨平台换行符问题脚本在 Windows 上突然跑不动同一个脚本在 Linux 上运行正常拿到 Windows Git Bash 里直接报$\r: command not found。原因是 Windows 下文件默认保存为 CRLF\r\n换行而 Shell 脚本要求 LF\n换行多出来的\r被当成命令的一部分。几个处理办法编辑脚本时统一用 LF或者给仓库加一个.gitattributes文件强制所有.sh文件以 LF 格式签出*.sh text eollf另外建议所有 Shell 脚本文件内都不要写中文注释至少在一些老旧的 Windows 终端环境下字符编码不一致会导致注释内容乱码甚至解析报错。如果非写不可确保文件以 UTF-8 无 BOM 编码保存BOM 在脚本第一行会直接导致 shebang 解析失败。6.2 SSH 认证失败的排查链路脚本在同步代码时报Permission denied (publickey)这是出现频率最高的问题。我的排查顺序是固定的先确认密钥是否被 ssh-agent 加载ssh-add -l如果列表为空执行ssh-add ~/.ssh/id_ed25519。再确认 SSH 能否连通托管平台ssh -T gitgithub.com不同平台域名不一样GitHub 会返回欢迎语。这一步能区分是网络问题还是认证问题。检查~/.ssh/config里的 Host 配置是否写错尤其是 IdentityFile 路径。确认远端仓库地址是不是 SSH 格式git remote -v如果输出是https://开头的地址脚本里用的证件逻辑完全不同。最后一条坑特别隐蔽很多人在命令行手动操作时用 HTTPS clone 的仓库换到脚本里直接执行git push这时候如果 credential helper 没配置脚本会挂起等待输入用户名密码。解决方法是统一把远程地址改成 SSH 格式git remote set-url origin gitgithub.com:example/repo.git6.3 脚本重复执行与并发执行问题脚本写了定时任务之后要格外小心重复执行的问题。比如定时拉取脚本跑了一半你手动又执行一次两个进程同时在操作同一个仓库极容易触发index.lock文件锁冲突。Git 仓库的锁机制是一个进程在写操作时仓库下会生成.git/index.lock第二个进程直接报错退出。防重复执行的标准写法是用mkdir做互斥锁LOCK_DIR$SCRIPT_DIR/.sync.lock if ! mkdir $LOCK_DIR 2/dev/null; then echo 已有脚本实例在运行退出 exit 1 fi trap rm -rf $LOCK_DIR EXITmkdir是原子操作只能有一个进程创建成功。这里要求有两个点一是锁目录不要放在仓库内部避免.git/index.lock冲突二是必须配合trap在脚本退出时清理锁目录否则脚本中途崩溃会导致锁永远存在。6.4 调试三板斧set -x、临时打印、验证输出最后分享一个调试脚本效率最高的组合。脚本开头加set -x可以让每一条执行过的命令和变量展开结果都打印到屏幕上定位问题非常直观。但正式跑批的时候set -x输出太吵我习惯的做法是给脚本加一个DEBUG环境变量开关if [[ ${DEBUG:-} 1 ]]; then set -x fi使用时DEBUG1 ./sync-repos.sh开调试平时正常跑保持安静。还有一个好习惯脚本里所有关键步骤写完先用echo把要执行的命令打印出来做空跑验证。比如批量删除分支的脚本第一次我总会让脚本只打印git branch -d xxx而不真正执行。确认无误后把echo去掉再真正运行。这类预演验证能挡住 90% 的灾难性误操作。7. 让脚本融入日常工作流从手动到习惯最后回到实践。我现在的日常已经离不开这套脚本了具体的工作流是这样的早上到工位先跑一条命令~/scripts/sync-all.sh ~/projects所有仓库自动同步到最新。哪个仓库有未提交改动导致跳过会在终端里明确显示我只需要处理那些有情况的项目。开发完功能提交时用规范提交脚本消息格式不对会被直接拦下。合代码之前先跑git pull --ff-only确认没有分叉再跑自动合并脚本。版本发布时一条命令完成合并、打标签、推远程、清理分支的整套动作。省下来的时间其实不算多每天大概十几分钟。但更重要的收益是注意力的释放。操作越机械人越容易掉以轻心掉以轻心时做的操作恰恰是最危险的。把固定流程交给脚本相当于把注意力的风险也交出去了。人在 Git 操作上只需要做真正的决策。后续想更进一步的话可以考虑把这些脚本接到更自动化的链路里。比如 sync 完成之后自动触发一次测试任务pytest、Appium 这类测试框架都能用命令行直接驱动或者把脚本配到 cron 定时任务里实现定时同步。再往下就是 CI/CD 流水线那边的领域了。但无论怎么扩展我都建议保留一个原则脚本只自动化那些出错后不会造成永久伤害的操作。在这一条边界内Shell 结合 Git 能帮你做的事情比你想象中多得多。