gfo、grbom、gpf:一条个人长期分支撑起的 Git 日常

我在 DevOps 和 IaC 这几个仓库里的 Git 操作,九成时间只用三个命令:gfo、grbom、gpf。

不开 feature 分支,不 merge main,也不用想「这次 push 要不要加 force」。所有改动都在一条只有我自己用的长期分支 ym-dev 上,攒到差不多了,在 GitHub 上开一个 ym-dev → main 的 PR。合并之后接着在同一条分支上干活。

这篇把这套做法和背后的几个判断写下来:为什么追 main 用 rebase、为什么永远 force push,以及出岔子的时候怎么收拾。

前提:这条分支只有我一个人用

下面所有内容都建立在一个前提上:**ym-dev 是我独占的,没有别人会往上面提交。**

这个前提决定了我可以随意改写这条分支的历史:rebase、force push 都不会坑到别人。反过来,main 这种共享分支上,下面讲的 gpf 一次都不要用。

另外两个小前提:终端用的是 oh-my-zsh 的 git 插件;默认分支叫 main(插件里的 git_main_branch 函数会自动识别 main 还是 master,所以叫 master 也一样)。

三个命令

别名 展开 作用
gfo git fetch origin 把 GitHub 上的最新状态拉到本地的 origin/*,不碰当前分支和工作区
grbom git rebase origin/$(git_main_branch) 把 ym-dev 上的提交挪到 GitHub 上 main 的最新位置之后
gpf git push --force-with-lease --force-if-includes 带保护的 force push

插件里还有一个 gpf!,展开是裸的 git push --force,我不用。

一天里的样子

开工的时候:

1
gfo && grbom && gpf

main 没有新东西的话,grbom 会说 Current branch ym-dev is up to date.,gpf 会说 Everything up-to-date,什么都没发生;main 有同事新合入的提交,ym-dev 就被接到最新的 main 后面,并同步到 GitHub。所以不用先判断「要不要 rebase」,每天无脑跑一遍就行。

只有一个例外:PR 正在被评审的时候,下面单独说。

写代码的时候:

1
2
3
gaa                       # 或 ga <文件...>
gcmsg "fix: handle empty env var in deploy script"
gpf

提交信息用英文的 Conventional Commits,因为仓库用 release-please 生成版本和 changelog。push 永远是 gpf,不用 gp,原因后面说。

想合进 main 的时候,在 GitHub 上开 PR:ym-dev → main。开之前最好先跑一遍 gfo && grbom && gpf,让 PR 基于最新的 main,冲突在本地就解决掉。

PR 等审批的时候,不用停下来。继续在 ym-dev 上 commit、gpf,PR 会自动带上新提交。

但这时候要不要追 main,得看 PR 处于什么状态:

  • 还没人看,或者已经批了在等合并:照常 gfo && grbom && gpf。
  • 已经有人在看,或者 PR 上有评论了:先不要 rebase。回应评论就追加新提交、gpf,评审的人看到的是「在上次的基础上又改了什么」。等评审结束再恢复每天 rebase。
  • 评审期间 main 和我有冲突,非 rebase 不可:rebase 完、gpf 之前,先核对一下改动有没有被意外改变(见下文 git range-diff),然后在 PR 里留一句话说明,比如 Rebased onto latest main to resolve a conflict in xxx; no other changes.

为什么评审期间要克制,后面讲 rebase 的代价时再说。

PR 合并之后,还是那一行:

1
gfo && grbom && gpf

我们的 PR 用的是 merge commit 方式合并,ym-dev 上的提交原样进了 main 的历史,所以这一步的 grbom 本质上是一次快进:ym-dev 直接对齐到 main 的最新位置。如果 PR 等待期间我又加了新提交,这些新提交会被挪到 main 后面,成为下一个 PR 的内容。

整个周期画出来是这样:

1
2
3
4
5
6
7
8
9
        开工 / main 有更新 / PR 已合并
|
gfo && grbom && gpf
|
v
commit -> gpf -> commit -> gpf -> ... (ym-dev)
|
v
GitHub PR: ym-dev -> main(等待期继续 commit + gpf)

为什么追 main 用 rebase,而且对着 origin/main

追 main 有两种做法:git merge origin/main 或 git rebase origin/main。

merge 不改写历史,对共享分支是对的选择;但在一条个人分支上,每追一次 main 就多一个 merge commit,几个月下来 ym-dev 的历史里一半是「Merge branch ‘main’ into ym-dev」,PR 里也全是噪音。rebase 让 ym-dev 始终是「main 最新位置 + 我的几个提交」,PR 里看到的就是我真正改了什么。代价是 rebase 会改写已经 push 过的提交,所以必须 force push。这个代价在独占分支上可以接受。

grbom 对着的是 origin/main,而不是本地的 main,这一点很关键:

  • origin/main 是上一次 fetch 时 GitHub 上 main 的快照,gfo 一下就是最新的;
  • 本地 main 只有 checkout 过去再 pull 才会更新,我根本不需要它。

所以整个流程里我从来不切到 main 分支,gfo 在 ym-dev 上直接跑就行。也正因为 grbom 用的是快照,**gfo 必须在 grbom 之前**,否则 rebase 到的可能是几天前的 main。

还有一点:git status -sb 显示的 ahead/behind 是相对 upstream(origin/ym-dev)的,看不出落后 main 多少。想看的话:

1
git rev-list --left-right --count origin/$(git_main_branch)...HEAD

输出两个数字,左边是 main 有、我没有的提交数(落后),右边是我有、main 没有的提交数(领先)。不过既然每天都无脑跑 gfo && grbom && gpf,这个命令我很少用。

rebase 的代价:提交会换 SHA

一个提交的 SHA,是对它的全部内容算出来的哈希,内容里包括快照(tree)、父提交(parent)、作者和提交者信息、提交信息。用 git cat-file -p HEAD 可以看到这些字段。

假设我的 C1、C2 基于 main 上的 B,期间同事合入了 M1、M2:

1
2
main:    A - B - M1 - M2
ym-dev: B - C1 - C2

grbom 并不是把 C1、C2「挪」过去,提交对象一旦生成就不能修改。它是把 C1、C2 的改动在 M2 上重新应用一遍,生成两个新提交:

1
ym-dev:  A - B - M1 - M2 - C1' - C2'

C1’ 的父提交从 B 变成了 M2,快照里多了 M1、M2 带来的文件,提交者时间也更新了,SHA 自然是新的。C2’ 的父提交是 C1’,于是也跟着变。改动(diff)一样,但快照和 SHA 都不一样了。

GitHub 的 PR 不保存提交清单,它显示的是「从 ym-dev 能追溯到、从 main 追溯不到」的那些提交。gpf 之后,PR 里列的就成了 C1’、C2’;新的分支头也不再是旧分支头的后代,所以时间线上会出现一条 force-pushed the ym-dev branch from ... to ...。

对 Git 和最终合进 main 的结果来说,这没有任何问题。代价只落在评审的人身上:如果他已经看了一半、甚至留了评论,旧提交从列表里消失了,「上次看到哪里」不好找;force-pushed 记录旁边的对比链接,比较的是新旧两个分支头,会混进 rebase 带进来的 main 上的改动;rebase 时解决冲突改到了被评论的那几行,那些评论还会被折叠成 outdated。这就是前面说「评审期间先不要 rebase」的原因。

反过来,main 没有新提交,或者 PR 刚以 merge commit 方式合并、我还没加新提交时,grbom 只是原地不动或者一次快进,不会生成任何新提交,也就没有这个代价。

不得不 rebase 时,用 range-diff 核对

评审期间如果非 rebase 不可,在 gpf 之前跑一下:

1
git range-diff origin/$(git_main_branch) @{u} HEAD

@{u} 是 origin/ym-dev,在 push 之前它还停在 rebase 之前的位置。这条命令把 rebase 前后的提交逐个配对,比较的是每个提交的改动本身,不受 SHA 和基点变化的影响:

1
2
1:  3aa3faf = 1:  210eddd C1
2: ca2dc98 = 2: ddb842e C2

= 表示改动完全一样;! 表示有变化,下面会列出具体差异,通常就是解决冲突时动过的地方。确认没问题再 gpf,有 ! 的话,也正好知道该在 PR 里跟评审的人说明哪一处。

注意顺序:gpf 之后 @{u} 就更新成新的分支头了,再跑这条命令就什么都比较不出来。

为什么永远 gpf

rebase 之后,本地 ym-dev 和 origin/ym-dev 的历史分叉了,普通的 git push 会被拒绝,只能 force。

但不是每次 push 都需要 force:只是加了个提交的话,普通 push 就够了。如果每次都要判断「这次改没改写历史」,迟早会判断错。所以我的做法是:**在 ym-dev 上一律用 gpf**。快进的情况下它和普通 push 效果一样,改写过历史它也能推上去。

问题是 force push 本身是危险的:它会把远端分支直接覆盖成本地的样子。gpf 的两个参数就是用来挡住「覆盖掉我没见过的东西」的。

**--force-with-lease**:push 之前检查远端 ym-dev 当前的位置,是否还等于我本地记录的 origin/ym-dev。如果远端在我上次 fetch 之后被人动过,就拒绝。

它有一个漏洞:「本地记录的 origin/ym-dev」会被任何一次 fetch 更新。IDE 在后台自动 fetch、或者我自己顺手跑了个 gfo,origin/ym-dev 就悄悄变成了远端最新的样子。这时 lease 检查会通过,但远端那些新提交我其实从来没看过,一 push 就没了。

--force-if-includes 补的就是这个洞:它额外检查远端 ym-dev 当前的位置,是否出现在本地 ym-dev 的 reflog 里,也就是说,我的本地分支是不是真的「经历过」远端的那个状态。只是被 fetch 下来、没有被我 rebase 或 merge 进本地分支的提交,过不了这一关。

对独占分支来说,「被人动过」最现实的场景其实是我自己:在另一台电脑上推了几个提交,回到这台忘了同步。这时 gpf 会拒绝,而不是把另一台电脑的工作覆盖掉。

正常流程里,grbom 之前本地 ym-dev 就停在 origin/ym-dev 的位置,这个位置自然在 reflog 里,所以 gpf 总能顺利推上去。

由此还有一条推论:**grbom 和 gpf 必须成对出现。** rebase 完不 push,本地和远端就是分叉状态,PR 上看到的也还是旧的提交。

出岔子的时候

rebase 遇到冲突。 按提示改好文件,然后:

1
2
ga <文件...>
grbc # git rebase --continue

不想处理了,grba(git rebase --abort)回到 rebase 之前的样子。

另外,工作区有未提交的改动时,grbom 会直接拒绝。先 commit,或者 git stash 一下。

rebase 完发现结果不对。 刚 rebase 完、还没 push 的话,git reset --hard ORIG_HEAD 可以回到 rebase 之前。已经过了好几步的话,去 git reflog 里找。

git status 显示 diverged。 最常见的原因是 grbom 之后忘了 gpf。这时本地是对的,直接 gpf 就好。

如果反过来,是本地被搞乱了,想以 GitHub 上的为准:

1
2
3
gfo
git log --oneline origin/ym-dev..HEAD # 先看清楚本地独有的提交,这些会丢
git reset --hard origin/ym-dev

无论哪种情况,**这时候都不要 git pull**。默认配置下它会把分叉的两段历史 merge 到一起,同一批改动在历史里出现两次,再 rebase 的时候还会冲突。

只想合一部分:ship 分支

偶尔 ym-dev 上混着没做完的东西,但其中一两个提交急着要进 main。这时从 main 拉一个临时分支,把需要的提交 cherry-pick 过去:

1
2
3
4
gfo
gcb ship/fix-xxx origin/$(git_main_branch)
git cherry-pick <commit1> <commit2>
git push -u origin ship/fix-xxx

然后开 ship/fix-xxx → main 的 PR,合并后删掉这个分支。

回到 ym-dev 之后照常 gfo && grbom && gpf。rebase 会发现这几个提交的改动已经在 main 里了,自动跳过,不会重复。

多说几句

这套做法能成立,靠的是「分支独占」这个前提。它的好处都来自这里:可以随便 rebase,push 不用动脑子,历史永远干净。换到多人协作的分支上,这些好处就全变成了风险。

它的代价也很明显:所有工作都在一条分支上,一个 PR 里可能混着几件不相干的事,评审的人不一定喜欢:只能整体批或整体不批,其中一件有争议,另外几件也得跟着等。PR 等得越久,我往里追加的提交越多;东西越多越难审,越难审又等得越久。我目前的解法是控制 PR 的节奏,攒到一个合适的量就开,不让它长得太大;真需要拆的时候,就用上面的 ship 分支。

另一个代价是 rebase 对评审中途的 PR 不友好。这一点我一开始没想到:只盯着「C1 变成 C1’ 有没有问题」,答案是没有,就以为 rebase 没有副作用了。实际上受影响的不是 Git,而是正在看 PR 的人。所以这套做法里唯一需要动脑子的地方,就是 rebase 之前先想一下:现在有没有人在看这个 PR。

最后回到那三个命令。gfo 拿到最新状态,grbom 接到最新的 main 上,gpf 安全地推上去。顺序不能乱,grbom 和 gpf 不能拆开,PR 评审期间先别 grbom。记住这三条,剩下的都是 commit。

参考资料