如果你已经会用 git addgit commitgit push,多半也遇到过两种让人发懵的情况:明明改了三处,提交完发现只有两处进了历史;或者 add 之后又顺手改了一行,结果进到提交里的还是改之前的版本。这些都不是 Git 出了错,而是它在按“三个区域”的规则办事。搞清这三个区域,addcommit 各自在做什么就都有了解释,后面分支、合并、处理冲突也全都建立在它上面。

你的每次操作,落在三个不同的地方

写代码的时候,你面前是磁盘上真实存在的那些文件,能打开、能运行。这就是 工作区(working tree)。工作区不是 Git 专有的东西,编辑器和终端看到的都是它。

git initgit clone 之后,项目目录里会多出一个 .git 目录。你所有的提交历史、每个版本的文件内容都打包放在这里,这就是 本地仓库(Git 目录)。从别人那里克隆一个项目时,被完整复制过去的就是它。

工作区和本地仓库之间还有一层:暂存区(staging area)。它对应 .git 目录里一个叫 index 的文件,你在文件管理器里看不到它,但它记录着一份很具体的东西——下一次提交时,每个文件分别取哪个版本。它的技术名字叫 index(索引),日常说法叫暂存区;两套名字并存,是因为前者来自 Git 很早期的实现,后来大家口中的“暂存”逐渐成了更通行的叫法 1

Git 中文件的状态流转:未跟踪、已修改、已暂存、未修改四个状态,以及 add、commit、编辑等操作如何在它们之间切换

三个区域之间,文件的流动是这样的:

绘制中

工作区里没 add 过的文件和改动,和暂存区、仓库都没有关系。Git 把“已经进入过一次提交,或者已经进过暂存区”的文件叫 已跟踪,其余的叫 未跟踪。这也是为什么新写的文件不会被自动纳入管理:你不明确说一声,Git 不会自己动手。

git add:把“此刻的内容”登记进暂存区

很多人对 add 的理解是“把这个文件加到项目里”。更准确的说法是:git add <file> 把这个文件 当前这一份内容 登记到暂存区,意思是“下一次提交请包含这个版本”。对一个全新的未跟踪文件来说,第一次 add 让它变成已跟踪;对一个已跟踪文件来说,add 的作用是把它最新的改动更新进那份清单。

“登记的是内容”这一点,用一个小实验就能看清楚:

1echo "hello" > readme.md
2git status            # readme.md 显示为 ??(未跟踪)
3git add readme.md     # 它现在进入了暂存区
4echo "second line" >> readme.md
5git status

最后一步的 git status 会告诉你一件初看很矛盾的事:readme.md 同时出现在“将要提交的更改”和“尚未暂存的更改”两个列表里。原因很简单——你 add 的时候文件只有一行 hello,暂存区登记的就是那一行;后来加的第二行只写在磁盘上,还没有登记过。这时候 git commit,进到历史里的是只有 hello 的版本,第二行会留在工作区,等着你下一次提交 2

所以 add 记下的是你敲命令那一刻文件的样子。如果你在 add 之后又改了同一个文件,就必须再 add 一次,新改动才会覆盖暂存区里那份旧的登记。

用简短模式看状态,两列的含义会清楚得多:

1$ git status -s
2A   readme.md      # 左列有 A:暂存区里这是一个新文件
3AM  notes.md       # A 说明它已进暂存区,M 说明工作区里又改了
4 M  app.js         # 只有右列有 M:改了但没 add

左列说的是暂存区相对于上一次提交多了什么,右列说的是工作区相对于暂存区多了什么。一个文件左右两边都有字母,就说明它在暂存区和工作区里是两份不同的内容。

git commit:把暂存区变成一次提交

git commit 做的事情只有一件:把暂存区当前记录的那份清单,写成一个新的提交存进本地仓库。它不读工作区,也不关心工作区里还剩多少没 add 的东西。

这解释了两件事。第一,提交之后 git status 往往还有内容,因为没 add 的改动从头到尾都待在磁盘上,和这次提交无关。第二,想确认自己到底要提交什么,别用 git diff,要用 git diff --staged

  • git diff 比较的是 工作区 vs 暂存区,也就是“我改了但还没登记的”;
  • git diff --staged 比较的是 暂存区 vs 上一次提交,也就是“这次提交真正会带上的”。

提交前跑一次 git diff --staged,就是把暂存区用好最直接的方式:你还有机会在改动进入历史之前,发现里面混了不该进来的东西。反过来,如果 add 错了,git restore --staged <file> 会把该文件从暂存区撤出,工作区里的内容不会有任何变化。

为什么非要留这一层

既然最终都要进仓库,为什么不让 commit 直接对着工作区干活?因为工作区随时处在半成品状态。

一个典型场景:你在改一个 bug,顺手把日志格式也调了,还留了一行只在本地调试用的打印。这三样东西你都想留在磁盘上,但把它们混进同一个提交,历史就变浑了。有了暂存区,你可以只 add 与 bug 相关的那个文件,提交;再 add 格式调整,提交;调试打印一直不 add,就永远不会进历史。还能更细:只登记同一个文件里的部分改动,让不同的改动分别进入不同的提交。暂存区把“我下一步要提交什么”变成了一个具体的、可以逐条增删的集合,而不是由工作区当前的样子替你决定。

这带来两个后面会反复用到的能力:提交前能检查(git diff --staged),以及能把一次连续的工作拆成边界清楚的若干提交。后一件事在多人协作里格外重要——评审人看到的就是这些提交,拆得干净,别人才看得懂你每一步在做什么。

还有一层更底层的原因:暂存区里存的是 内容,而不只是文件名。正是这个设计,让 Git 在合并两个分支、遇到内容冲突时能记下“这个文件还没定下来”,也让你在解决冲突后用 add 表示“这块我认可了”。这部分等讲到合并和冲突时会重新出现 3

两个常见的误会

git commit -a 并没有绕过这套规则。 它只是替你省了一次手:提交前自动把 所有已跟踪文件 的最新改动 add 一遍。新写的、还没跟踪过的文件不在其中。它方便,但不等于“把工作区全部提交”。

git add . 也不是“更省事的 commit”。 它的意思是“把当前目录下该登记的内容都登记进来”,范围更大,反而更容易把不想要的东西带进清单。更稳的做法是精确 add 你要的那几个文件,提交前再用 git diff --staged 看一眼。

到这里,三区域模型就闭合了:工作区是你在改的地方,暂存区是下一次提交的草稿,本地仓库是已经落定的历史。add 在工作区和暂存区之间搬运,commit 在暂存区和本地仓库之间搬运。这条主线在接下来讲分支、合并和远程协作时会一次次重新出现。