资讯详情

资讯详情

Git分支原理与合并实践:从对象模型到冲突恢复全解析

用 Git 这些年被问得最多的问题不是“命令怎么写”而是“分支到底是个什么东西”。很多人用了两三年脑子里仍然有一个模糊的模型分支像是一份代码副本切分支就是把整个目录复制一遍。这个误解一旦扎根后面所有关于合并、变基、冲突、找回丢失提交的讨论都会变形——因为你会在错误的心智模型上做决策。这篇内容就把 Git 分支的原理与使用一次性讲透分支在磁盘上到底是什么、为什么创建它能快到几乎无感、merge 和 rebase 各自改变了什么、冲突产生的根源在哪、误操作之后怎么把东西捞回来。适合刚接手团队仓库的新人也适合那些天天敲命令但说不清背后机制的老手看完你至少能对“我现在这一下操作会改动哪几个文件”心里有数。1. 先把“分支”这层窗户纸捅破1.1 分支不是代码副本是一个装着哈希值的小文件打开任意一个仓库的.git目录进到refs/heads/下面你会看到一堆以分支名命名的文件。用文本编辑器打开其中一个里面只有一行内容一个 40 位的十六进制字符串末尾带个换行。就这些没有别的了。这个文件就是分支的全部实体。所谓“创建一个分支”本质上是往这个目录里写一个 41 字节的文件所谓“切换分支”本质上是改一下.git/HEAD里指向的路径然后让工作区跟上。这件事听起来简单得有点反直觉但它解释了一个很多人困惑的现象为什么在一个几十万文件、几个 G 的仓库里git switch一个新分支几乎是瞬间完成的因为它根本不需要复制任何东西。真正让切换变慢的是工作区文件的更新动作而不是分支创建本身。同理为什么你在一个分支上删代码切到另一个分支代码又回来了因为那些文件内容存在提交对象里分支只是指向某个提交的路标路标换了你看到的世界自然就变了。我把这个模型叫做“书签模型”。一本书提交历史已经印好了书签分支只是夹在某一页。你挪动书签不会让书的内容发生变化你在书签所在页做的新笔记新提交会形成新的一页然后书签自动前移到那一页。理解了这一点后面所有的“分支”行为都只是这句比喻的推论。1.2 一次提交背后到底存了什么要真正讲清分支绕不开 Git 的对象模型。Git 把仓库里的所有东西都存成对象放在.git/objects/里用内容的 SHA-1 值做文件名。核心就三类blob、tree、commit。blob 存文件内容本身不存文件名tree 相当于目录记录“哪个名字对应哪个对象”条目里带权限位和类型commit 则指向一个 tree代表这次提交时的整个项目快照同时记录父提交、作者、提交者、时间戳和提交信息。关键点在于commit 存的是完整快照不是差异。每次提交都会生成一棵完整的 tree未改动的文件复用之前已有的 blob 对象所以存储上并不浪费但逻辑上每一次提交都是一个“全量状态”。这就是为什么你可以随便跳到历史上任何一个提交代码都能完整还原——不存在“从某个基准点开始做增量回放”这种事。commit 对象的哈希值是对一段固定格式的头部加上内容做 SHA-1 得来的大致是commit 字节数\0加上 tree、parent、author、committer、空行、message 这些字段拼接。这意味着任何一个字段变了包括父提交变了哈希就会完全变。这条性质是后面理解 rebase 为什么会改写提交 ID 的钥匙务必记住。1.3 HEAD 与引用两个层级的路由表.git/HEAD文件里写的通常是ref: refs/heads/main这样的内容它是一个“指向指针的指针”。当你处于正常分支状态时HEAD 不直接存哈希而是存分支文件的路径。这样新提交产生时Git 只需要更新分支文件里的哈希值HEAD 跟着自动生效省了一次写操作。除了refs/heads/本地分支还有几个位置值得知道路径含义典型用途refs/heads/*本地分支日常开发refs/remotes/origin/*远程跟踪分支记录上次与远端同步时的状态refs/tags/*标签发布版本锚点.git/HEAD当前所在位置决定工作区内容.git/logs/reflog 日志找回误删、误重置远程跟踪分支这个名字容易误导人它并不是“远端服务器上的分支”而是“我本地上次看到远端是什么样”的一份快照。git fetch干的事就是更新这份快照它不会动你的工作区也不会动你的本地分支。搞不清这一点的人经常会在git pull之后一脸茫然为什么多了一堆合并提交。提示想快速看清楚当前分支指向哪个提交cat .git/refs/heads/分支名和git rev-parse 分支名得到的是同一个值。前者更直观地说明“分支就是一个文件”。1.4 分离头指针是怎么产生的当 HEAD 里不再写ref: refs/heads/xxx而是直接写一个提交哈希时你就处于 detached HEAD 状态。触发场景很常见git checkout 某个提交哈希、git checkout v1.2.0检出标签、rebase 过程中的中间态、某些 CI 环境里的默认检出方式。这个状态本身不危险危险的是在这个状态下提交。你的新提交确实生成了但没有分支指向它一旦切走这个提交就变成“不可达对象”只能靠 reflog 或哈希值捞回来最终会被gc清理。实操上有个非常省事的做法发现自己在 detached HEAD 上做了提交别慌直接git switch -c 临时分支名这个命令会把当前 HEAD 位置建成一个新分支之前那些提交立刻有了归属。养成习惯检出历史提交做实验之前先建个临时分支成本只有 41 字节。2. 分支模型怎么选取决于团队而不是工具2.1 长分支和短分支的成本差异在哪分支本身不花钱花钱的是“分支存在的时间”。一个分支存活越久它偏离主干的提交越多合并时共同祖先越遥远需要比对的文件越多冲突面积越大。这不是 Git 的性能问题是协作的物理规律。我见过一个团队需求分支开了三个月才合回主干合并那天两个人在会议室待了一整个下午。事后复盘实际冲突的文件只有五个但每个文件里两边的改动已经交织在一起语义层面的冲突远超文本层面工具帮不上忙。反过来如果一个分支从拉出到合并控制在两天以内共同祖先很近三路合并的比对范围小冲突往往只在几十行内几分钟就能处理。分支的寿命直接决定了合并的痛苦程度。这句话我认为比任何分支模型理论都实用。2.2 三种主流策略别照搬策略核心特征适合场景主要代价主干开发所有人往 main 提交靠特性开关遮蔽未完成功能持续交付、发布频繁、测试覆盖好对自动化测试和开关治理要求高特性分支流每个需求一条分支合并回主干后删除中等规模团队、需求粒度清晰分支寿命容易失控多环境长分支develop、release、hotfix 多线并行有固定发版窗口、多版本并行维护合并路径复杂容易漏合第三种常被诟病但它在有版本维护义务的场景下确实省事。问题不在模型本身在于很多人上了这套模型却没配套的合并纪律导致 hotfix 修在 release 上主干没同步下个版本又冒出来。我的建议很直接团队小于十人、发版周期小于两周直接用主干开发加短特性分支别搞多环境长分支。真要并行维护多个版本那也是发布之后从 tag 拉维护分支而不是提前铺一堆环境分支。2.3 分支命名与清理的实操约定命名这件事看着小实际上是仓库可维护性的一部分。我倾向于用类型/简短描述-工单号的形式比如feat/login-rate-limit-1024、fix/export-timeout-2048。好处是git branch一列出来就能看出哪些是功能、哪些是修复配合--sort-committerdate还能按活跃度排序。清理要主动做。合并完立刻删本地分支同时把远端分支删掉git switch main git pull --ff-only git branch -d feat/login-rate-limit-1024 git push origin --delete feat/login-rate-limit-1024-d是小写删除它会检查该分支是否已经合并进当前分支没合并就拒绝删除并提示你。这是一个安全网别图省事直接用-D。真正需要强删的场景很少通常是分支内容已经被 squash 合并或者干脆作废了。还有个容易被忽略的点git fetch默认不会清理本地已经不存在的远程跟踪分支。时间长了git branch -a会列出一大堆早就被删掉的分支看着很乱。加一个配置一劳永逸git config --global fetch.prune true之后每次 fetch 都会顺手清掉失效的远程跟踪引用。3. 从建分支到合并每一步到底改了什么3.1 最小命令集与它们背后的动作先说创建和切换。老命令是git checkout -b 名字新命令拆成了git switch -c 名字。拆分的理由是checkout承担了太多语义切分支、还原文件、检出提交全是一个命令误用率极高。switch只管切分支restore只管还原文件语义清晰我强烈建议新项目统一用新命令。# 基于当前 HEAD 创建并切换 git switch -c feat/demo # 基于远端分支创建本地跟踪分支 git switch -c fix/demo origin/fix/demo # 只创建不切换 git branch feat/demo-only这三条命令执行后磁盘上发生的事完全一样在refs/heads/下写一个文件然后改HEAD。区别只在于后两条多做了一步工作区同步。切换分支时如果工作区有未提交的改动Git 会尝试把改动“带过去”。如果目标分支和当前分支在这些文件上没有差异改动会跟着走一切顺利如果有差异且会覆盖Git 会拒绝切换并报错。这个行为经常让人困惑觉得“我明明只是想切个分支”。正确的处理是先 stash 或者先提交别硬来。查看状态这块我最常用的两个组合# 图形化看全部分支的历史走向 git log --oneline --graph --all --decorate -20 # 看两个分支的分叉点 git merge-base main feat/demo第一条命令里的--graph是理解合并关系的关键字符画虽然朴素但能一眼看出哪里分叉、哪里汇合。第二条给出的是共同祖先的哈希这个值就是三路合并的基准点后面讲冲突时会反复用到。3.2 merge 的三种结果以及 --no-ff 该不该加merge 的结果分三种情况很多人只知道两种。第一种是快进fast-forward。当目标分支是当前分支的直接祖先时Git 不需要做任何合并计算直接把当前分支的指针挪过去就行。这种情况不产生新的提交历史是一条直线。执行后你会看到 “Fast-forward” 的提示。第二种是三方合并。两个分支各有对方没有的提交Git 找出共同祖先作为基准把三方内容做比对生成一个新的合并提交。这个提交有两个父提交。第三种是已经是最新。目标分支的所有提交当前分支都有merge 什么都不做直接返回。--no-ff参数的用途是即使可以快进也强制生成一个合并提交。为什么有人要这么干因为快进会让特性分支的所有提交直接变成主干上的线性历史事后你无法从历史图里看出“这一段是作为一个整体合进来的”。对于需要回溯“哪个需求引入了这批改动”的场景保留合并提交很有价值。反过来如果你追求极简线性历史那就该用 rebase 而不是 merge。我的取舍是主干合并用--no-ff保留结构个人分支同步主干用 rebase 保持干净。混着用各取所长。3.3 rebase 到底做了什么为什么会被叫做“重写历史”rebase 的行为可以用一句话概括找到两个分支的共同祖先把当前分支上祖先之后的所有提交逐个在目标分支的最新提交之上重新“播放”一遍。这里的“播放”不是简单地打补丁而是对新基底做一次三方合并然后生成一个全新的提交对象。因为是新对象作者时间可能保留但提交者时间会变父提交变了哈希必然全变。举个具体例子。main在提交 C 上你从 C 拉出分支做了 D 和 E 两次提交。这期间main前进到了 F。现在执行git rebase mainGit 做的是找到共同祖先 C把 D、E 的改动暂存起来把分支指针移到 F以 F 为基准重放 D 的改动生成 D以 D 为基准重放 E 的改动生成 E分支指针指向 E。结果就是历史变成 C→F→D→E一条直线。原来那条 C→D→E 的路径还在对象库里reflog 里能找到但没有分支指向它了。这个过程的风险点在于每一步重放都可能冲突。两次提交如果都会让你反复解同样的冲突那git rebase会让人很烦躁。老手会开git config --global rerere.enabled true让 Git 记住你解过的冲突下次遇到同样片段自动套用。还有一个降低痛苦的手段是git rebase -i交互式变基。它能做的事远不止“修改基底”合并提交squash/fixup、调整顺序、改提交信息、删掉某个提交、把某个提交拆开。这个命令值得单独花时间练习我在提交评审前经常用它把一串“改了个错别字”“再改一下”整理成两三个语义清晰的提交评审的人会感谢你。注意rebase 只应该用在“还没有别人基于它工作”的提交上。已经推送到共享分支的提交一旦你 rebase 后强推别人本地基于旧提交的所有工作都会对不上合并时会把旧提交又带回来历史直接变成双份。这条规矩没有例外团队里该写进规范文档。3.4 几个边缘但极其好用的操作git cherry-pick解决的是“我只想要那一个提交”的场景。比如线上出问题你在开发分支上修好了想把这一处改动搬到发布分支而不带其他半成品代码。用法就是git cherry-pick 哈希。它本质上是把那个提交的改动在当前 HEAD 上重放同样会生成新哈希同样可能冲突。git stash是人人都该会的应急操作。手上有半成品代码突然要切分支处理急事git stash push -m 描述存起来切走回来git stash pop恢复。-m加描述很重要因为git stash list默认显示的是“WIP on main: 一堆哈希”过两天你根本不知道哪个是哪个。还有git stash push -u可以连未跟踪文件一起存避免新写的文件被落下。git worktree是我近两年才知道真香的命令。它的作用是从同一个仓库同时检出多个工作目录每个目录对应不同分支。以前为了对比两个分支的代码我只能在同一个目录反复切换或者干脆 clone 两份又占空间又容易搞混。现在直接git worktree add ../demo-hotfix hotfix/urgent cd ../demo-hotfix # 这里改 hotfix原目录不受影响两个目录共享同一个.git对象库所以不重复占用空间分支也不会互相干扰。缺点是它会让你忘记哪个目录对应哪个分支建议用完就git worktree remove别囤着。4. 冲突这件事根源在语义不在文本4.1 冲突标记到底怎么读冲突的本质是三路合并时同一个文件的同一个区域两边都相对基准点做了修改且修改结果不同Git 无法判断谁对。落在文件里就是这段标记 HEAD 当前分支的内容 被合并分支的内容 feat/demo和之间是当前检出分支也就是 HEAD 所在分支的版本和之间是要合进来的那个分支的版本。这个方向感一定要建立很多人解冲突时凭感觉删删完发现逻辑反了。处理方式有三种留左边、留右边、或者两边都不要重新写。第三种其实最常见因为真正的业务逻辑经常需要把两边改动合并成一段新代码而不是二选一。解完之后git add标记为已解决然后git commitmerge 情况下或者git rebase --continuerebase 情况下。注意 rebase 中途的提交命令不一样容易搞错。4.2 ours 和 theirs 在 merge 与 rebase 里是反的这是一个几乎所有人都会踩一次的坑。merge 场景下--ours是当前分支--theirs是来源分支符合直觉。但在 rebase 场景下Git 的视角是“把当前分支的提交重放到新基底上”也就是说“我要去的地方”新基底在逻辑上是 ours而你原来的提交变成了 theirs。这个反转非常反直觉但只要记住 rebase 是“以别人为基准重建我的提交”就能推出来。实际操作中我建议不要死记而是在冲突时先看一眼git status的输出再用git show :1:文件路径、:2:、:3:分别查看基准版本、ours 版本、theirs 版本看清楚了再决定。git show :1:src/config.js # 共同祖先版本 git show :2:src/config.js # ours git show :3:src/config.js # theirs这套命令在冲突复杂的时候非常管用比在编辑器里对着标记猜要靠谱得多。4.3 降低冲突频率的几个习惯冲突多不多很大程度上不是工具问题是习惯问题。第一主干变动频繁的项目每天开工第一件事就是同步主干。git fetch之后在自己的分支上git rebase origin/main把冲突分摊到每天的小批量里。攒一周再合就是一次性面对所有冲突。第二别在同一段代码上并行开工。这话听起来是废话但实际项目中经常发生两个人同时重构同一个工具函数一个改签名一个改内部实现。要么提前沟通分工要么把重构和功能拆成两个前后依赖的任务。第三代码格式化这类改动单独提交、单独合并别和业务改动混在一起。一次全文件格式调整会让后续所有 diff 都变得难以阅读冲突检测也会失效。第四善用.gitattributes声明合并策略。比如锁文件、自动生成的代码可以标记为mergeours或者干脆不参与合并。这类文件冲突是纯噪音处理它们的时间和风险完全不值得。5. 误操作排查实录与速查表5.1 分支被删了提交还在吗在本地的几乎都在。删分支只是删掉refs/heads/下那个文件提交对象本身还在对象库里只要还没被垃圾回收默认策略下未引用的对象会保留一段时间。git reflog git branch rescue reflog里看到的哈希reflog记录的是 HEAD 和分支引用的移动历史默认保留 90 天。找到删除前最后一次提交的哈希建个分支接上就完事。如果是远端分支被误删麻烦一点因为服务端的 reflog 你看不到。但只要本地有人还有那个分支的引用或者你本地就有远程跟踪分支origin/xxx直接推回去就行git push origin origin/feat/demo:refs/heads/feat/demo前提是本地那个origin/feat/demo引用还在。所以有个好习惯是删远端分支之前先确认没有其他人正在基于它工作。5.2 reset 三种模式的区别一句话记住git reset是高频误用命令。三种模式的区别在于“重置到哪个区域”模式移动分支指针重置暂存区重置工作区典型用途--soft是否否撤销提交但保留改动在暂存区--mixed默认是是否撤销提交和暂存改动留在工作区--hard是是是彻底丢弃改动没了最危险的是--hard它会直接覆盖工作区文件。但即便是--hard被丢弃的提交本身仍可通过 reflog 找回只是工作区里那些还没提交的改动是真的没了。所以真正不可逆的是“未提交内容被覆盖”不是“提交被撤销”。我踩过一次git reset --hard之后发现写了两小时的东西没提交。从那以后养成了两个习惯写一会儿就git add -p分批暂存以及重要改动先git commit -m wip占个位宁可事后 squash。wip 提交不值钱丢失的工作值钱。5.3 合并完了才发现合错分支如果合并提交还没推送最干净的处理是git reset --hard HEAD~1回到合并前重新合。前提是合并后的工作区改动你都不要了。如果已经推送了别 reset用git revert -m 1 合并提交哈希。-m 1表示保留第一个父提交也就是你合入的那个分支的线路撤销掉另一个分支带来的改动。这个参数不写会报错因为合并提交有两个父Git 不知道你要以哪条线为准。这里有个著名的坑revert 一个合并提交之后那个分支以后再想合进来之前被 revert 掉的那些改动不会自动恢复因为 Git 认为它们“已经合过了”。解决办法是先 revert 掉那次 revert再重新合并。这个流程很容易绕晕所以能 reset 就 reset别在共享分支上折腾 revert 合并。5.4 常见问题速查表现象大概率原因处理方式切换分支提示会覆盖本地改动目标分支该文件与当前工作区不同先 stash 或先提交pull 后多出一堆合并提交本地有提交远端也有配置为 mergegit config pull.rebase truepush 被拒绝提示 non-fast-forward远端有你本地没有的提交先 fetch再 rebase 后推送历史图里出现两条平行线然后又合并本地 merge 了远端分支正常现象rebase 可避免提交里出现了不该有的文件.gitignore没覆盖到或已经跟踪git rm --cached后再提交大小写重命名在仓库里没生效文件系统大小写不敏感git mv分两步改名5.5 把不该提交的东西塞进去了怎么办分两种情况。如果只是加进了暂存区还没提交git restore --staged 文件撤出来就行。如果已经提交并且推送了那就得处理历史。最省事的办法是把文件加进.gitignore然后git rm --cached 文件再提交一次。缺点是历史里那个文件还在仓库体积不会减小敏感内容也还在旧提交里。真要清理历史得用git filter-repo这类工具重写所有涉及该文件的提交然后所有人重新克隆。这个操作破坏性很强必须提前通知全团队停手否则有人推一下旧历史清理就白做了。我的态度是涉及凭据泄露必须清其他情况比如误提交了一个大日志文件评估一下仓库体积影响权衡后再决定别为了洁癖大动干戈。6. 我在实际使用中沉淀的几条经验关于分支我最想强调的一条是先想清楚这次操作会不会动到已经共享的提交然后才敲命令。会动的rebase、reset、amend、filter用在没推送的本地提交上怎么折腾都行有 reflog 兜底不会动的merge、revert、新建提交才用在共享分支上。这条判断标准比背任何命令都管用。第二条是别怕用图形工具。命令行我用了很多年但看复杂的分支拓扑时git log --graph的字符画还是有局限尤其是几十条分支交织的时候。找个顺手的可视化客户端看一眼全貌再回命令行精确操作效率高得多。工具没有高下之分能让你把历史看清楚的就是好工具。定义一个顺手的别名也值回票价。我的~/.gitconfig里有这么几行每天能用上十几次git config --global alias.lg log --oneline --graph --all --decorate git config --global alias.st status -sb git config --global alias.last log -1 --stat git config --global alias.unstage restore --staged第三条是在团队里推行一条硬规矩main分支开启保护禁止直推合并必须走评审。这不是不信任谁而是给“手滑”留一道闸。很多人第一次强推覆盖主干都是在自己本地测试时不小心带着--force推错了分支。有了保护最坏情况也就是推失败报个错损失为零。最后分享一个排查思路当你搞不清“现在到底在哪、跟谁分叉、差哪些提交”时按这个顺序走一遍——git status -sb看当前分支和领先落后数git log --oneline --graph --all -20看拓扑git merge-base HEAD 目标分支找分叉点git log 分叉点..目标分支 --oneline看对方有哪些你没有的提交。这四步走完任何分支关系的谜团基本都能解开比瞎猜快得多。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →