生成式软件工程
搜索
开始阅读/第 3 部分 · 管住变化/第 7 章

版本管理:给项目拍快照

每个写代码的人最早学会的一句祷告是「能不能退回去」。版本管理把这句祷告做成了一套工具。

这一页讲什么
  • 从「我想撤销一步」这个愿望,一步步推出「快照」这个概念;
  • 一次提交里装着什么,Git 又为什么不必把每个文件复制一千遍;
  • 工作区、暂存区、提交历史各管什么,add 之后又改一次,交上去的到底是哪一版;
  • 分支怎么分叉、合并冲突时文件里长什么样、改写历史之前先想清楚什么;
  • 快照装不下的两件事:制品之间的追踪关系,与一项改动在反复修改中的身份。

一、从「我想退回去」这句话开始

昨天的程序还能正常运行,今天一早打开,一启动就报错。你改了一上午,试了几十遍,已经想不起是哪一行把东西弄坏的。这时候冒出来的念头非常朴素:能不能退回去。

最直接的办法是复制整个目录,把副本改名叫「作业-v1」「作业-v2」「作业-最终版」。两个版本的时候还行,到第五个版本就会撞上三件麻烦事。

前两件麻烦靠「多存几份」勉强能扛,第三件扛不住。它逼着我们换一个问法:光保存项目现在的样子不够,还得保存它经过哪些状态,以及这些状态之间的先后关系。这就是版本管理(version control)要解决的问题,Git 是这些年用得最广的一个工具。

顺着这个愿望往下推,会自然落到一个概念上:既然要回到过去,就得先有过去的样子可以回。于是每次告一段落,就给整个项目拍一张照片,照片上记着此刻每个文件的内容、放在哪个目录、谁拍的、什么时候拍的、一句说明,还记着上一张照片是哪一张。这张照片叫快照(snapshot)。

生活类比 平时按 Ctrl+S,像把一份材料塞进快递袋封好寄走,下一份材料来了,原来那份就被顶掉。快照像给整个寝室的摆放状况拍一张照片:桌上有什么、抽屉第二层放了什么、充电线绕在哪个格子,全部拍进去。以后不管过了多久,照着这张照片就能把寝室恢复成那天的样子。

这里有一个容易想歪的地方:为什么给整个项目拍,而不给单个文件各拍一张?因为源代码、接口文档和配置文件经常要一起变化。只把旧代码恢复回来,却留下了新的配置,可能拼出一个历史上从来没有正常运行过的版本。以项目为单位拍,恢复出来的状态才是一个真正存在过的状态。

二、一次提交里装着什么

每次拍下的这张照片,在 Git 里叫一次提交(commit)。一次提交记着五样东西:

  1. 这一版所有纳入管理的文件,各自是什么内容、放在哪个目录;
  2. 作者是谁;
  3. 什么时间提交的;
  4. 一句说明,讲清这一次想推进什么;
  5. 父提交,也就是「这张照片是从哪一张改出来的」。

第五项最容易被忽略,也最关键。它把一张张孤立的照片串成一条有来有往的历史线,让「上一版」这四个字有了明确的指向。想退回某一天,你知道该退到哪个编号;想知道某个决定是哪一步引入的,顺着父提交往回走就行。

删除一个已经提交过的文件,再提交一次,新快照里确实没有它了,可旧快照里它还在。这是快照式设计最实用的一个后果:删掉的东西不会凭空消失,只要它曾经被提交过,就还能从历史里捞回来。

接下来的问题是容量。一个项目提交一千次,难道要在硬盘上存一千份完整副本?Git 的答案是把存储分成三层:

没有变过的文件,在多个提交里指向同一个 blob,内容在硬盘上只存一份。所以「提交一千次」和「复制一千遍项目」是两件完全不同的事:逻辑上每一次提交都是一张完整快照,存储上相同的内容可以共享。这个设计让人敢于频繁提交,而频繁提交恰恰是版本管理最好用的前提。

flowchart TB
  C1["提交 1"] --> T1["目录树 1"]
  C2["提交 2"] --> T2["目录树 2"]
  C1 --> C2
  T1 --> B1["note.txt 的内容"]
  T2 --> B1
  T2 --> B2["新文件 的内容"]
关键区别 编辑器里「存过盘」和 Git 里「提交过」,保存的是两个层次的东西。存盘保住的是此刻这个文件的内容,下一次存盘会把它顶掉;提交保住的是整个项目在历史中的一个可以回去的状态,之前的状态不会因为新的提交而消失。

三、工作区、暂存区、提交历史

在终端里先确认一次版本:

PS> git --version
git version 2.51.0.windows.1

接着在练习目录里建仓库。所谓仓库(repository),就是那个多了一个隐藏目录 .git 的文件夹,全部历史都存在里面。删掉 .git,当前文件还在,历史全没了。

PS> cd C:\Users\你的用户名\gse-practice
PS> git init
Initialized empty Git repository in C:/Users/你的用户名/gse-practice/.git/

从这里开始,项目里的东西分住在三个地方:

flowchart LR
  W["工作区 正在编辑的文件"] -->|"git add"| S["暂存区 下一次提交采用的内容"]
  S -->|"git commit"| H["提交历史 一条条快照"]
  W -.->|"git diff 看未暂存的修改"| S
  S -.->|"git diff --cached 看已暂存的修改"| H

拿一个文件从头走一遍,输入和真实输出一起看:

PS> git status
On branch main

No commits yet

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        note.txt

nothing added to commit but untracked files present

PS> git add note.txt

PS> git commit -m "记下第一次练习的目标"
[main (root-commit) 7d1e4a2] 记下第一次练习的目标
 1 file changed, 2 insertions(+)

PS> git log --oneline
7d1e4a2 (HEAD -> main) 记下第一次练习的目标

Untracked 的意思是「文件在这儿,Git 还没开始管它」。git add 把它放进暂存区,git commit 把暂存区的内容拍成一张快照,git log 把这条历史列出来。行首那串 7d1e4a2 是提交的编号,之后想指名道姓地提某一次,就用它。

生活类比 食堂打饭时你手里端着一个餐盘。在窗口一勺一勺夹菜,菜还没进盘之前不算你这一单;夹进盘里的才作数,走到结算台一次算清。餐盘就是暂存区:它让你先挑,挑好了再一次性交上去。

暂存区这个中间环节有它的用处。它让你能把一堆混在一起的改动分成几次有意义的提交:改了三处代码,第一处修错误、第二处补测试、第三处顺手调了缩进,你可以只 add 前两个,让这次提交只讲一件事。历史干净,将来用 git bisect 找问题、用 git blame 查来历时才有意义。

四、add 之后再改一次,交上去的是哪一版

这是理解暂存区最有效的一个实验。先往文件里加一行,add 它,再加第二行,然后提交:

PS> echo "第二行:今天学会提交" >> note.txt
PS> git add note.txt
PS> echo "第三行:add 之后才补上的" >> note.txt
PS> git commit -m "补充两行"
[main 3b8f0c5] 补充两行
 1 file changed, 1 insertion(+)

注意输出里的 1 insertion:只加了一行,可你明明写了两次。答案很干脆,提交进去的是 add 那一刻的内容:第二行在里面,第三行不在。第三行仍然留在工作区,等着下一次 add。

用 git status 能同时看到这两半:

PS> git status
On branch main
Changes to be committed:
  (use "git restore --staged <file>..." to unstage)
        modified:   note.txt

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   note.txt

同一个文件名出现两次,含义完全不同。排在前面那一半是「暂存区里等着提交的内容」,排在后面那一半是「暂存之后又改过、还没进暂存区的内容」。想看具体差在哪几行,用两条不同的命令:

PS> git diff --cached
+第二行:今天学会提交

PS> git diff
+第三行:add 之后才补上的

这两条命令的分工值得记牢:git diff 比较工作区和暂存区,git diff --cached 比较暂存区和最近一次提交。搞不清自己在看哪两样东西的比较,就会觉得两条命令的输出莫名其妙。

关键结论 每敲一条命令之前,先在心里回答三个问题:工作区会变吗?下一次提交会包含什么?哪个引用会往前移动?回答完再敲,敲完用 git status 和 git diff 核对。能预测后果,这个概念才算长在自己身上;只会照着教程输入,换一个场景就抓瞎。

五、分支怎么分叉,冲突时文件里长什么样

分支名最朴素的理解是「指向某个提交的可移动书签」。默认那条叫 main,HEAD 表示你此刻站在哪条分支上。在这条分支上做一次新提交,书签就往前挪一格;别的分支仍然停在原处,或者沿它自己的路线前进。

PS> git switch -c fix-parser
Switched to a new branch 'fix-parser'

PS> git switch main
Switched to branch 'main'

假设你和室友从同一个版本出发:你改界面,他修解析器。两条线各自往前走,最后要合成一个版本:

flowchart LR
  A["提交 A 共同起点"] --> B["提交 B 你改界面"]
  A --> C["提交 C 室友修解析器"]
  B --> M["合并提交 M 有两个父提交"]
  C --> M

git merge 会拿共同的祖先做基准,把双方的变化都算进去,得到一个合并结果。双方都往前走过的情形,常见结果是造一个有两个父提交的新提交;如果一方只是另一方的后续,Git 也可能只把书签往前挪一下,这叫快进(fast-forward)。

当两边改了同一段文字,Git 没法替你裁决,就把两版都摆进文件里,让你自己决定:

<<<<<<< HEAD
窗口标题按你写的那一版改
=======
窗口标题按室友写的那一版改
>>>>>>> fix-parser

这几行里,紧接第一行的是你这一侧的内容,======= 之后是他那一侧的内容,最后一行指出对方来自哪条分支。解决冲突的动作只有三步:把整段编辑成最终该有的样子,把三行标记删干净,git add 之后再提交。此时 git status 报的状态是 both modified,看到它就说明这个文件正在等你裁决。

常见坑 冲突标记只会出现在文本文件里。二进制文件、或者一边删掉一边修改的情形,不给你这种三行提示,只在 git status 里报一个状态。所以处理冲突要同时看两样东西:打开文件看内容,看状态里怎么说。漏掉后者,容易把一半的处理当成全部完成。
生活类比 两个人共写一张小组作业的课表:一个人把周三的课挪到周四,另一个人把周三那格改成「自习」。合并时其他格子都没事,只有周三这一格出现两份主张。冲突标记就是在这格贴了两张便签,一张写「我改成周四」,一张写「我改成了自习」,等你决定这一格最终写什么。

六、rebase 与改写历史的边界

git rebase 做的事情是:把一串提交所代表的改动,依次重放到一个新的起点上。你基于旧的主线做了两次修改,主线已经往前走了几步,rebase 能让这两次修改像是刚刚基于新主线做出来的一样。

PS> git switch fix-parser
Switched to branch 'fix-parser'

PS> git rebase main
Successfully rebased and updated refs/heads/fix-parser.

重放不等于搬家。父提交一变,提交的内容就变了,Git 会造出新的提交对象,给出新的编号。两边的代码看起来差不多,标识完全不同。

这就是「改写历史」这四个字的由来。改写本身没什么问题,问题在于别人有没有已经站在这段历史上:你已经把这两次提交推给室友,室友基于它们写了代码,你单方面重放一遍,他那边记着的父提交指向的提交在他机器上还在,在你这里却变成了另一个编号,两个人翻同一段历史会看到不同的图。

注意 改写历史之前先问一句:这段历史有没有人已经依赖它。自己的工作分支、还没分享出去的提交,可以放心整理成更好读的形状;已经推到共享分支、别人可能已经拉取的历史,动手前先和对方说清楚。判断依据在协作关系里,不在命令本身。

把 rebase 和 merge 摆在一起看,它们的差别落在「历史长什么样」上:merge 保留分叉,用合并提交如实记下两条线在这里汇合;rebase 把分叉抹平,让历史看起来像一条直线。选哪一个取决于你希望后来的人读到什么,而后来的人读到的,正是他判断一件改动为何存在的依据。

七、快照装不下的两件事

1. 目录树装不下的关系

快照把文件的内容和位置记得很清楚,它记不住的是文件之间的关系。个人助手里的一条需求「记住用户纠正过的偏好」,可能对应一段规格、一个更新记忆的函数、三组测试。现在需求改成「有些偏好只在某段时间内有效」,你真正想知道的是:哪些设计假设受影响、哪些实现要改、哪些测试要加。

从需求走到设计、实现和验证,再从一段代码反查它服务的需求,这类关系叫可追踪性(traceability)。它关心的是能不能认出并持续维护制品之间的联系,并用这些联系回答项目里的问题。

把相关文件放进同一个目录,不会自动建立联系;把每个文件的每个版本都存下来,也不会自动说明某项设计为什么存在。今天我们用需求编号、文档链接、提交说明、测试名称补上其中一部分,这些关系需要有人或工具持续维护,否则代码已经改了,解释还留在原地。

2. 一项改动的身份

想一想执行 cherry-pick 时你心里想的是什么。你想搬走的是「某次提交引入的那项改动」。用 rebase 时也一样,你觉得自己在做同一件事,只是换了一个基础。可是传给工具的标识是提交,提交的编号与内容、父提交绑在一起,父提交一变,编号就变。

于是有了两个必须分开的问题:某个版本的准确状态是什么,以及一项改动在反复修改中经历了哪些版本。前者用提交编号回答,后者需要一个不受重写影响的标识。人心里很清楚「这还是在改那个功能」,工具却拿不出对应的名字,沟通成本就从这里长出来。

有一种设计把这两个问题分开回答:用稳定的变更编号(change ID)追踪一项持续演化的改动,同时保留提交编号标识具体版本。重写时提交编号会变,变更编号通常不变,改动的两个版本因此能被认出是同一个人推进的同一件事。

3. 冲突也可以是一种状态

在常见的流程里,冲突是一个卡住你的意外:合并或者重放走到一半停了,必须先把文件里的标记清干净才能继续,整个过程被切成两段,中间悬着一个不能提交的状态。

另一种做法把冲突当成可以保存的东西。重放到某一步发生冲突时,把冲突本身记录在新提交里,让重放先跑完,之后再从容处理这些冲突;带着冲突的提交还能继续被重写和合并。原本被当作异常的东西,变成了一个可以表示、可以保存、可以继续操作的对象。

这个变化影响的不只是手感。调整历史结构与决定冲突处最终写什么本来就是两件事,能分开安排,就可以先做机械的那一件,等人有空的时候再做需要判断的那一件。

关键结论 快照回答「当时是什么样」,历史回答「怎么走到这里」,追踪关系回答「谁依赖谁」,改动身份回答「这几次修改算不算同一件事」。四类信息里,Git 把前两类做得很扎实,后两类长期靠人用编号、链接和说明去补。
现在就做 在练习目录里做完这四件事,二十分钟足够:
① git init 建仓库,配好用户名邮箱,git add 一个文件后 git commit,用 git log --oneline 看到这条记录;
② 做一次「add 之后又改」的实验,提交,然后用 git diff 与 git diff --cached 各看一眼,说出提交里到底有哪几行;
③ git switch -c try-edit 建一条分支,改同一行文字后提交,回到 main 再改同一行,然后 git merge try-edit,把文件里的冲突标记原样抄进记事本;
④ 把文件改成最终该有的样子,删掉三行标记,git add 之后提交,再用 git log --oneline --graph 看一眼两条线怎么合到一起。
每一步先写下预测,再敲命令核对。

自测

1. 用「作业-v1、作业-v2」复制目录的办法存版本,第三个麻烦是什么?快照怎么解决它?
麻烦在于两个人各自在自己的副本里改,最后要合起来时无从下手:谁从哪份改来、哪一处该以谁为准,都只能靠人回忆。快照把「项目经过哪些状态以及它们的先后关系」保存下来,每次提交都记着父提交,于是分叉与汇合有了明确依据。两个人从同一次提交出发,各自提交,再合并,就能把两条线接回去。
2. 提交了一千次,硬盘上会有一千份完整副本吗?为什么?
不会。Git 分三层保存:blob 存一份文件内容,tree 把文件名与对象组织成目录结构,commit 关联根目录的 tree、父提交、作者与说明。没有变过的文件在多个提交里指向同一个 blob,内容只存一份。逻辑上每次提交都是完整快照,存储上相同内容可以共享,所以频繁提交不会把项目复制成很多份。
3. 先改文件、git add、再改一次、然后提交,提交里是哪一版?两条 diff 分别看什么?
提交的是 add 那一刻的内容,第二次修改仍留在工作区。用 git status 会同时列出「已暂存待提交」和「已修改未暂存」两半。git diff 比较工作区与暂存区,看到的是那一次没被暂存的修改;git diff --cached 比较暂存区与最近一次提交,看到的是即将进入下一次提交的内容。
4. 合并冲突时文件里那几行标记分别是什么意思?解决冲突要做哪三步?
<<<<<<< HEAD 之后是你这一侧的内容,======= 之后是对方的内容,>>>>>>> 后面写着对方来自哪条分支。三步是:把整段编辑成最终该有的样子,把三行标记删干净,git add 之后再提交。二进制文件或者一边删除一边修改的情况不出现这种文本标记,所以还要同时看 git status 报的状态。
5. rebase 之后提交编号为什么会变?什么时候不该改写历史?改动身份为什么要单独有一个标识?
提交的编号与它的内容和父提交绑在一起,rebase 把改动重放到新的起点,父提交变了,Git 就造出新的提交对象、给出新编号。别人已经依赖这段历史时不该改写:他拉取过并基于这些提交工作,你单方面重写,他记录的历史就对不上。单独的身份标识解决的是另一个问题:cherry-pick 与 rebase 时人心里认同「这还是在改同一件事」,提交编号一变就没法表达这种延续,稳定的变更编号让「某个版本是什么」与「这项改动经历了哪些版本」两个问题分开回答。