Git 状态详解:从工作区到暂存区,一文理清修改去向
那天我盯着终端里的git status屏幕上是干净得不能再干净的一行nothing to commit, working tree clean可我明明记得刚才改了三个文件。我甚至清楚地记得自己敲过git add也看到过绿色的new file:和modified:。然后我做了什么呢好像是一条git checkout .又或者是git reset --hard等我反应过来的时候改动已经没了。那一刻的恐慌用过 Git 的人都懂——你的修改去哪了后来我才慢慢想明白不是修改凭空消失了而是我根本不知道修改到底散落在 Git 的哪一层。working tree、index、HEAD、staged、unstaged、untracked这些词我全都认识但它们在什么条件下切换、切换之后文件去哪了我从来没系统地理清过。这篇内容就是来把这个事彻底讲透的。我会从一个文件的完整旅程出发——从你在编辑器里敲出第一行字到它成为远程仓库里一个雷打不动的提交——把 Git 的状态体系拆成一张能直接记住的地图。你如果是刚接触 Git 的人这篇能帮你少走很多弯路如果你用了好几年 Git 但偶尔还是会被status的输出搞得心里没底这篇也一样适合你。1. 那次「我的修改去哪了」恐慌问题出在对状态层级的无知在进入技术细节之前我想先聊聊为什么git status明明已经把答案写在了屏幕上我们还是会对改动的去向产生恐惧。因为git status给出的只是当前快照它不会主动告诉你某个文件上一秒的状态是什么、你刚敲的那条命令让它跳到了哪一层、以及它现在还能不能被找回来。1.1 一个再典型不过的翻车现场假设你正在写一个功能改了src/index.js然后执行了下面的操作序列git add src/index.js git commit -m refactor: update index page git undo # 抱歉没有这个命令你敲的是 git reset --hard HEAD~1 git status此时你看到工作区一片干净但代码已经回到上一次提交的状态。你刚提交的src/index.js的新版本已经从工作区、暂存区、以及分支历史上全部消失了。如果你没有把那次提交的哈希记下来也没用git reflog查过那它对你来说就跟从来没有存在过一样。这种翻车不是因为你不够细心而是因为 Git 的模型天然就是多份副本在工作你正在编辑的是一份暂存区里的是另一份仓库里提交的又是第三份。每一条命令到底操作了哪一份、改动了哪一份、丢弃了哪一份你需要一个清晰的心理模型否则就是靠猜。1.2 为什么「状态」这个词容易把人绕晕我见过不少人把git status里的Changes to be committed和Changes not staged for commit混为一谈觉得它们「反正都是修改」。但它们的位置完全不同一个已经进了暂存区index一个还只存在于工作区。而git commit只认暂存区里的内容。这就是最常见的状态误区——你改了文件以为提交了实际上提交的是暂存区里那份旧版本。同样容易懵的是untracked未跟踪状态。它非常特殊因为它既不在工作区对比的差异体系里也不在暂存区里。Git 的常规 diff 机制完全忽略它只有当你明确git add之后它才进入 Git 的管辖范围。很多「我的文件去哪了」的问题源头就是用户对一个 untracked 文件执行了git checkout .或git clean然后发现它没了——这不奇怪因为 Git 从没承诺过替你保存 untracked 文件。所以真正要建立的是一个三层模型工作区Working Tree→ 暂存区Staging Area / Index→ 本地仓库Repository。再加上远程仓库作为第四层。理解了每一层之间是什么、命令如何驱动内容在这些层之间移动git status输出的每一行对你来说就不再是密语。2. 从「草稿」到「存档」先画清 Git 的三层状态地图把 Git 的状态想明白最偷懒的办法是找一个现实世界里的类比。我写文章的时候桌面上的物理状态大概是这样的A4 草稿纸随手散在桌上上面全是涂改桌面角落有个「待录入」的托盘放的是我确定要整理进文档的稿子而书架上的文件夹才是真正对外有效的版本。Git 的三层模型和这个几乎一模一样。2.1 工作区是草稿纸暂存区是待发货架仓库才是档案柜工作区Working Tree你打开编辑器正在操作的文件就属于工作区。它可能处于两种状态被 Git 跟踪过tracked或者从未被跟踪untracked。工作区里的文件随便改改坏了顶多影响你本地不会污染任何历史。暂存区Index / Staging Area这是 Git 独有的一个设计也是初学者最不习惯的东西。它本质上是一个「预告清单」告诉 Git 下一次commit时要把哪些文件、哪些改动装进去。你执行git add就是把工作区的某份修改「摆上货架」准备随下一班车发出。本地仓库Repository / .git当你执行git commit暂存区里的内容被固化成一次提交写入.git对象库。这时候文件才算真正「存档」了后续你改乱了工作区也能从仓库里找回它。远程仓库Remotegit push把本地仓库的提交上传到远端。这一步和前一层不同它不代表「你提交了」只代表「你同步了」。用草稿、货架、档案柜这个类比串起来文件的命运就很清晰了在桌上改的是草稿端到货架上的是待定稿放进档案柜的才是正式存档。而对git status的恐惧大多来自你分不清手里的文件到底是处于哪个物理位置。2.2 同一个文件可能同时出现在多个状态里很多人在git status里看到下面这段输出时会以为报错了Changes to be committed: modified: index.html Changes not staged for commit: modified: index.html同一个文件index.html既在「待提交」列表里又在「未暂存」列表里。这不是 Git 抽风而是两个不同副本你git add之后又改了工作区里的index.html所以暂存区里是旧版本等待被提交工作区里是新版本还没暂存。此时git commit提交的是旧版本你在文件里最新的一行改动并不会进这次提交。这个细节是很多人「修改丢了」错觉的重灾区。你回到编辑器里打开文件发现最新改动还在于是觉得没问题但仓库里存档的是另一版。如果你在这个状态下直接git commit然后打开 GitHub 一看——咦我明明改了怎么上传的不是这版原因就在此。2.3 一张表看懂 git status 的常见组合我把自己在实际项目里见过的高频状态组合整理成了下面这张表覆盖了日常开发 95% 以上的情况。状态显示工作区暂存区实际含义?? newfile.txt存在新文件无untrackedGit 还没开始追踪它M index.html注意 M 前有空格已修改无修改只发生在工作区未 addM index.html注意 M 后有空格已修改存有这份修改已 add工作区与暂存区一致MM index.html再次修改有旧修改暂存区是旧的修改工作区又有新修改A newfile.txt新文件已 add已追踪的新文件等待提交D oldfile.py已删除已暂存删除删除操作已排队进下次提交无输出与暂存区一致与 HEAD 一致working tree clean全同步理解这张表是读懂git status的关键。接下来的章节我会沿着一个文件的真实旅程把这张表里的状态一个个走一遍。3. 修改的完整旅程一个文件从新建到推送经历了什么理论到这里已经够多了现在让我们用一个实际的文件完整地走一遍它从诞生到推送的旅程。我建议你打开终端跟着敲比干看文字有感觉得多。3.1 第一站untrackedGit 知道你的存在但还没有接管mkdir git-status-demo cd git-status-demo git init echo hello git README.md git statusgit status的输出会是Untracked files: (use git add file... to include in what will be committed) README.mdREADME.md前的??代表它处于 untracked 状态。这个状态特别容易让人误会因为它看起来「好像已经与 Git 相关了」但实际上Git 对 untracked 文件不承担任何保存义务。你随手git clean -fd它就会消失而且git reflog也救不回来因为 reflog 记录的是提交和 HEAD 的移动从没关心过 untracked 文件。实操建议如果你有不想丢失的草稿、临时脚本、本地方案第一时间git add让它进入 tracked 状态。很多「我文件没了」的悲剧都是因为 untracked 文件躺在工作区太久。3.2 第二站modified 与 staged一个文件如何同时拥有两种身份现在让 README 进入 tracked 状态git add README.md git status输出变成Changes to be committed: new file: README.md状态是A即 added已经排队等待提交。注意这时候如果在工作区继续修改echo more content README.md git status你会看到熟悉的「双状态」Changes to be committed: new file: README.md Changes not staged for commit: modified: README.md这个README.md一条在待提交列表一条在未暂存列表原因就是我在第二章说的——暂存区里存的是第一版工作区里是第二版。此时你马上git commit存档的是第一版hello git而第二版more content会孤零零地留在工作区变成无家可归的修改。3.3 第三站committed提交之后为什么有时候还会出现修改如果不小心把两版内容一起提交惯用做法是git add README.md git commit -m docs: init readme提交之后git status回到干净状态。但你可能注意到一个有趣的现象在git log里看这次提交文件内容包含了两行而如果你中途只用git commit没加第二个git add这次提交只包含第一行。这里引出一个很实用的判断技巧怎么知道我这次提交到底包含了哪些内容在提交前用git diff --staged看一眼提交后用git show --stat HEAD复核一遍。这两个命令是我的固定搭配每次提交前后各执行一次几乎杜绝了「自以为提交了但其实没有」的问题。3.4 远程同步push 之后状态发生了什么变化当本地仓库已经提交完成执行git push origin maingit status里关于分支的信息会从Your branch is ahead of origin/main by 1 commit变成Your branch is up to date with origin/main。这其实是对应了文件旅程的最后一站从本地档案柜复制了一份到远端档案柜。这里有个关键点push 不会改变你的工作区和暂存区状态它只同步了分支引用。很多人以为 push 之后本地就「干净了」这个理解大致正确但逻辑要清楚——本地干净是因为你之前 commit 了与 push 没有直接关系。哪怕你不 push只要 commit 过本地同样能是 clean 状态。4. 状态查询实战git status、git diff、git log 怎么配合只看git status只解决了「文件在哪个状态」的问题还没解决「当前状态里具体改了什么」的问题。后一个问题的答案要交给git diff和git log。4.1 git status 短格式与长格式的读法git status的完整输出适合人类阅读但长度感人。我日常更常用它的短格式git status -s输出类似M index.html M style.css MM script.js ?? notes.md D old.py熟记短格式的规则一眼就能明白第一列是暂存区相对于 HEAD 的状态staged 状态第二列是工作区相对于暂存区的状态unstaged 状态字母含义M修改、A新增、D删除、R重命名、??未跟踪所以MM就是刚才说的「暂存区有一份修改工作区又改了一遍」。这个格式配合git diff使用效率极高。4.2 git diff 三兄弟工作区、暂存区、HEAD 之间的差异git diff是一族命令我把它拆成三兄弟来记命令比较对象主要用途git diff工作区 vs 暂存区看我手头但还没 add 的改动git diff --staged也可写--cached暂存区 vs HEAD看已经排队、将被提交的改动git diff HEAD工作区 vs HEAD看所有未提交的改动含已暂存和未暂存这三个命令解决的是同一个问题修改去哪了以及是否真的在我想要的那一层里。在git commit之前执行git diff --staged相当于汇款前核对收款人信息值得养成习惯。4.3 git log 如何看「存档历史」和 commit 状态到了提交之后git status只会告诉你「历史有没有落后/超前」具体的历史脉络要看git log。我常用的组合git log --oneline --graph --decorate --all git log --stat -1 git show --name-status HEAD第一条能看全部分支的提交拓扑第二条看最近一次提交改了哪些文件第三条直接列出这次提交涉及的文件和它们的操作类型A/M/D。这里给一个排查思路如果哪天你发现git status显示 clean但代码内容和预期不符第一反应不是怀疑全局而是用git log --oneline -5确认 HEAD 在哪再用git show HEAD看最后这次提交到底改了啥。大部分「修改丢了」的案子到这里就已经水落石出了——不是修改丢是提交的就不是你想的那版。5. 修改真的消失了吗reset、checkout、stash、amend 的状态边界这章是全文的「高危区」。前面的状态都是静态观察这里的每条命令都会移动修改的位置而且移动的方向是不可逆的——除非你知道reflog的存在。5.1 git reset 三兄弟--soft、--mixed、--hard 到底动了几层git reset是最容易造成「修改消失」的命令核心原因是它的参数直接决定你动的是哪一层参数移动 HEAD重置暂存区重置工作区通俗理解--soft是否否取消提交但改动还留在暂存区等你重新提交--mixed默认是是否取消提交也取消暂存改动退回工作区--hard是是是全部丢弃工作区直接回到目标提交的状态我建议你把git reset HEAD~1即 mixed想成「拆掉一次提交内容退回到桌面」把git reset --soft HEAD~1想成「拆掉提交但内容还在货架上」把git reset --hard HEAD~1想成「拆掉提交并把桌上的草稿也撕了」。其中--hard是最危险的一档。如果你没有记录被 reset 前的提交哈希没有开reflog那这次提交真的会从所有常规视图中消失。所以我在任何需要--hard的场合都会先执行git rev-parse HEAD把当前哈希记下来或者干脆创建一个备份分支git branch backup/before-reset-20250401这行命令的代价几乎为零但能给你的后悔药留个绝对安全的底。5.2 git checkout / git restore 为什么会让修改「消失」git checkout .是把工作区所有文件恢复到暂存区的状态git checkout -- index.html则只恢复单个文件。这里的执行逻辑是用暂存区覆盖工作区。所以它对那些你改了但还没git add的修改是毁灭性的——那些修改只存在于工作区一覆盖就没了。新版 Git 推荐的git restore用法更清晰git restore index.html # 用暂存区覆盖工作区 git restore --staged index.html # 用 HEAD 覆盖暂存区特别注意git restore --staged只会把文件从暂存区里拿出来取消暂存工作区文件不动。这和我早年用git reset HEAD index.html的习惯等价但语义直白得多。我之前提到的「修改消失」场景里最常遇到的就是git checkout .或git restore .随手一敲当时没意识到工作区里还有未提交的宝贵改动。现在我的习惯是任何会覆盖工作区的命令执行前先git diff看一眼有没有未暂存改动再决定要不要继续。5.3 stash草稿被临时收进抽屉抽屉满了呢git stash把工作区和暂存区的修改打包存到一个独立的 stash 栈里让工作区变成干净的 baseline。这个机制本来是救命的——需要紧急切分支、或者要拉取新代码但手头有半成品时git stash比硬 commit 干净得多。但它也有自己的坑。排在第一位的坑是stash 默认不会包含 untracked 文件。你需要额外加-u参数git stash -u如果不加那些??状态的新文件会原封不动留在工作区可能在你后续操作中被误伤。第二位坑是恢复时容易跟当前分支产生冲突尤其当你 stash 之后改了很多代码、再 pop 出来时会进入解决冲突的流程这个过程对新手而言同样容易造成心理恐慌。5.4 amend改写最近一次存档但小心改的不是你以为的热搜词里有git commit --amend确实是高频操作。它的作用是把当前暂存区的改动合并进最近一次提交而不是生成一个新提交。听起来非常方便但它有一个致命注意事项amend 的本质是用一个新提交替换旧提交所以提交哈希会变。示范一下典型场景git commit -m docs: init readme echo typo fix README.md git add README.md git commit --amend这个操作后原本那次提交的哈希 A 变成了新哈希 B。如果你是刚 commit 还没 push这个操作完全没问题历史看起来就像没有发生过错字一样。但如果你已经git push过了再amend就会导致本地和远端分支历史分叉下次 push 必须用强推才能对齐。在小团队里这可能无所谓在共享分支上这就是个大坑——其他人的本地历史会被你的--amend弄得不一致。所以我个人的体验是--amend适用于「上一次提交还没离开本地」的黄金窗口期。一旦提交已经 push我就老老实实再补一个新提交绝不回头改写。这个原则帮我挡掉了无数跟同事对不上历史的麻烦。6. 找回和预防reflog 兜底与日常状态管理习惯最后一个章节要给已经「丢失」的修改一个真正的救命稻草同时聊聊怎么从流程上避免进入「修改去哪了」的恐慌。6.1 reflog 是 Git 的黑匣子找回「丢失」的提交git reflog记录的是 HEAD 指针的每次移动历史。意味着即使你 reset、checkout、cherry-pick 了一通只要 HEAD 移动过reflog 里就有脚印。你那些被--hard干掉的提交只要还留在对象库里没被 gc就有机会找回来。git reflog输出大概是这样的a1b2c3d HEAD{0}: reset: moving to HEAD~1 e4f5g6h HEAD{1}: commit: docs: init readme ...如果你想恢复HEAD{1}那个时间点的状态可以执行git cherry-pick e4f5g6h或者直接创建分支指向它git branch recover-from-reflog e4f5g6h这是我从「差点丢了半天工作量」的经历里学到的保命技能。那次我在功能分支上一路reset --hard到了很前面的提交导致三分之一的代码消失慢悠悠地切了大脑离线模式。就在准备重写的瞬间我想到了reflog用一次cherry-pick把代码完整捞了回来。整个找回过程不到一分钟但从此之后我再也不敢小看reflog。需要说明的是reflog 也有生命周期默认 90 天会被过期清理。如果你怀疑自己要找回的提交已经过期那只能祈祷有所谓的「误操作备份」了。所以与其依赖救援不如养成下面这些低成本习惯。6.2 每天开工/收工的状态检查工作流我现在的日常状态管理流程非常简单但已经稳定运行了好几年开工先看git statusgit status -s确认工作区没有昨天遗留的意外改动。动手前先选边一个文件一旦开始改就明确它是该次 commit 的一部分还是独立变更。是独立变更就先git stash -u收起来避免混在一起。提交前核查git diff --staged看即将提交的内容git diff看还没暂存的内容用git status -s一眼确认短格式状态。三分检查通过才 commit。提交后复核git log --oneline -1加上git show --stat HEAD确认这次提交的文件和内容都符合预期。收工前兜底如果今天还有没提交的改动但我要关电脑了我会明确选择「提交成 WIP commit」或「stash -u」绝不把理解不清的状态留给第二天。这套流程看起来多敲了好几条命令但实际上在每一个大型重构都省了大量时间。因为绝大多数时间我们花在焦虑上的成本远大于敲命令的成本。最后再提一个我自己的小习惯在项目里全局配置一个自定义状态格式让默认输出更贴合我的使用习惯。git config --global status.short true git config --global status.branch true这条配置让git status默认就是短格式分支信息也一起显示。第一次看到新版输出时可能会不习惯但适应后你会觉得整个状态面板都清爽了许多。从「草稿」到「存档」Git 的每一步其实都有迹可循你要做的只是学会读懂这些痕迹。