git rebase 之后同事说他的分支”全乱了”;git reset --hard 之后你以为一下午的提交没了;git status 显示 MM,你不知道提交会带上哪一版。这些困惑有同一个来源:把 Git 当成一堆要背的命令。Git 的命令行设计得确实混乱,但它底下的数据模型只有三种对象和一张名字表,小到一页纸写得下。弄懂模型,每条命令都只是”往库里加对象”或”挪一个指针”。

前置:Shell 与命令行环境。输出来自 macOS 自带的 git version 2.50.1 (Apple Git-155),演示仓库固定了作者和时间,hash 可复现。

打开一个 commit 看看

建一个小仓库:README.md、copy.txt,以及子目录 src/greet.txt,其中 copy.txt 和 greet.txt 的内容都是一行 hello。提交一次,然后用底层命令 git cat-file -p 把对象一个个打印出来:

$ git cat-file -p HEAD
tree 40f8e147a6eb922d59faf6a8536b5943875856f0
author Demo <demo@example.com> 1792548000 +0800
committer Demo <demo@example.com> 1792548000 +0800
 
first snapshot
$ git cat-file -p 'HEAD^{tree}'
100644 blob fc72a5c1094e203eefcd1c710f060957ebbbaac4	README.md
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	copy.txt
040000 tree 56ef077a153a22a552fb32288831d723bbc3e26c	src
$ git cat-file -p HEAD:src
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	greet.txt
$ git cat-file -p ce01362
hello

一层层剥下来,结构是这样的:

blob   = 文件内容的字节,不含文件名
tree   = 目录:{ 名字 -> blob 或 tree },外加文件权限
commit = { tree: 根目录快照, parents: [父 commit], author, committer, message }

commit 里没有”改了哪些行”,它记录的是整个项目在那一刻的完整快照(根 tree),外加是谁、何时、为什么。我们平时看到的 diff 是 Git 拿两个快照现场比出来的。文件名存在 tree 里而不在 blob 里,所以重命名一个文件只换 tree 的一条记录,内容 blob 原封不动。

注意 copy.txt 和 src/greet.txt 指向同一个 blob ce01362。仓库里一共只有 5 个对象:1 个 commit、2 个 tree、2 个 blob,两个 hello 只存了一份。

名字就是内容:内容寻址

每个对象的 ID 是对它内容算出的 SHA-1。blob 的算法简单到可以手算:在内容前面加上 blob <字节数>\0 的头部再取 hash。

$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a  -
$ echo hello | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

和上面 tree 里看到的 ID 一字不差。这叫内容寻址:对象存放在 .git/objects/ 下以 hash 命名的文件里,同样的内容必然得到同样的名字。三个性质由此而来:

去重。内容相同就是同一个对象,存一份。改 README 再提交一次:

$ git cat-file -p HEAD
tree 15675deba37502c6e189a18328f2e7c3c4469a91
parent 2f6406950fa8bbb29a4b095f1e1ff5e01280a254
...
docs: add usage
$ git cat-file -p 'HEAD^{tree}'
100644 blob 7ff50726880383a97c8e5411e871cedf9ceb2257	README.md
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	copy.txt
040000 tree 56ef077a153a22a552fb32288831d723bbc3e26c	src

src 还是那个 56ef077,copy.txt 还是 ce01362。这次提交只新增了 3 个对象(新 README blob、新根 tree、新 commit),对象总数从 5 变成 8。“每次存完整快照”听起来浪费,实际上没变的部分全是引用旧对象。(Git 之后还会把对象打包并做增量压缩,那是存储优化,不改变模型。)

不可变。对象的名字由内容决定,改一个字节名字就变了,所以”修改对象”在 Git 里不存在,只能创建新对象。commit 里有 parent 一行,记着父 commit 的 hash,于是任何 commit 的 hash 间接取决于它全部祖先的内容。改动历史上任何一点,之后每个 commit 的 hash 都会变,这一点后面讲 rebase 时会直接看到。

完整性。取出对象时重新算一遍 hash,对不上就是数据损坏。给你一个 commit hash,就等于给了你一份不可篡改的整段历史的指纹。

commit 通过 parent 连成一张有向无环图(DAG):普通 commit 一个 parent,第一个 commit 没有 parent,merge commit 有两个。仓库的历史就是这张图。

停一下,检查理解单选

用 git commit --amend 修改了上一个 commit 的说明,下面哪个说法正确?

引用:给 hash 起个名字

40 位 hash 记不住,于是 Git 维护一张名字表,叫 ref。对象不可变,ref 可变。

$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
2f6406950fa8bbb29a4b095f1e1ff5e01280a254

分支就是一个文件,里面写着一个 commit hash。 git branch fix 只是新建了 .git/refs/heads/fix 这个 41 字节的文件:

$ git branch fix
$ ls .git/refs/heads
fix
main
$ git switch fix
$ cat .git/HEAD
ref: refs/heads/fix

建分支不复制任何文件,所以 Git 鼓励随手开分支。每次 git commit,Git 新建 commit 对象,parent 设为当前分支指向的 commit,然后把当前分支的 ref 改成新 commit。分支会”跟着提交往前走”,原因就在这里。

HEAD 表示”你现在在哪”。它通常不直接存 hash,而是指向一个分支名,这样提交时才知道该推进哪个分支。git switch --detach <hash> 或 git checkout <hash> 之后,HEAD 直接写着一个 commit hash,叫 detached HEAD。此时提交的新 commit 不属于任何分支,切走之后没有名字指着它,只能靠 reflog 找回。

tag 是不会移动的 ref,用来标记版本:git tag v1.0。

于是可以用一句话概括 Git:仓库 = 对象库 + ref 表。所有命令要么往对象库里加东西,要么改 ref。HEAD~1、main~3、v1.0、hash 前缀 2f64069,都是在图上指定一个 commit 的不同写法。

停一下,检查理解单选

在一个有 1 万个文件的仓库里执行 git branch experiment,Git 做了什么?

暂存区:快照是怎么攒出来的

git commit 并不直接拍工作目录的照片,中间隔了一层暂存区(index)。git add 把文件此刻的内容写成 blob 放进暂存区,git commit 把暂存区整个做成一个 tree。于是同一时刻有三份内容:最新 commit、暂存区、工作目录。

为什么要多这一层?因为工作目录里的改动经常是混杂的。下面同时改了三处:src/greet.txt 是真正的功能,copy.txt 是临时试验,debug.py 是新建的调试脚本,只想提交第一个:

$ git add src/greet.txt
$ git status --short
 M copy.txt
M  src/greet.txt
?? debug.py

两列字母分别是两次比较:左列是暂存区对比最新 commit,右列是工作目录对比暂存区。M 表示已暂存, M 表示改了还没暂存,?? 是未跟踪的新文件。如果 add 之后又改了一次 greet.txt:

$ echo 'y' >> src/greet.txt
$ git status --short
 M copy.txt
MM src/greet.txt
?? debug.py
$ git diff --stat
 copy.txt      | 1 +
 src/greet.txt | 1 +
 2 files changed, 2 insertions(+)
$ git diff --staged --stat
 src/greet.txt | 1 +
 1 file changed, 1 insertion(+)

MM 是最容易踩的坑:此时提交,进入快照的是 add 那一刻的 greet.txt,后加的那行不在里面。git diff 看工作目录和暂存区的差别,git diff --staged 看暂存区和最新 commit 的差别,提交前看后者就知道这次到底会提交什么。

git add -p 能按块(hunk)挑选,同一个文件里的两处改动也能拆进两个 commit。其他常用操作:git restore --staged f 取消暂存但保留修改;git restore f 用暂存区的版本覆盖工作目录,未暂存的修改直接丢失,无法找回;git stash 临时收起所有修改;不该进仓库的文件(.venv/、__pycache__/、.DS_Store、密钥)写进 .gitignore。

停一下,检查理解单选

git status --short 显示 MM app.py,此时直接 git commit,会提交什么?

合并:把两条历史接回来

做菜谱的两个人从同一个 commit 分出两条分支:salty 把 1 cup sugar 改成 1 cup salt,sweet 把它改成 2 cups sugar。main 先合并 salty:

$ git merge salty
Updating 0834eb4..aefef58
Fast-forward
 recipe.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Fast-forward:main 自从分叉后没有新提交,它是 salty 的祖先。合并不需要任何新内容,只要把 main 这个 ref 往前挪到 salty 所在的 commit,没有新 commit 产生。

再合并 sweet 就不一样了,两边各有新提交。Git 做的是三方合并:找到两边的共同祖先(add recipe),分别算出”祖先 → main”和”祖先 → sweet”的改动,把两份改动叠加。改动落在不同位置时自动完成;两边改了同一行,Git 没法替你决定:

$ git merge sweet
Auto-merging recipe.txt
CONFLICT (content): Merge conflict in recipe.txt
Automatic merge failed; fix conflicts and then commit the result.
$ cat recipe.txt
2 eggs
<<<<<<< HEAD
1 cup salt
=======
2 cups sugar
>>>>>>> sweet
1 cup flour
$ git status --short
UU recipe.txt

<<<<<<< HEAD 到 ======= 是当前分支的版本,======= 到 >>>>>>> sweet 是被合并分支的版本。冲突是语义问题,不是 Git 的故障:把文件改成你真正想要的内容并删掉标记,git add recipe.txt 标记为已解决,再 git commit。不想合了就 git merge --abort 回到合并前。

$ git log --oneline --graph --all
*   5d3e081 Merge branch 'sweet'
|\  
| * 0ec93fc sweet
* | aefef58 salty
|/  
* 0834eb4 add recipe
$ git cat-file -p HEAD | head -3
tree 3f26bdc0d2944d661e2ab141b8a01059d494864c
parent aefef58e33635a340503418edb2877169712ec7c
parent 0ec93fcedd67a99d4d42df247182f1e3bb0aa32e

merge commit 和普通 commit 是同一种对象,只是有两个 parent。图上的分叉和汇合,就是 parent 字段的形状。

停一下,检查理解多选

关于 git merge,下面哪些说法正确?

rebase 与改写历史

merge 保留了真实的分叉,但历史会长出很多”小辫子”。另一种整合方式是 rebase:把当前分支独有的 commit 摘下来,逐个重新应用到目标分支的最新 commit 上。

notes 分支从 merge commit 分出,加了一个 commit;之后 main 又前进了一步:

$ git log --oneline --graph --all
* d4a458b docs: servings
| * e6ea127 feat: add notes
|/  
*   5d3e081 Merge branch 'sweet'
...
$ git switch notes && git rebase main
Successfully rebased and updated refs/heads/notes.
$ git log --oneline --graph --all
* b6e06c3 feat: add notes
* d4a458b docs: servings
*   5d3e081 Merge branch 'sweet'
...
$ git cat-file -p notes | head -2
tree 2f072a2afecdcfd17bfdc3dcc1d6ad7fb14f59dc
parent d4a458bc28e9a60aa685db1393d66466acf101e3

历史变成了一条直线,但 feat: add notes 的 hash 从 e6ea127 变成了 b6e06c3。按前面的模型这是必然的:parent 变了,commit 的内容就变了,只能是一个新对象。旧的那个还在库里,只是没有 ref 指向它:

$ git cat-file -t e6ea127
commit

这就引出 rebase 唯一的硬规则:不要 rebase 已经推送、别人可能基于它工作的分支。 你本地的 b6e06c3 和同事手里的 e6ea127 是两个不同的 commit,内容相同但身份不同。你强推之后,他再拉取,Git 看到的是两段分叉的历史,他的工作会和你”重复”的 commit 纠缠在一起。只在自己本地、还没分享的分支上 rebase,比如提 PR 之前用 git rebase -i HEAD~3 合并、改写、重排最近几个 commit。

mergerebase
历史形状保留真实分叉,多一个 merge commit一条直线
是否创建新的 commit 副本否是,被重放的 commit 全部换 hash
冲突一次解决每个 commit 重放时可能各冲突一次,解决后 git rebase --continue
适用共享分支自己的本地分支

既然”改历史”只是”建新对象 + 挪 ref”,那么旧对象几乎总能找回来。Git 把 HEAD 每一次移动记在 reflog 里:

$ git commit --amend -m "feat: add serving notes"
$ git reflog -4
1711ad4 HEAD@{0}: commit (amend): feat: add serving notes
b6e06c3 HEAD@{1}: rebase (finish): returning to refs/heads/notes
b6e06c3 HEAD@{2}: rebase (pick): feat: add notes
d4a458b HEAD@{3}: rebase (start): checkout main

git reset --hard <rev> 把当前分支挪到指定 commit,并用它覆盖暂存区和工作目录。它对已提交的东西是安全的:

$ git reset --hard HEAD~2
$ git log --oneline -1
5d3e081 Merge branch 'sweet'
$ git reset --hard 'HEAD@{1}'
$ git log --oneline -1
1711ad4 feat: add serving notes

两个 commit “消失”又回来了,因为对象一直都在。真正找不回的是从未提交的修改:reset --hard 和 git restore 覆盖掉的工作目录内容从没变成过对象。reflog 只在本地,没被引用的对象过一段时间会被垃圾回收,所以”能找回”不等于”永远在”。

已经推送的错误提交,正确的撤销方式是 git revert <rev>:新建一个内容相反的 commit,不改写任何已有历史,别人拉取时毫无痛苦。

停一下,检查理解单选

你在本地 git rebase main 后,git push 被拒绝,于是用 git push --force 强推到一个同事也在用的分支。最可能的后果是?

远程与协作

远程仓库就是另一份完整的 Git 仓库,在 GitHub 上、在服务器上,或者就在本机另一个目录。clone 下来后,本地会为远程的每个分支保留一个副本,叫远程跟踪分支,如 origin/main。它也只是一个 ref,只在和远程通信时更新。

用一个本地 bare 仓库当 origin 演示:

$ git push -u origin main
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.
$ git commit -qam "docs: tip"
$ git status -sb | head -1
## main...origin/main [ahead 1]

push 做两件事:把远程缺的对象传过去,然后请求远程把它的 main 指向你的 main。远程只接受 fast-forward 式的移动,也就是新位置必须是旧位置的后代。这时另一位同事 alice 先推送了一个 commit:

$ git push origin main
 ! [rejected]        main -> main (fetch first)
$ git fetch -q && git status -sb | head -1
## main...origin/main [ahead 1, behind 1]
$ git log --oneline --graph main origin/main | head -4
* 8017c2e feat: alice
| * a189e3a docs: tip
|/  
* 1711ad4 feat: add serving notes

两边分叉了,直接把远程的 main 挪到你的 commit 会丢掉 alice 的提交,所以被拒绝。git fetch 只下载对象、更新 origin/main,不动你的分支和文件;git pull 等于 fetch 加一次 merge(或 --rebase)。你的 docs: tip 还没推送过,rebase 它是安全的:

$ git pull -q --rebase
$ git log --oneline -3
9090282 docs: tip
8017c2e feat: alice
1711ad4 feat: add serving notes
$ git push -q && git status -sb | head -1
## main...origin/main

Git 是工具,GitHub 是托管服务,Pull Request 是托管平台的功能。开源项目常用 forking workflow:在平台上 fork 主仓库,在本地为每个改动开一个独立的 feature 分支,推到自己的 fork,再向主仓库发 PR;根据 review 往同一个分支追加提交,维护者合并。每个 PR 一个分支,否则不同改动会混进同一个 PR。

commit message 是写给未来读者的。Conventional Commits 规定了一种机器可读的格式:<type>(<scope>): <摘要>,常用 type 有 feat(新功能)、fix(修 bug)、docs、refactor、test、chore,不兼容改动加 ! 或在正文写 BREAKING CHANGE:。上面演示里的 feat: add notes、docs: tip 就是这种格式。好处是工具能据此自动生成 changelog、决定下一个版本号该升哪一位;对人来说,扫一眼 git log --oneline 就知道每个提交属于哪类。正文写为什么这么改,改了什么 diff 里已经有了。

历史整洁还有一个回报:出了 bug,git bisect 能在 commit 图上二分查找是哪一次提交引入的。标记一个好的、一个坏的 commit,Git 每次检出中点让你判断,log2(N) 步就能定位;配合 git bisect run <测试脚本> 可以全自动完成。每个 commit 都小、都能编译通过,bisect 才能精确指出那一个。git blame file 则告诉你每一行最后是哪个 commit 改的,再 git show 那个 commit 读它的说明。

停一下,检查理解单选

git status -sb 显示 ## main...origin/main [ahead 1, behind 1]。下面哪个理解正确?

记住这三件事

  1. Git 存的是快照:blob(内容)、tree(目录)、commit(快照 + parent + 说明),全部以内容的 hash 命名,因此去重、不可变、可校验。
  2. 分支、HEAD、tag 都只是指向 commit 的 ref;所有命令要么新增对象,要么移动 ref,“改历史”其实是建新 commit 再挪 ref,旧 commit 可以从 reflog 找回。
  3. merge 保留分叉,rebase 换 parent 生成新 hash;只 rebase 没推送过的分支,撤销已推送的提交用 revert。

延伸阅读