三棵树
Git 三棵树模型详解:工作区、暂存区、仓库的分工、状态流转与排障方法。
知识点地图
- 知识类别:Git 三棵树模型——工作区、暂存区、仓库三层结构的分工与状态流转。它是整个 Git 模块的「地形图」:add/commit/diff/restore/reset 的行为全部能落在三棵树上解释。
- 解决什么问题:「我明明 add 了怎么 diff 还有输出」「提交里怎么少了个文件」「想把改动撤回去但怕撤错」——这些日常困惑的根因都是分不清改动停在哪棵树里。
- 什么时候用到:每次提交前的体检(status + diff);拆分提交(add -p);撤销误操作(restore 家族);排查「提交内容与预期不符」。
前置知识与学习目标
前置知识:完成过至少一次 git init → add → commit 流程。
学完本文你应当能够:
- 说出工作区、暂存区、仓库三棵树各自的角色与物理位置;
- 看懂
git status -s的双列状态码; - 用 diff 矩阵准确定位「我的改动到底在哪棵树里」;
- 掌握暂存部分改动、撤销误暂存等日常操作。
一个贯穿全文的类比:工作区是草稿纸,暂存区是装订夹,仓库是归档柜。草稿随便涂改;觉得稳定的页面放进装订夹(git add);每攒好一册就归档一份完整快照(git commit)。装订夹的意义在于允许你把散碎改动整理成干净的一册再归档。
1. 三棵树模型
Git 在任何一个时刻都用三份「目录清单」管理工作状态:
| 树 | 物理位置 | 内容 | 进出命令 |
|---|---|---|---|
| 工作区 | 项目目录 | 你正在编辑的实际文件 | 编辑器、git restore |
| 暂存区 | .git/index | 下一次提交将包含的快照 | git add / git rm |
| 仓库 | .git/objects | 提交历史与全部对象 | git commit |
flowchart LR
W["工作区<br/>草稿纸"] -->|"git add"| S["暂存区<br/>装订夹"]
S -->|"git commit"| R["仓库<br/>归档柜"]
R -->|"git push"| REM["远程仓库"]
S -.->|"git restore --staged<br/>取消装订"| W
W -.->|"git restore<br/>丢弃草稿"| S一个必须先扭转的直觉:暂存区存的不是「改动清单」,而是完整的文件快照。git add 时 Git 把文件内容做成 blob 写入对象库,并在 index 里记录「这个路径现在指向哪个 blob」。所以暂存区随时可以整体拿出来当一棵树看(它就是下一次提交的 tree 的草稿)。
2. 工作区:文件的四种状态
stateDiagram-v2
[*] --> Untracked: 新建文件
Untracked --> Staged: git add
[*] --> Unmodified: 已提交的文件
Unmodified --> Modified: 编辑
Modified --> Staged: git add
Staged --> Unmodified: git commit
Staged --> Modified: 再次编辑git status 的完整输出会分区显示这三类:
git status
# On branch main
# Changes to be committed: ← 暂存区相对 HEAD 有差异(将进入提交)
# modified: index.js
# Changes not staged for commit: ← 工作区相对暂存区有差异
# modified: README.md
# Untracked files: ← 从未进入过快照的文件
# new-feature.js3. 暂存区:index 文件
3.1 直接观察 index
git ls-files -s
# 100644 a1b2c3d... 0 README.md ← mode blob哈希 stage号 路径
# 100644 e5f6a7b... 0 src/index.jsmode、blob 哈希、路径正是提交时 tree 条目的三个组成部分(见 对象模型),stage 号在合并冲突时区分「ours / theirs / 原始版」(非 0 即有冲突,见 合并冲突解决)。
3.2 进出暂存区
git add file.txt # 暂存单个文件
git add . # 暂存当前目录全部变更(含新文件)
git add -u # 只暂存已跟踪文件的修改与删除
git add -p # 交互式按代码块暂存
git restore --staged f.js # 取消暂存(Git 2.23+,推荐)
git rm --cached f.js # 停止跟踪但保留工作区文件3.3 部分暂存:把一次修改拆成两个提交
git add -p src/auth.js
# Stage this hunk [y,n,q,a,d,s,e,?]?
# y 暂存此块 n 跳过 q 退出
# s 把相邻改动拆成更小块 e 手动编辑块典型场景:同一文件里既有 bug 修复又有重构,用 -p 只暂存修复部分,保证单个提交只做一件事。
3.4 一个高频困惑:add 之后又改了文件
echo "v1" >> app.js && git add app.js
echo "v2" >> app.js # 暂存后继续编辑
git status -s
# MM app.js ← 第一列:暂存区有 v1;第二列:工作区又有 v2
git diff --cached app.js # 看到 v1
git diff app.js # 看到 v2暂存的是「那一刻的快照」,之后的编辑不会自动跟进——这正是双列状态码存在的原因。
4. 仓库:提交即归档
git commit -m "feat: add login" # 归档暂存区快照
git commit -a -m "fix: typo" # 跳过 add:先暂存已跟踪文件的改动再提交
git commit --amend --no-edit # 把当前暂存区并入上一次提交注意 commit -a 只对已跟踪文件生效,新文件仍需 git add。提交的对象层面发生什么(blob/tree/commit 的构造过程)见 对象模型。
5. 三棵树之间的差异:diff 矩阵
| 命令 | 比较的两棵树 | 回答的问题 |
|---|---|---|
git diff | 工作区 vs 暂存区 | 「还没装订的草稿」 |
git diff --staged | 暂存区 vs HEAD | 「即将归档的内容」 |
git diff HEAD | 工作区 vs HEAD | 「所有未提交的改动」 |
git diff --name-only | (同默认,只列名) | 快速列出改动文件 |
--staged 与 --cached 是同义词。记忆法:diff 不带参数看「装订夹外的」,带 --staged 看「装订夹里的」。
6. 完整会话:一次规范的工作循环
# 1) 修改文件后先体检
git status # 我改了什么?
git diff # 具体改了哪几行?
# 2) 分块暂存并复核
git add -p
git diff --staged # 装订夹里到底是什么?
# 3) 提交与推送
git commit -m "fix: validate email format in login form"
git push
# 4) 常见修正
git restore --staged wrong.js # 暂存错了文件
git restore typo.js # 丢弃工作区里的误改
git add missed.js
git commit --amend --no-edit # 提交完发现漏文件(未推送时)7. 陷阱与调试
- 「我明明 add 了,为什么 diff 还有输出」:add 之后又编辑了文件,见 3.4 节;暂存快照不会跟随后续编辑。
git add .把日志、构建产物都暂存了:先补.gitignore(见 .gitignore 深入),已误暂存用git restore --staged退出。- 想丢弃工作区改动却用了
git restore --staged:前者只动装订夹、后者只动草稿;真正丢弃工作区修改的是不带--staged的git restore,执行前确认清楚。 commit -a之外新增的文件没进提交:-a不含 untracked,新文件必须显式 add。git diff没变化但提交内容不对:你看到的可能是暂存区旧快照,用git diff --staged HEAD对照确认。
小结
初学者要点
- 三棵树 = 工作区(草稿)/ 暂存区(装订夹)/ 仓库(归档柜);
add装订、commit归档。 git status的分区与-s双列码是定位改动位置最快的体检工具。- diff 命令按「比较哪两棵树」记忆,不要死记参数。
进阶注意
- 暂存区是完整快照(
.git/index二进制文件),ls-files -s可直接解剖,冲突时 stage 号为 1/2/3。 commit -a只覆盖已跟踪文件;--amend只应在未推送提交上使用。- 现代命令分工:
restore(工作区/暂存区修复)已接管checkout --与reset <path>的日常场景,reset 保留给「移动分支指针」的本职(见 git reset)。
动手实践
练习 1(三棵树定位):在一个测试仓库制造「同一文件处于三种状态」的局面:文件 A 已 add 但又改(MM)、文件 B 只改未 add( M)、文件 C 全新未跟踪(??)。只靠 git status -s 与三个 diff 命令,分别说出每个文件的改动分布在哪几棵树。
提示:git diff、git diff --staged、git diff HEAD 各回答一个方向的问题;双列状态码的第一列看暂存区与 HEAD 的关系。
练习 2(拆分提交):往一个文件里写两类改动(一行功能、一行日志输出),用 git add -p 只暂存功能行提交,再用 git diff 确认日志行还留在工作区。
提示:交互提示里 y 收块、n 跳过;块太大先用 s 拆细。
练习 3(救援演练):依次执行「误 add 了日志文件」「误改了配置文件」,分别用 restore --staged 与 restore 撤销,提交前用 git diff --staged 复核最终暂存区干净。
参考实现(先自己动手,再看这里)
# 练习 1
git init trees-lab && cd trees-lab
echo base > a.txt && echo base > b.txt
git add . && git commit -m "init"
echo feature >> a.txt && git add a.txt && echo tweak >> a.txt # A: MM
echo bugfix >> b.txt # B: M
echo new > c.txt # C: ??
git status -s # MM a.txt / M b.txt / ?? c.txt
git diff # B 的未暂存改动 + A 的第二轮 tweak
git diff --staged # A 的第一轮 feature
git diff HEAD # A、B 两文件的全部改动(C 不在,untracked 不进 diff)
# 练习 2
echo "feat: add filter" > work.txt
echo "console.log('debug')" >> work.txt
git add -p work.txt # s 拆块后 y 只收功能行
git diff --staged # 只见功能行
git diff # 日志行仍在外
# 练习 3
touch debug.log && git add debug.log
git restore --staged debug.log && git status -s # debug.log 回到 ??
echo "bad" > config.ini
git restore config.ini # 工作区误改被丢弃
git diff --staged # 确认暂存区干净后再提交