Learn
Git/02-three-areas

三区模型:工作区、暂存区与仓库

如果说 Git 有一个必须先建立、之后一切都会顺理成章的心智模型,那就是三区模型。几乎所有"Git 怎么又出问题了"的困惑,追根溯源都是没搞清楚"我这个改动现在在哪个区"。

1. 三个区

  工作区              暂存区              仓库
 (Working Dir)      (Staging/Index)     (Repository)
      |                   |                  |
      |  ---- git add --->|                  |
      |                   |--- git commit -->|
      |<--------- git checkout / restore ----|
      |                   |                  |
  你能看见和              .git/index        .git/objects
  编辑的文件            (一份文件清单)    (所有历史快照)
区物理位置里面是什么谁在改它
工作区项目目录(除 .git 外)你能用编辑器打开的文件你、编辑器、构建工具
暂存区.git/index 二进制文件一份"下次提交要包含什么"的文件清单git add / git restore --staged
仓库.git/objects所有已提交的历史快照,不可变git commit

暂存区(staging area,官方文档里也叫 index)是 Git 相比大多数 VCS 多出来的一层。很多人第一反应是"多此一举,为什么不能直接提交",第 4 节会说明它的价值。

2. 文件的状态流转

对 Git 来说,工作区里的每个文件只有两种大类:已跟踪(tracked) 和 未跟踪(untracked)。已跟踪的文件又分三种状态:

   未跟踪            已跟踪
 (untracked)
     |
     |  git add
     v
  +-------------+   编辑文件   +-------------+   git add   +-------------+
  |  已暂存      | ----------> |   已修改     | ----------> |   已暂存     |
  | (staged)    |             | (modified)  |             | (staged)    |
  +-------------+             +-------------+             +-------------+
     |                                                          |
     | git commit                                               | git commit
     v                                                          v
  +-------------+                                          +-------------+
  |  未修改      |  <---------------------------------------|  未修改     |
  | (unmodified)|                                          | (unmodified)|
  +-------------+                                          +-------------+

用 git status 观察这套流转:

三区流转全过程
git init -q
echo 'v1' > a.txt
git add .
git commit -q -m "init: a.txt"
 
echo '===== 起点:干净的工作区 ====='
git status --short --branch
echo '(除了分支行没有别的输出 = 三区完全一致)'
echo
 
echo '===== 1. 修改文件 -> 改动只在工作区 ====='
echo 'v2' > a.txt
echo 'new' > b.txt
git status --short
echo '   M = 已修改未暂存    ?? = 未跟踪'
echo
 
echo '===== 2. git add -> 改动进入暂存区 ====='
git add a.txt b.txt
git status --short
echo '  M  = 已暂存的修改    A  = 新增已暂存'
echo
 
echo '===== 3. 再改一次 a.txt -> 同一文件同时处于两种状态 ====='
echo 'v3' > a.txt
git status --short
echo '  MM = 暂存区里是 v2,工作区里还有更新的 v3'
echo
 
echo '===== 4. commit -> 落入仓库 ====='
git add a.txt
git commit -q -m "feat: 更新 a,新增 b"
git status --short
echo '(没有输出,又干净了)'

git status --short 的输出是两列字符:第一列表示暂存区相对于 HEAD 的状态,第二列表示工作区相对于暂存区的状态。

符号含义
??未跟踪
A 新文件已暂存
M 修改已暂存(工作区与暂存区一致)
M工作区已修改,未暂存
MM暂存了一部分改动后又改了(两个版本并存)
D / D删除已暂存 / 工作区已删除未暂存
R 重命名已暂存

理解 MM 是关键:这说明暂存区确实是独立的一份内容,而不是一个"要提交的文件名列表"。git add 是把文件当前内容的快照存进去,之后再改文件,暂存区不会自动跟着变。

3. 用 diff 看清每一层

三区之间有两道缝隙,git diff 的三种用法正好对应:

 工作区  <--(1) git diff -->  暂存区  <--(2) git diff --staged -->  HEAD
   |                                                                 |
   +---------------- (3) git diff HEAD ------------------------------+
git diff 的三种视角
git init -q
echo 'line1' > code.txt
echo 'line2' >> code.txt
echo 'line3' >> code.txt
git add .
git commit -q -m "init"
 
# 改一版并暂存
echo 'line1' > code.txt
echo 'line2-changed' >> code.txt
echo 'line3' >> code.txt
echo 'line4' >> code.txt
git add code.txt
 
# 再改一版,这次不暂存
echo 'line5-not-staged' >> code.txt
 
echo '===== git diff:工作区 vs 暂存区(还没 add 的改动)====='
git diff
echo
 
echo '===== git diff --staged:暂存区 vs 最新提交(这次 commit 会包含什么)====='
git diff --staged
echo
 
echo '===== git diff HEAD:工作区 vs 最新提交(全部改动)====='
git diff HEAD
echo
 
echo '===== git diff HEAD --stat:只看统计 ====='
git diff HEAD --stat
命令比较回答的问题
git diff工作区 ↔ 暂存区我还有哪些改动没 add?
git diff --staged(= --cached)暂存区 ↔ HEAD我这次 commit 会提交什么?
git diff HEAD工作区 ↔ HEAD相比上次提交,一共改了什么?
git diff --stat加在任一种后面只看文件与增删行数统计
💡提交前的肌肉记忆

养成习惯:git add 之后、git commit 之前,先跑一次 git diff --staged 通读一遍。这一步能拦下绝大多数"手滑提交了 console.log / 密钥 / 调试代码"的事故,成本只有几秒钟。

读 diff 输出的几个要点:

diff --git a/code.txt b/code.txt     <- a = 旧版本, b = 新版本
index 83db48f..a00cdb9 100644        <- 两个 blob 的哈希 + 文件权限
--- a/code.txt                       <- 旧版本标记
+++ b/code.txt                       <- 新版本标记
@@ -1,3 +1,4 @@                      <- 变更块:旧版从第1行起3行, 新版从第1行起4行
 line1                               <- 空格开头 = 上下文,未变
-line2                               <- 减号 = 旧版有、新版删掉
+line2-changed                       <- 加号 = 新增
 line3
+line4

4. 暂存区到底有什么用

这是初学者最常提出的质疑。答案是:暂存区让"我改了什么"和"我要提交什么"解耦。

真实开发中,你很少会只干一件事再提交。修 bug 的路上顺手改了个变量名,调试时加了段日志,还顺便更新了 README。如果只能全量提交,历史里就会出现一堆 "update stuff" 这样毫无信息量的提交。有了暂存区,你可以把一次混乱的编辑拆成若干个语义清晰的提交:

用暂存区把一堆改动拆成干净的提交
git init -q
echo 'base' > README.md
git add .
git commit -q -m "init"
 
echo '# 一次编辑干了两件事:修 bug + 写文档'
echo 'fixed bug' > bugfix.py
echo 'base + docs' > README.md
git status --short
echo
 
echo '===== 拆成两个提交:先只暂存 bugfix.py ====='
git add bugfix.py
git status --short
echo '(README.md 的改动留在工作区,不会进这次提交)'
git commit -q -m "fix: 修复登录空指针"
echo
 
echo '===== 再暂存文档 ====='
git add README.md
git commit -q -m "docs: 补充说明"
echo
 
echo '===== 结果:两个语义清晰、可独立回退的提交 ====='
git log --oneline
echo
 
echo '===== 索引里存的其实是「模式 + 内容哈希 + 路径」====='
git ls-files --stage

最后一条 git ls-files --stage 揭开了暂存区的真面目:它是一张表,每行记录 文件模式(100644 普通文件 / 100755 可执行)+ 内容对象的 SHA-1 + 路径。注意它记的是内容哈希,不是文件路径的引用——这就是为什么 add 之后再改文件,暂存区岿然不动。

更极致的用法是部分暂存:同一个文件里只提交某几行改动。

git add -p file.py     # 逐个 hunk 询问 y/n/s/e

交互中的常用回答:

键作用
y暂存这个 hunk
n跳过
s把当前 hunk 拆得更小
e手动编辑要暂存的行
q退出
ℹ️为什么本课不用 Playground 演示 git add -p

git add -p 需要交互式输入,而沙箱是一次性执行整段脚本、没有终端交互的。你可以在自己的机器上试:随便改一个文件的两个不相邻位置,然后 git add -p,用 y/n 分别处理两个 hunk,再 git status 就能看到 MM。

5. add 与 commit 的常用变体

# add
git add file.txt          # 单个文件
git add src/              # 整个目录
git add .                 # 当前目录及子目录的所有改动(含新增、删除)
git add -A                # 整个仓库的所有改动(不受当前目录限制)
git add -u                # 只暂存"已跟踪文件"的修改与删除,不加新文件
git add -p                # 交互式部分暂存
 
# commit
git commit -m "msg"       # 带信息提交
git commit                # 打开编辑器写多行信息
git commit -a -m "msg"    # 跳过 add:自动暂存所有【已跟踪】文件的改动
git commit --amend        # 修补上一个提交(改信息或补文件)
git commit -q -m "msg"    # 安静模式,少打印
⚠️git commit -a 的陷阱

-a 只处理已跟踪的文件。新建的文件依然是 untracked,-a 不会带上它。这导致一个经典事故:新写了一个模块文件,一路 git commit -am "feat: 新功能",推上去后同事拉下来编译失败——因为那个新文件从来没进过仓库。提交前扫一眼 git status 有没有 ??。

6. 一张状态速查表

你想做什么命令
看当前有哪些改动git status / git status -s
看还没 add 的改动内容git diff
看这次要提交的内容git diff --staged
把改动放进暂存区git add <file>
把文件移出暂存区(保留改动)git restore --staged <file>
丢弃工作区改动(危险)git restore <file>
提交git commit -m "msg"
看索引里有哪些文件git ls-files --stage

后两个撤销类命令下一章还会详细展开,这里先混个眼熟。

小结

  • Git 有三个区:工作区(你编辑的文件)、暂存区(.git/index,一份内容清单)、仓库(.git/objects,不可变历史)
  • git add 把工作区内容快照进暂存区;add 之后再改文件,暂存区不变,这就是 MM 状态的来源
  • git status -s 的两列分别代表"暂存区 vs HEAD"和"工作区 vs 暂存区"
  • git diff(未暂存)、git diff --staged(将提交的)、git diff HEAD(全部)对应三区之间的不同缝隙
  • 暂存区的价值是把"我改了什么"和"我要提交什么"解耦,让混乱的编辑能拆成干净的提交
  • git commit -a 不包含未跟踪的新文件
  • 下一章:读懂提交历史,log / show / blame →
🎯练习
  1. 制造一个 MM 状态:创建并提交一个文件,改它 → git add → 再改它 → git status -s。然后分别执行 git diff 和 git diff --staged,说明这两份 diff 各自展示的是哪一次改动。
  2. 在同一次编辑里改动两个文件(一个模拟功能代码、一个模拟文档),用暂存区把它们拆成两个提交,最后用 git log --oneline 验证。
  3. 新建一个未跟踪的文件,然后执行 git commit -am "test",用 git status 确认这个文件仍然是 ??。解释为什么。
  4. git ls-files --stage 输出里第二列是什么?把同一份内容写进两个不同文件名再 git add,观察它们的哈希是否相同,并解释原因(第 13 章会给出完整答案)。