版本管理:给项目拍快照
每个写代码的人最早学会的一句祷告是「能不能退回去」。版本管理把这句祷告做成了一套工具。
- 从「我想撤销一步」这个愿望,一步步推出「快照」这个概念;
- 一次提交里装着什么,Git 又为什么不必把每个文件复制一千遍;
- 工作区、暂存区、提交历史各管什么,
add之后又改一次,交上去的到底是哪一版; - 分支怎么分叉、合并冲突时文件里长什么样、改写历史之前先想清楚什么;
- 快照装不下的两件事:制品之间的追踪关系,与一项改动在反复修改中的身份。
一、从「我想退回去」这句话开始
昨天的程序还能正常运行,今天一早打开,一启动就报错。你改了一上午,试了几十遍,已经想不起是哪一行把东西弄坏的。这时候冒出来的念头非常朴素:能不能退回去。
最直接的办法是复制整个目录,把副本改名叫「作业-v1」「作业-v2」「作业-最终版」。两个版本的时候还行,到第五个版本就会撞上三件麻烦事。
- 哪一份副本对应哪一次改动,只能靠记性,而记性最不可靠;
- 删掉了一个文件,想找回它当初长什么样,只能翻旧副本碰运气;
- 你和室友同时改同一份代码,各自在自己的目录里改,最后要合起来时无从下手。
前两件麻烦靠「多存几份」勉强能扛,第三件扛不住。它逼着我们换一个问法:光保存项目现在的样子不够,还得保存它经过哪些状态,以及这些状态之间的先后关系。这就是版本管理(version control)要解决的问题,Git 是这些年用得最广的一个工具。
顺着这个愿望往下推,会自然落到一个概念上:既然要回到过去,就得先有过去的样子可以回。于是每次告一段落,就给整个项目拍一张照片,照片上记着此刻每个文件的内容、放在哪个目录、谁拍的、什么时候拍的、一句说明,还记着上一张照片是哪一张。这张照片叫快照(snapshot)。
这里有一个容易想歪的地方:为什么给整个项目拍,而不给单个文件各拍一张?因为源代码、接口文档和配置文件经常要一起变化。只把旧代码恢复回来,却留下了新的配置,可能拼出一个历史上从来没有正常运行过的版本。以项目为单位拍,恢复出来的状态才是一个真正存在过的状态。
二、一次提交里装着什么
每次拍下的这张照片,在 Git 里叫一次提交(commit)。一次提交记着五样东西:
- 这一版所有纳入管理的文件,各自是什么内容、放在哪个目录;
- 作者是谁;
- 什么时间提交的;
- 一句说明,讲清这一次想推进什么;
- 父提交,也就是「这张照片是从哪一张改出来的」。
第五项最容易被忽略,也最关键。它把一张张孤立的照片串成一条有来有往的历史线,让「上一版」这四个字有了明确的指向。想退回某一天,你知道该退到哪个编号;想知道某个决定是哪一步引入的,顺着父提交往回走就行。
删除一个已经提交过的文件,再提交一次,新快照里确实没有它了,可旧快照里它还在。这是快照式设计最实用的一个后果:删掉的东西不会凭空消失,只要它曾经被提交过,就还能从历史里捞回来。
接下来的问题是容量。一个项目提交一千次,难道要在硬盘上存一千份完整副本?Git 的答案是把存储分成三层:
- blob 保存一份文件内容,文件名不参与,只按内容算出一个标识;
- tree 把文件名和对象组织成目录结构,相当于一层的目录清单;
- commit 关联根目录的 tree、父提交、作者和说明。
没有变过的文件,在多个提交里指向同一个 blob,内容在硬盘上只存一份。所以「提交一千次」和「复制一千遍项目」是两件完全不同的事:逻辑上每一次提交都是一张完整快照,存储上相同的内容可以共享。这个设计让人敢于频繁提交,而频繁提交恰恰是版本管理最好用的前提。
flowchart TB C1["提交 1"] --> T1["目录树 1"] C2["提交 2"] --> T2["目录树 2"] C1 --> C2 T1 --> B1["note.txt 的内容"] T2 --> B1 T2 --> B2["新文件 的内容"]
三、工作区、暂存区、提交历史
在终端里先确认一次版本:
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/
从这里开始,项目里的东西分住在三个地方:
- 工作区:你正在编辑的那些文件,用编辑器打开看到的东西;
- 暂存区(Staging Area,也叫 Index):下一次提交准备采用的内容,一份待装袋的清单;
- 提交历史:已经拍好、收进相册的那些快照。
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 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. 提交了一千次,硬盘上会有一千份完整副本吗?为什么?
3. 先改文件、git add、再改一次、然后提交,提交里是哪一版?两条 diff 分别看什么?
add 那一刻的内容,第二次修改仍留在工作区。用 git status 会同时列出「已暂存待提交」和「已修改未暂存」两半。git diff 比较工作区与暂存区,看到的是那一次没被暂存的修改;git diff --cached 比较暂存区与最近一次提交,看到的是即将进入下一次提交的内容。4. 合并冲突时文件里那几行标记分别是什么意思?解决冲突要做哪三步?
<<<<<<< HEAD 之后是你这一侧的内容,======= 之后是对方的内容,>>>>>>> 后面写着对方来自哪条分支。三步是:把整段编辑成最终该有的样子,把三行标记删干净,git add 之后再提交。二进制文件或者一边删除一边修改的情况不出现这种文本标记,所以还要同时看 git status 报的状态。