"每次新环境部署,最烦的不是写业务代码,而是那些掰着手指头数不过来的 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 git,Windows 上最省事的是装 Git for Windows,它会顺带提供一个 Git Bash 终端环境。
装完之后,有两条全局配置是脚本自动化之前必须做的:
git config --global user.name "Your Name" git config --global user.email "your_email@example.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_email@example.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 log,pull 失败了 tee 可能还是成功的,没有 pipefail 你根本发现不了问题。
SCRIPT_DIR这个变量是另一个实战经验。脚本如果用相对路径,在哪个目录执行bash script.sh结果可能完全不同。把脚本所在目录显式取出来,后续所有文件路径都基于它拼接,脚本就具备了"在哪里都能运行"的稳定性。
3. 代码拉取自动化:从批量 clone 到一键同步所有本地仓库
拉取类操作最值得自动化,因为它规则最简单。但"简单"不等于"没有坑",批量拉取的实际难点在于不同仓库的分支状态差异。
3.1 场景一:按仓库清单批量 clone
团队里新人入职,或者你在新电脑上初始化开发环境,经常需要一次把多个仓库克隆下来。手动十几条git clone敲完,还要逐个进目录。换个方式:维护一个仓库清单文件,让脚本去读。
假设清单文件repos.txt,每行一个 Git 地址:
git@github.com:example/api-server.git git@github.com:example/web-console.git git@github.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 2>&1; 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 eol=lf另外建议所有 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 git@github.com(不同平台域名不一样,GitHub 会返回欢迎语)。这一步能区分是网络问题还是认证问题。 - 检查
~/.ssh/config里的 Host 配置是否写错,尤其是 IdentityFile 路径。 - 确认远端仓库地址是不是 SSH 格式:
git remote -v,如果输出是https://开头的地址,脚本里用的证件逻辑完全不同。
最后一条坑特别隐蔽:很多人在命令行手动操作时用 HTTPS clone 的仓库,换到脚本里直接执行git push,这时候如果 credential helper 没配置,脚本会挂起等待输入用户名密码。解决方法是统一把远程地址改成 SSH 格式:
git remote set-url origin git@github.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使用时DEBUG=1 ./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 能帮你做的事情,比你想象中多得多。