三区模型:工作区、暂存区与仓库
如果说 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 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
+line44. 暂存区到底有什么用
这是初学者最常提出的质疑。答案是:暂存区让"我改了什么"和"我要提交什么"解耦。
真实开发中,你很少会只干一件事再提交。修 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 | 退出 |
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" # 安静模式,少打印-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 →
- 制造一个
MM状态:创建并提交一个文件,改它 →git add→ 再改它 →git status -s。然后分别执行git diff和git diff --staged,说明这两份 diff 各自展示的是哪一次改动。 - 在同一次编辑里改动两个文件(一个模拟功能代码、一个模拟文档),用暂存区把它们拆成两个提交,最后用
git log --oneline验证。 - 新建一个未跟踪的文件,然后执行
git commit -am "test",用git status确认这个文件仍然是??。解释为什么。 git ls-files --stage输出里第二列是什么?把同一份内容写进两个不同文件名再git add,观察它们的哈希是否相同,并解释原因(第 13 章会给出完整答案)。