资讯动态

Git分支原理与实战:从指针、reflog到合并、rebase与远端协作

发布时间:2026/10/26 4:38:34 来源:尧图企业网站定制
第一次看到git log --graph --all打出来那张斜线交错的分支图我的第一反应是这不就是一张地铁线路图么主干一路向前几条支线从某个站岔出去走一段又并回来。当时我以为 Git 分支是在磁盘上把整个项目复制了一份所以每次git switch都要等半天。后来翻了下.git目录才发现一个新分支在磁盘上不过是一个四十来字节的文本文件里面就写了一串哈希值。这个认知一旦建立后面关于合并、变基、远端分支的一堆困惑几乎是连锁解开的。Git 分支的原理与使用说到底就两件事一是明白分支只是指向某次提交的可移动指针二是明白指针怎么移动才不把别人的工作搞乱。前者属于原理后者属于纪律。市面上讲命令的文章很多讲清楚这条命令在.git里改了什么的并不多而恰恰是后者能让你在出问题时知道往哪儿看。下面这些内容我会按磁盘上发生了什么 → 命令做了什么 → 出问题怎么查的顺序展开适合已经会用commit、push但一遇到冲突、分离头指针、误删分支就发怵的读者。1. 分支不是复制一份代码而是一个四十字节的文本文件1.1 从一次提交说起commit、tree、parent 构成的有向无环图要理解分支得先理解 Git 存的是什么。每次git commitGit 会生成一个 commit 对象这个对象里包含四样东西一个指向目录树快照的tree对象、零个或多个parent父提交、作者与提交者的信息、以及提交说明。注意这里没有文件差异commit 指向的是整棵目录树的快照差异是查看时才临时算出来的。parent这个字段是整个分支模型的基石。普通提交有一个 parent合并提交有两个甚至更多 parent。把这些 commit 用 parent 连起来就得到一张有向无环图DAG——方向永远是从新指向旧且不可能绕回自己。所谓分支在这张图上就是一条从某个节点延伸出去的路径。图是只增不改的commit 一旦生成哈希就固定了内容也固定了。你后面所有的改写历史操作本质上都是生成新的 commit 对象然后让指针指过去旧对象还在只是没人引用了。我习惯用一个类比commit 像是一张张已经冲洗好的照片哈希就是照片的编号parent是照片背面写的上一张的编号。照片本身不能修改你只能重新拍一张然后把相册目录里的页码改掉。这个类比在后面讲 rebase 为什么会导致哈希全变时会非常有用。1.2 refs/heads 与 HEAD分支名只是指针的别名.git/refs/heads/目录下每一个文件就是一个本地分支文件名就是分支名文件内容是它当前指向的 commit 哈希。你可以直接cat .git/refs/heads/main看一眼输出就是一行四十位SHA-1或六十四位SHA-256的十六进制字符串。这就是全部——没有代码副本没有目录拷贝。那 HEAD 是什么.git/HEAD通常只有一行ref: refs/heads/main。它是一个符号引用意思是我现在站在 main 这个分支上。所以一次提交发生时Git 做的事情极其简单生成新 commit把refs/heads/main这个文件的内容改写成新 commit 的哈希。指针往前挪一格完事。这里有个细节很多人没注意分支文件是可以被外部工具直接读写的。我曾经在排查一个自动化脚本的问题时发现脚本为了批量创建分支直接往refs/heads/里写文件结果因为没走git update-ref跳过了 reflog 记录后来误删时连恢复线索都没有。所以哪怕是原理层面的操作也建议用git update-ref这类正式入口别手工写文件。1.3 为什么切分支几乎瞬间完成理解了上面两点切分支为什么快就很好解释了。git switch other做的事情是把.git/HEAD的内容改成ref: refs/heads/other然后更新工作区和索引使之与目标 commit 的 tree 一致。指针切换本身是 O(1) 的代价全在第二步的文件同步上——而 Git 只同步两边真正不同的文件靠的是索引里记录的 stat 信息修改时间、大小、inode做快速比对。所以你会观察到一个现象在一个几万文件的大仓库里如果两个分支只差三个文件切分支几乎是眨眼的事但如果两个分支差了上万个文件比如一个分支删了整个 vendor 目录那切换就会明显变慢因为工作区要真正重写这些文件。这个规律在实操中很有用分支切得慢往往说明你的分支划分方式有问题两个分支承担了过多不相关的变更。还有一个反直觉的点分支数量多几乎不影响性能。因为每个分支就是一个几十字节的文件一百个分支也就几 KB。真正影响性能的是分支之间的差异大小和仓库对象数量。所以别怕建分支该建就建。2. 切分支时 Git 到底改了哪些文件2.1 .git 目录里那三个关键位置出问题时想快速定位必须记住三个位置。第一个是.git/HEAD它告诉你当前在哪儿第二个是.git/refs/heads/它告诉你所有本地分支指向哪儿第三个是.git/logs/也就是 reflog它记录了 HEAD 和各个 ref 的每一次移动格式是旧哈希 新哈希 操作者 时间 动作描述。reflog 的重要性怎么强调都不过分。它是一份本地操作流水账默认保留 90 天可通过gc.reflogExpire配置调整。误删分支、误 reset、误 rebase 之后绝大多数救援都从git reflog开始。我个人的习惯是在做任何破坏性操作reset --hard、rebase、branch -D之前先顺手git reflog | head -5看一眼当前状态心里有个锚点。另外还有一个容易被忽略的文件.git/index。它是暂存区记录的是下一次提交要包含哪些文件、各自对应哪个 blob 哈希、以及文件的 stat 信息。切换分支时index 必须和目标分支的 tree 保持一致否则你git status会看到一堆莫名其妙的已修改。这就是为什么有时候切完分支编辑器里文件没变但git status说改了一大片——多半是行尾或文件权限导致的 stat 失配。2.2 工作区与索引的同步checkout 的真实动作序列git switch或老版本的git checkout内部大致走这么几步先做一遍安全检查确认当前未提交的改动不会和要切换的目标分支冲突然后把 HEAD 指向目标分支接着对比当前索引和目标 tree算出需要增删改的文件列表再更新工作区文件最后更新索引。这个顺序解释了为什么如果有未提交改动且目标分支也改了同一批文件切换会被拒绝——安全检查没过。被拒绝时的报错通常是Your local changes to the following files would be overwritten by checkout。这时候千万别条件反射地git checkout -f强切那会直接丢掉你的改动。正确做法是先git stash切过去再git stash pop或者对这批改动单独建一个临时提交。还有一个隐蔽的坑新创建但未被跟踪的文件不会阻止切换。因为 Git 只保护已跟踪文件的改动。所以如果你在分支 A 上新建了一个config.local.js还没add切到分支 B 时它会被带过去——如果分支 B 恰好也有一份同名但已跟踪的文件你会看到内容莫名其妙变了。这类问题排查起来极费时间我一般建议在项目里配好.gitignore把本地配置文件全部列进去。2.3 未提交改动会被带走这是很多事故的源头承接上一条未跟踪文件会被带到目标分支这是 Git 的既定行为不是 bug。理解这一点之后很多诡异现象就说得通了明明没提交切个分支怎么代码就不一样了因为那个文件在两个分支里都存在且已跟踪切换时被覆盖了。我踩过最典型的一次在一个特性分支上改了一个公共工具文件改到一半没提交就去切主干查问题回来后发现改动没了。当时第一反应是Git 吞代码了冷静下来git reflog一看切分支那一步并没有丢失任何提交——问题在于那次改动是已跟踪文件的修改切换时被目标分支的版本覆盖而我没有提交也没有 stash。后来我的习惯是切分支前必看git status如果有已跟踪文件的改动要么提交要么 stash绝不裸奔切换。顺便说一个提升效率的配置git config --global merge.conflictStyle zdiff3新版本支持。它会在冲突标记里额外展示共同祖先版本解决冲突时能直接看出两边各自改了什么比默认的两栏标记信息量大得多尤其在三方合并冲突复杂的时候。3. 合并的三种结局快进、三方合并、冲突3.1 快进合并条件与副作用当目标分支是当前分支的直接祖先时合并可以快进fast-forward既然没有任何分叉那就把当前分支的指针直接挪到目标分支的哈希上不生成新 commit。命令输出里那句Fast-forward就是它。快进的好处是历史干净、线性、没有多余的合并节点。但副作用也很明显合并这个动作在历史上不留痕迹。事后你从图上看会觉得这些提交本来就是直接在主干上开发的根本看不出曾经有过一个特性分支。这在需要追溯哪个需求对应哪批提交的场景下是个损失。所以有些团队会在合并特性分支时强制--no-ff即使能快进也生成一个合并提交让分支边界留痕。这是一个纯粹的取舍问题没有对错策略历史形态优点代价默认允许快进线性图干净、git log易读分支边界丢失难以按特性回溯--no-ff有合并节点分支边界清晰便于整体回滚图变复杂合并节点多--squash线性单提交主干历史极简丢失分支内细粒度提交原作者信息需要手工保留我自己在个人项目里用默认快进因为提交粒度控制得比较细在多人协作的项目里倾向--no-ff因为需要一个合并节点对应一个需求这个可追溯性。3.2 三方合并merge base 决定了什么算改动有分叉时Git 会走三方合并three-way merge。关键在于找到merge base也就是两个分支的最近共同祖先。找到之后Git 做的是三方对比base → 当前分支得到我方改了什么base → 目标分支得到对方改了什么然后按文件、按块hunk把两边不冲突的改动都应用上去。注意三方合并对比的是相对于共同祖先的变更不是两个分支的当前内容。这解释了一个常见困惑——为什么两个分支的文件内容看起来完全一样合并时却报冲突因为它们相对于 base 都做了改动而且改在了同一区域Git 无法判断该保留哪个。git merge-base A B可以显式查看共同祖先排查合并问题时非常有用。另外git log --merge -p file能列出冲突文件在两边各自的提交配合git diff --name-only --diff-filterU找出所有冲突文件这套组合拳在处理大合并时能省不少时间。合并提交与普通提交的区别就在于它有两个 parent。这一点在后续做git log --first-parent时特别有价值——只看第一父链就能把主干历史过滤成一条干净的线跳过所有分支内部的细碎提交。3.3 冲突标记的读法与解决套路冲突时文件里会出现这样的标记 HEAD 当前分支的版本 要合并进来的分支的版本 feature/login上半部分是你所在分支HEAD的内容下半部分是对方分支的内容。解决方式无非三种保留我方、保留对方、手工融合。改完之后必须git add file标记为已解决然后git commit完成合并提交。我见过不少人改完文件就直接git commit -a看似没问题但-a会把所有已跟踪文件的改动一股脑提交包括那些你根本没检查过的冲突文件。规范做法是先git add逐个确认再git commit。再分享一个提效技巧git config --global merge.conflictstyle diff3之后冲突标记会多出一段|||||||分隔的共同祖先内容。对于两边都改了一大段、看不出意图的冲突祖先版本能直接告诉你原本长什么样判断该保留哪边会快得多。我自己用了这个配置之后解决冲突的时间大概缩短了一半。git mergetool也是一个选项但如果团队没有统一的图形工具约定反而容易产生格式差异。我的建议是简单冲突手改复杂冲突用git checkout --ours file/--theirs file先整体取一边再局部手动调。注意在 rebase 过程中ours和theirs的含义是反的——ours指的是你正在 rebase 到的那个基底分支这点坑过很多人。4. rebase 的本质是把提交重放一遍4.1 一次 rebase 内部发生了什么哈希为什么全变了git rebase main在特性分支上执行时Git 做的是找到特性分支与 main 的共同祖先把共同祖先之后特性分支上的提交逐个取出来以 main 的最新提交为新的基底依次重新应用每应用一次就生成一个新的 commit 对象。因为 parent 变了commit 内容也变了哈希由内容算出所以所有被重放的提交哈希都会变。这就是为什么 rebase 之后git log看起来历史被改写了。严格说不是改写是新建了一批提交旧的那批还在对象库里直到被 gc 回收只是分支指针指向了新的那批。这也解释了为什么 rebase 之后你本地原有的、基于旧提交的其他分支会分叉出去——它们还指向旧的哈希。理解这层之后rebase 的一大堆规矩就都顺理成章了凡是别人已经基于旧哈希做了工作的地方你 rebase 就会让别人对不上号。所以那条红线——不要对已经推送并被他人使用的公共分支做 rebase——不是一个道德规范而是一个技术后果。4.2 交互式 rebase整理提交的四种常用动作git rebase -i HEAD~5会打开一个编辑器列出最近五个提交每行前面是一个动作关键字。最常用的四个pick保留这个提交什么都不改reword保留改动重新编辑提交说明squash把这个提交合并进上一个提交两条说明都保留供编辑fixup同 squash但丢弃本条的说明直接用上一条的还有drop删掉这个提交、edit停下来让你修改内容、以及调整行顺序来重排提交顺序。我在提交前整理时最常用的是fixup开发过程中随手提交一堆wip、fix typo最后用git rebase -i把它们压进对应的功能提交里形成一组逻辑清晰的提交再推上去。这里有个实操细节git rebase -i的列表顺序是从旧到新和git log展示的顺序相反。第一次用的人经常会挑错提交。我的做法是先用git log --oneline -10看清楚再执行交互式 rebase心里有数。另一个细节squash时编辑器的内容是几条提交说明拼在一起建议保留一条主说明把其余内容降级成正文条目而不是简单堆叠。这属于提交习惯问题但会直接影响后来人 review 的成本。4.3 那条被反复强调的红线公共分支不要 rebase判断标准很简单这个分支上有没有别人的提交或者别人有没有基于它继续工作。如果是就别 rebase。如果只有你自己且确定没有别人基于它建分支那 rebase 是安全的甚至是推荐的。真正麻烦的场景是我已经 rebase 并强推了怎么补救。这里没有优雅方案只能靠沟通加git reflog。同事的本地分支还指向旧提交需要他们执行git fetch后git reset --hard origin/branch前提是他们本地没有未推送的提交或者用git rebase --onto精确地把自己的提交搬到新历史上。无论哪种都会有人要做手工操作所以代价是实实在在的。提示如果团队里确实需要用 rebase 来保持历史线性一个更稳妥的做法是只在本地分支上 rebase推之前做一次 squash 整理推上去的提交视为不可变。这个约定比单纯说别 rebase更容易执行。还有一个常被忽略的点rebase 过程中每一个提交都可能产生冲突而且冲突可能要解决很多次每个被重放的提交各来一遍。如果分支上提交很碎、冲突文件又相同用git rerere记录并复用冲突解决方案能省大量重复劳动。开启方式是git config --global rerere.enabled true之后同样的冲突会被自动解决你只需要git add确认。5. 远端分支是一份只读快照不是别人电脑上的分支5.1 fetch、remote-tracking branch 与本地分支的三层关系很多人的困惑来自把origin/main和main当成了同一个东西。实际上这是三层完全不同的东西远端仓库里的main真实存在于服务器本地的origin/main是远端状态在你本地的只读快照只有fetch/pull时才会更新本地的main是你自己的工作分支只有你自己能移动它git fetch只更新第 2 层不动第 3 层也不动工作区。所以 fetch 之后git status可能会告诉你你的分支落后于 origin/main 3 个提交但你的代码一行没变。这是一个非常安全的信息同步动作我每天的例行操作就是git fetch --all --prune先看清楚全貌再决定做什么。git pull等于fetchmerge或fetchrebase取决于配置。默认的 merge 行为会产生合并提交如果本地有未推送的提交历史会变得很杂。我个人习惯配置git config --global pull.ff only让 pull 在有分叉时直接失败逼自己显式选择 merge 还是 rebase。这个配置看着麻烦但能避免很多莫名其妙多出一个合并提交的情况。5.2 upstream 的设置与它在 push/pull 时的作用本地分支可以通过upstream也叫 tracking关联到一个远端分支。设置之后git push、git pull、git status都能省略远端和分支名直接知道该跟谁交互。设置方式有两种# 首次推送时直接建立关联 git push -u origin feature/login # 对已有分支补充设置 git branch --set-upstream-toorigin/main main查看关联状态用git branch -vv输出会列出每个分支的 upstream 以及领先/落后的提交数。这个命令我几乎每天都会敲它给的是全局视图——哪个分支该推、哪个该拉一眼就清楚。一个小坑upstream的配置写在.git/config里是本地文件不会被提交。所以新建的本地分支如果不-u推一次别人是看不到这个关联的而你自己换台机器也要重新设。这不影响仓库本身但会影响这个分支该往哪儿推的默认行为。5.3 远端分支残留为什么列表里总有一堆 delete 不掉的git branch -r看到一堆已经删掉的远端分支这是常见现象。原因是远端的删除动作只会在你 fetch 时同步而默认的fetch不会自动清理已删除的远端跟踪引用。解决办法是加--prune# 一次性清理 git fetch --prune # 或者配置成默认行为 git config --global fetch.prune true我强烈建议把fetch.prune设为全局默认。不加这个配置origin/feature/old-xxx会一直挂在那儿时间长了分支列表能翻三屏而且容易误以为那些分支还在。还有一种残留更隐蔽远端分支确实还在但你推送时提示remote: error: cannot lock ref。这多半是因为本地存在一个名字相似但大小写不同的引用比如Feature/Login和feature/login在某些文件系统上会冲突。排查办法是git for-each-ref列出所有引用对比大小写这种情况只能删掉一个再重建。6. 实战一套能长期维护的分支约定与日常动作清单6.1 命名与生命周期主干、集成分支、特性分支分支模型不用搞得太复杂我实践下来最能长期维持的是两层半结构一条长期主干main若干短生命周期的特性分支feature/xxx、fix/xxx必要时加一条预发布分支release/x.y。特性分支的生命周期应该以天为单位超过一周的分支一定会开始积累大量冲突。命名上我坚持两个原则带类型前缀feature/fix/refactor/docs和短横线分词不用中文、不用空格、不用大写。前缀的价值在git branch列表里体现得最明显——一眼就能区分哪些是待合并的功能、哪些是临时救火。我见过用人名日期命名的团队分支列表里全是zhangsan-0815完全看不出内容最后没人敢删。分支的死亡时间也要有约定合并后立刻删。本地删git branch -d xxx远端删git push origin --delete xxx。-d是安全删除会检查是否已合并确认要强删时用-D。养成合并即删的习惯分支列表才能一直保持可读。6.2 每天都要敲的那几条命令工具的价值在于形成肌肉记忆。我把日常动作压缩成一张速查表贴在编辑器旁边场景命令说明开新功能git switch -c feature/xxx origin/main从最新主干拉分支避免从落后分支起手看全貌git log --graph --oneline --all --decorate -20一眼看清分叉与合并看关联git branch -vv每个分支跟谁同步、领先落后多少同步远端git fetch --all --prune只更新快照不改工作区暂存现场git stash push -m wip: xxx带说明方便日后找回清理已合并git branch --merged main | grep -v main | xargs -r git branch -d批量删已合并的本地分支其中从origin/main起手这一条是减少后续冲突成本最有效的做法。很多人习惯从本地main拉分支而本地main可能已经落后几十个提交等到要合并时才发现一堆冲突。改成从origin/main起手再配合每天fetch冲突量会明显下降。还有git stash push -m一定要带说明。不带说明的 stash 列表里全是WIP on main: 3f2a1b ...过两天根本认不出哪个是哪个。带说明的 stash 至少能靠git stash list找到线索。6.3 stash、cherry-pick、worktree 三个救场工具stash解决手上有活但必须切分支的问题前面提过了。补充一个细节git stash pop会删除 stash 记录如果 pop 时冲突了记录会保留你先解决冲突再git stash drop而git stash apply则始终保留记录。我一般用apply确认没问题后再手动drop多一步但更保险。cherry-pick解决只想拿某一个提交的问题。比如你在特性分支上顺手修了一个主干也需要的小 bug可以直接git cherry-pick sha把它摘到主干不必合整个分支。注意它同样是生成新提交、哈希会变所以不能指望靠哈希在两个分支间做对应关系。如果摘的是合并提交需要-m 1指定看哪个 parent这个参数不熟悉的话容易摘出诡异结果。worktree是这三个里最少人用、但最能提升效率的。git worktree add ../hotfix hotfix/urgent可以在另一个目录里检出另一个分支两个目录共享同一个.git对象库但工作区互不干扰。它的价值在于你不用再为了救火而 stash。主线继续开发另一个窗口处理紧急问题互不影响。我在需要同时对照两个分支的代码、或者跑长时间构建时几乎都会开一个 worktree。7. 出事之后的补救几个高频故障的完整排查链路7.1 误删分支从 reflog 里把提交捞回来链路是这样的先git reflog找到被删分支最后一次指向的提交哈希再git branch name sha重建。因为 reflog 记录的是 HEAD 的移动历史分支删除这个动作本身可能不在里面但该分支上最后一次 checkout/commit 一定在从那条记录里能看到哈希。如果 reflog 里也找不到比如超过保留期或者那次是在别的路径下操作还有两条后路git fsck --lost-found能列出所有悬挂提交没有被任何引用指向的 commit把它们逐个git show看内容找到目标再建分支或者git log -g --all查所有 ref 的 reflog。这两条后路的成功率取决于 gc 有没有跑过——一旦 gc 把不可达对象清掉就真没了。这也引出我的一条个人原则在删分支之前先git log --oneline -1 branch记下哈希写进当天的笔记。这个动作只要两秒但能免掉后面半小时的心跳加速。7.2 分离头指针怎么进去的怎么出来提交怎么救HEAD里不是ref: refs/heads/xxx而是一串哈希就是分离头指针detached HEAD状态。进去的方式很多git checkout sha、git checkout tag、git rebase过程中的中间状态、甚至某些工具的内部操作。这个状态本身不可怕可怕的是在里面提交。因为 HEAD 没有指向任何分支新提交只被 HEAD 直接引用一旦你切走这个提交就变成不可达对象看起来像提交丢了。救援办法还是git reflog——切走的那次操作会记录旧哈希找到后git switch -c rescue sha把它救到一个新分支上。想退出分离状态很简单git switch main或git switch -。如果你在分离状态下做了修改但还没提交切走时 Git 会警告并可能保留改动此时要么提交到临时分支要么 stash。我的建议是看到 detached HEAD 提示先别慌也别急着切走。先git status看清楚有没有提交有就立刻建分支。养成看完 tag 就切回分支的习惯能把这类问题消灭在萌芽。7.3 合并/ rebase 进行到一半想放弃合并冲突解决到一半发现方向错了git merge --abort可以回到合并前的状态前提是合并前工作区是干净的。如果是 rebase对应git rebase --abort同样要求工作区能恢复。这两个--abort之所以能完美回滚是因为 Git 在操作开始前把原始 HEAD 记在了.git/ORIG_HEAD或 rebase 的临时目录里。顺带一提git reset --hard ORIG_HEAD是另一个通用的回到上一次危险操作之前的办法它在 merge、rebase、pull 之后都能用。如果已经提交了合并但想撤销用git reset --hard HEAD~1回退一个提交合并提交算一个。注意这只适用于还没推送的情况推过之后要用git revert -m 1 merge-sha生成一个反向提交-m 1表示保留第一父链也就是你合并进去的那条主线。7.4 把错误合并推上去之后这是最需要谨慎处理的场景。原则只有一条不要用强推去改公共历史。正确做法是git revert生成反向提交把错误合并的影响抵消掉历史保持可追溯。虽然图会难看一点但所有人都能安全地pull到正确状态。如果错误合并里夹带了不该提交的敏感内容比如本地配置文件里带了密钥revert是不够的因为旧提交里内容还在。这种情况必须走改写历史 全员重新克隆的流程先在本地把那段历史清理掉然后通知所有协作者重新拉取最后在托管平台上清理缓存。代价很大所以更根本的防线是.gitignore和提交前 review——我现在的习惯是推之前一定git diff --stat origin/main扫一眼看看这批改动涉及哪些文件防的就是顺手 add 了不该 add 的东西。最后分享一个小技巧也是我自己用了很多年的给git log配个别名专门看带分支边界的历史。git config --global alias.lg log --graph --oneline --decorate --all -30配合--first-parent看主干、不带它看全貌两个视角切换着看分支之间的关系基本不会看错。工具本身不复杂真正花时间的是养成动手前先看一眼全景的习惯——踩过的坑多了你会发现大部分事故都不是因为不会命令而是因为闭着眼睛敲命令。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑