2005年,Linus Torvalds写Git只用了10天。20年后,全球4300万开发者每天敲着git add、git commit,却说不清.git文件夹里到底存了什么。这不是用户的错——教程从来先教命令,再让你死记硬背。结果?一个冲突就慌到git push --force,然后祈祷。

Git的本质是一台内容寻址文件系统,版本控制只是跑在上面的应用。每个文件、每棵目录树、每次提交,都是哈希对象,躺在.git/objects/里。分支、标签、HEAD,全是指向这些对象的指针。搞懂这层,命令自然归位。

四张底牌:Git的对象类型

四张底牌:Git的对象类型

Git只认四种对象。不是五种,不是三种,恰好四种——这个数字从1.0到现在没变过。

blob(二进制大对象)存文件内容,不管文件名。你改个文件名?新blob。改个空格?新blob。Git用SHA-1算哈希,前两位当文件夹名,后38位当文件名,塞进.git/objects/。两个内容完全相同的文件,磁盘上只存一份。这叫去重,也是Git仓库能瘦下来的原因。

tree(树对象)存目录结构。它是一份清单:文件名、blob哈希、文件类型权限。递归下去,整棵目录树就被拍扁成一串哈希引用。checkout某个commit时,Git就是顺着tree往下爬,把blob还原成文件。

commit(提交对象)存元数据:作者、时间、提交信息,以及一个指向tree的指针。关键是它还存父提交的哈希——一个commit可以有零个(初始提交)、一个(常规提交)或多个父提交(合并提交)。这条父指针链,就是Git版本历史的骨架。

tag(标签对象)最冷门。轻量标签只是引用,附注标签才是完整对象,存着打标签的人、时间、GPG签名,以及指向commit的指针。大多数人没用过,但release版本上那个带签名的tag,安全性就靠它。

有向无环图:Git的隐藏数据结构

有向无环图:Git的隐藏数据结构

四种对象拼起来,构成一张图。节点是commit,边是"父提交"关系。这张图有两个硬性约束:有向(箭头只能从子指向父,不能回头),无环(不能出现A是B的祖先、B又是A的祖先)。数学上叫DAG(有向无环图),这是Git真正的底层协议。

分支是什么?只是指向某个commit的指针。master、feature-x、HEAD,全是refs文件夹里的文本文件,内容是一串40位哈希。切换分支?改一下指针指向。创建分支?新建一个文本文件。删除分支?删文件。所谓"分支",廉价到近乎免费。

rebase的本质是重写DAG。你把feature分支接到master最新commit后面,Git不是"移动"节点,而是复制feature上的每个commit,算出新哈希,再让feature指针指向新的末端。旧节点还在objects里,只是没人引用了——git gc才会清理。所以rebase"改写历史",其实是"伪造历史副本"。

merge则不同。它新建一个commit,有两个父提交,把两个分支的末端捏在一起。DAG从此分叉又汇合,像河流汇入。这是真正的协作痕迹,不会丢。

为什么git push --force会出事

为什么git push --force会出事

远程仓库也是一张DAG。你本地rebase后,feature分支的末端commit哈希变了。强制推送时,远程发现你提交的"新历史"和现有历史接不上——你的分支底端,不是远程认为的那个父提交。

Git拒绝这种"断裂",除非你用--force。你强行覆盖,远程的DAG被改写,其他人的本地分支瞬间变成"悬挂提交"(dangling commit)。他们pull下来,Git傻眼:我本地的feature指向的commit,远程怎么没了?

--force-with-lease的存在,就是为了防止你覆盖别人刚推的内容。它先检查远程refs是否和你上次fetch时一致,不一致就拒绝。这是2004年那批内核开发者用血换来的教训——Linus本人就骂过乱用force的人。

HEAD和~3到底什么意思

HEAD和~3到底什么意思

HEAD是当前分支的别名,通常指向一个分支引用,分支再指向commit。detached HEAD状态?就是HEAD直接指向commit,没有分支这层缓冲。你提交后,新commit没有分支名保护,切走就丢——除非记住哈希,或者reflog救命。

~3是祖先运算符。HEAD~3 = HEAD的父提交的父提交的父提交。^用于选择多父提交的某个分支,HEAD^2是merge commit的第二个父提交。这些符号不是语法糖,是DAG上的导航指令。

git rebase -i HEAD~3,现在拆解:从当前commit往回数3代,把这3个提交挑出来,交互式重演。你可以改顺序、合并不必要的提交、改提交信息。每个操作对应DAG的剪切重组——Git把你画的草图,翻译成objects目录里的一堆新哈希。

GitLab在这张图上加了什么

GitLab在这张图上加了什么

GitLab没有改动DAG,它在上面盖了一层协作协议。Merge Request不是Git概念,是GitLab的发明:一个指向源分支的指针,加上目标分支、讨论线程、CI状态、审批规则。

当你点"Merge"按钮,GitLab执行的是git merge --no-ff(默认),强制生成一个merge commit,哪怕能fast-forward。为什么?为了保留MR的存在痕迹——代码审查、讨论、CI结果,全挂在这个merge commit上。纯fast-forward会让这些元数据无处附着。

Squash and merge则是另一种策略:把源分支的所有commit压成一个,再rebase到目标分支。DAG变简洁了,但细粒度的提交历史没了。团队选哪种策略,是在"可读历史"和"可审计历史"之间做权衡。没有标准答案,只有约定。

Git的objects目录是内容寻址的终极形态。你删了文件、改了分支、reset --hard到三天前,那些旧对象还在,直到git gc扫描到无人引用才清理。reflog是最后一道保险,记录HEAD的每次移动,默认保留90天。所谓"删了找不回来",90%的情况是不知道reflog存在。

20年前Linus设计这套系统,是为了管理Linux内核的分布式开发。今天4300万人用它,却多数人把它当成SVN的替代品。Git不是魔法,是显式化的DAG操作。命令是表象,对象和指针才是真相。

你最近一次看.git/objects/目录,是什么时候?