Skip to content

工作区、暂存区与按块暂存:提交里到底是什么 ​

修 bug 的时候顺手加了一行调试输出,提交前想只提交修复、不提交调试代码,于是用编辑器插件把修复那一块单独暂存。提交之后发现,提交里的修复是半小时前的版本,后来改进的那几行不在里面。另一次,想撤销调试代码,按错了键,撤掉的是还没提交的修复,怎么也找不回来。

Git 里一个文件同时有三个版本:HEAD(最后一次提交)、暂存区(index,下一次提交的内容)、工作区(磁盘上的文件)。git add 把工作区的内容复制到暂存区,git commit 把暂存区变成新的提交。按块暂存、编辑器里的 Git 插件、各种撤销命令,都是在这三个版本之间复制内容。弄清楚每个命令从哪里复制到哪里,就能知道提交里是什么、撤销会丢掉什么。

本文用 Git 2.54 在一个临时仓库里逐个验证。实验见文末。

工作区磁盘上的文件暂存区下一次提交的内容HEAD最后一次提交add -pcommitrestoreresetgit diff:工作区 vs 暂存区git diff --cached:暂存区 vs HEAD插件「暂存一块」= git apply --cached插件「撤销一块」= 覆盖工作区暂存后再修改:提交的是暂存那一刻的版本(v1),工作区的 v2 不在提交里reset --hard:已暂存的内容 add 时已写成对象,fsck --lost-found 能找回;从未暂存的找不回有风险的操作之前先 git stash
图 1 · 实测(Git 2.54.0):add 与插件的「暂存一块」把内容复制进暂存区,commit 只提交暂存区;restore 与插件的「撤销一块」用暂存区覆盖工作区,从未暂存的修改没有副本;reset --hard 同时覆盖两者,只有暂存过的内容还能从悬空对象找回。

一、按块暂存:一个文件里的两处修改分开提交 ​

一个文件里改了两处:第 3 行是修复,第 25 行是调试输出。

text
改了第 3 行与第 25 行:git diff 有 2 个块
git add -p 对两个块依次回答 y、n:暂存区与 HEAD 的差异 1 个块(line 3 fix bug),工作区与暂存区的差异 1 个块(line 25 debug print)
提交后:提交里改了 1 个文件,内容「line 3 fix bug」;工作区仍有「line 25 debug print」

git add -p 把差异按块(hunk)逐个展示,每一块回答是否暂存。暂存之后,两个差异命令各看一段:

  • git diff:工作区和暂存区的差异,也就是还没暂存的部分。
  • git diff --cached(--staged):暂存区和 HEAD 的差异,也就是将要提交的部分。

提交之前看一眼 git diff --cached,就知道提交里会有什么。只看 git diff 是不够的:它显示的恰恰是不会被提交的内容。

二、块是怎么划分的 ​

text
改第 10 行与第 12 行(中间只隔 1 行):git diff 有 1 个块;add -p 里输入 s 拆分后:Split into 2 hunks

diff 默认给每处修改带上前后 3 行上下文,两处修改的上下文重叠时就合成一个块。在 add -p 里输入 s 可以拆开(中间至少要有一行没改);挨在一起的修改拆不开,用 e 直接编辑补丁,删掉不想暂存的行。

三、暂存之后再修改:提交的是暂存时的版本 ​

text
暂存「line 5 v1」后又改成 v2 再提交:提交里是「line 5 v1」,工作区是「line 5 v2」

开头那个「提交了半小时前的版本」就是这样发生的:暂存区保存的是 git add 那一刻的内容,之后在工作区的修改不会自动跟进。暂存之后又改了同一处,要再暂存一次。git status 会把这种文件同时列在「要提交的变更」和「尚未暂存的变更」两栏里,这是一个值得停下来看一眼的信号。

四、编辑器插件做的是同一件事 ​

编辑器里的 Git 插件(例如 Vim 的 fugitive 与 gitgutter、各 IDE 的「暂存选中的行」)并没有另一套机制。它们通常的做法是:算出这一块的补丁,交给 git apply --cached,只修改暂存区:

text
只把第一个块的补丁交给 git apply --cached(编辑器 Git 插件按块暂存的做法):暂存区 1 个块,工作区 1 个块

所以插件的行为和命令行完全一致,包括第三节的陷阱。使用插件时要分清它操作的是哪一个版本:

操作改动的版本
暂存一块(stage hunk)暂存区
取消暂存(unstage)暂存区,恢复成 HEAD
撤销一块(undo / revert hunk)工作区,未暂存的修改直接丢失
保存缓冲区工作区

另一个细节:插件比较的通常是编辑器里的缓冲区和暂存区,缓冲区还没保存时,它显示的差异和 git diff 不同。暂存前先保存。

五、撤销会丢掉什么 ​

text
git restore -p 丢弃第一个块:第 7 行变回「line 7」,第 28 行仍是「line 28 drop」;这个修改从未暂存或提交,Git 里没有任何副本

git restore(以及旧的 git checkout -- 文件、插件里的「撤销这一块」)用暂存区的内容覆盖工作区。被覆盖的修改如果从来没有 add 过,Git 里就没有它的任何副本,只能靠编辑器的撤销历史。开头那个找不回来的修复就是这样丢的。

git reset --hard 同时覆盖暂存区和工作区:

text
暂存一处、未暂存一处后 git reset --hard:第 15 行「line 15」,第 16 行「line 16」
git fsck --lost-found 找回的悬空对象里:「line 15 staged fix」;已暂存的内容在 add 时写成了对象,从未暂存的修改找不回

git add 会把文件内容写成仓库里的一个对象,所以暂存过的内容即使被 reset --hard 清掉,也还能从悬空对象里翻出来(在 git gc 清理之前,默认保留两周)。从未暂存的修改则彻底消失。git reflog 能找回的是提交,不是工作区的修改。

做有风险的操作之前,先 git stash:

text
先 git stash 再 git reset --hard,stash pop 后第 20 行:「line 20 wip」

stash 把工作区和暂存区的修改保存成提交对象,之后的 reset --hard、切换分支都不会影响它。

六、检查清单 ​

  • 提交前看 git diff --cached,确认提交里的内容。
  • 暂存之后又改了同一个文件,再暂存一次;git status 里同时出现在两栏的文件要留意。
  • 使用编辑器插件时分清「暂存一块」和「撤销一块」:后者改的是工作区。
  • 暂存前保存缓冲区。
  • restore、reset --hard、切换分支之前,不确定就先 git stash。
  • 误删了已暂存的内容,用 git fsck --lost-found 尽快找回。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权