
改一行代码然后git add、git commit——不少人对 Git 的全部理解就停在这两步。可一旦问题落到修改这两个字上比如我改了但还没提交想退回去怎么办提交信息写错了想改一下为什么同事只改了一行diff 显示整个文件都变了换台电脑后 Git 说所有文件都被修改了立刻就卡壳。Git 里的修改看着是最日常的操作实际上牵扯到它的对象模型、三棵树结构、索引机制和引用管理任何一个环节没吃透你都会在关键时刻做出错误判断甚至把同事的提交冲掉。这篇内容就是把这四个字拆开揉碎讲一遍Git 认为什么算一次修改、一次修改从工作区到版本库经历了什么、不同阶段的修改分别该用什么命令撤销、哪些看起来像修改的东西其实是隐式修改、以及想整理历史提交时边界在哪。适合已经会clone、add、commit、push这四件套但遇到回退和冲突就心里没底的人也适合刚开始用 Git 做团队协作、想少走弯路的新手。1. 先把“修改”这件事定义清楚Git 为什么会这样设计1.1 快照不是差异一次改名背后的对象模型要理解 Git 里的修改先得接受一个反直觉的事实Git 不存差异它存快照。传统集中式版本控制工具更偏向记录 delta也就是第 37 行从 A 变成了 B这种增量描述好处是省空间坏处是每次想还原某个版本都要从头把所有增量叠加一遍。Git 走的是另一条路每次提交它给当前工作目录的每个文件内容算一次哈希内容相同的文件哈希相同直接复用已有对象内容变了的文件就生成一个全新的 blob 对象存进去。整个目录结构的清单被打包成一个 tree 对象tree 再被 commit 对象引用commit 又串成一条链。所以当你在 Git 里改了一个文件的一行底层发生的是旧 blob 原封不动躺在对象库里新内容生成一个新 blob当前 commit 的 tree 指向新 blob分支引用比如main指向新的 commit。旧的 blob 并不会消失这也是为什么你还能用git show 旧commit:文件把历史版本捞出来。生活里打个比方这不像在照片上涂改而像每次变更都重新拍一张全景照片旧照片锁进档案柜——因此修改在 Git 里本质上是产生新对象并移动引用而不是在原地动刀。这个设计带来的直接结果有三条。第一切换分支非常快因为只需要换一下引用指向工作区按新 tree 展开即可。第二历史版本随手可得只要你记得或能找到 commit。第三跨平台、跨编辑器的任何细微字节变化都会被判定为修改因为哈希是对内容算的一个换行符、一个空格、一个文件权限位的差别都会改变结果。后面第 4 章讲的那些暗雷根子都在这里。1.2 三棵树模型工作区、暂存区、HEAD 各管什么Git 里有三个位置反复出现把它们想象成三张桌子绝大部分修改的困惑都会自动消解。**工作区working directory**是你实际编辑文件的地方编辑器里看到的、ls出来的都是它**暂存区staging area / index**是一个二进制索引文件记录下次提交时每个路径应该对应哪个 blobHEAD则指向当前分支的最新 commit也就是上一次提交固定下来的状态。三者的关系是add把工作区的内容写进暂存区commit把暂存区的内容固化成 commit 并把 HEAD 往前移。用寄快递来类比最贴切。工作区是你桌上正在打包的东西随手改来改去暂存区是已经装进箱子、贴上运单的那一箱胶带一贴就定下来了HEAD 是已经寄出去、单号可查的历史记录。你在桌上把东西换掉箱子里的不会变你把箱子里的东西掏出来寄出去的那箱也不会变。这就是为什么我明明改了文件git status却还显示有一份待提交的旧内容——因为工作区和暂存区是两个独立的位置你改的是桌子箱子里装的还是旧版本。提示新手最容易把改了文件和提交里包含这个改动画等号。记住一句话只有进过暂存区的内容才可能出现在下一次提交里。反过来说暂存区里的内容如果和 HEAD 不同git status会把它列在 Changes to be committed 下面这才是下次真的会提交的东西。1.3 为什么搞清定义比背命令更省时间我见过太多人把 Git 命令当成咒语背出问题就搜git 回退到上个版本命令抄一条git reset --hard HEAD~1就跑结果本地没提交的改动全没了。问题的根源不是命令背得少而是没弄清楚这次修改现在停在哪个位置。修改可能停在工作区可能停在暂存区也可能已经落进 commit甚至是已经推到远程的 commit——每种位置对应的正确操作完全不同用错工具轻则无效重则丢数据。把三棵树的位置关系刻进脑子里之后你会发现撤销类命令其实是可以自己推导的想让某个位置变成另一个位置的样子就从哪来、到哪去、影响谁三句话一过命令自然浮出来。git restore系列是从一个位置往另一个位置复制文件内容git reset系列是移动引用并顺手重置暂存区git revert是造一个反向提交——它们不是魔法只是操作的层次和影响范围不同。理解到这个程度你就不需要靠记忆了。2. 追踪一次修改的完整生命周期从工作区到提交2.1 git status 两列输出到底在说什么git status是所有操作的仪表盘但它的输出被很多人当成了背景噪音。短格式git status -s里每行有两个字符左列表示暂存区相对 HEAD 的差异右列表示工作区相对暂存区的差异。如果你看到MM说明这个文件既有一份改动已经进了暂存区之后又在工作区里继续改动暂存区和工作区内容已经不一致M表示只改了工作区还没addM表示改动已入暂存区、工作区跟暂存区一致??是未跟踪的新文件A是新增已暂存D是已暂存的删除。看一个典型的现场。假设app.py原本提交好了你改了一行、git add了然后又改了一行但没add此时git status -s会输出MM app.py这个MM是关键信号它告诉你如果我此时 commit提交进去的是第一次改动第二次改动会被留在工作区。很多人以为 commit 会带上全部改动结果发现线上少了一段逻辑就是栽在这里。处理办法有两个要么再git add app.py把最新内容也塞进暂存区要么用git add -p分块挑选你真正想提交的那部分。git add -p是资深用户的高频操作它把每个改动拆成小块逐个问你要不要特别适合顺手改了个无关的格式但不想提交它的场景。2.2 git add 放进暂存区的究竟是什么git add常被翻译成添加文件这个翻译误导性极强。它真正做的事是把工作区当前内容写入对象库生成 blob然后更新索引中该路径的条目。也就是说它不是把文件交给 Git 管理而是拍下此刻这个文件的快照登记为下次提交的候选内容。文件在add之后继续被编辑暂存区里留下的仍然是add那一刻的版本。想验证这一点你可以git add后修改文件再用git diff --cached看暂存区里到底是什么会发现它和你眼下编辑器里的内容不一样。git add app.py # 把当前内容写入索引 # 此时再编辑 app.py加入一行 print(debug) git diff # 显示工作区 vs 暂存区能看到 print(debug) 这一行 git diff --cached # 显示暂存区 vs HEAD看不到 print(debug)如果add的是一个以前被.gitignore忽略、后来才改名去掉忽略的目录Git 会递归把里面所有未被忽略的文件都暂存进来包括你可能不想提交的临时文件。所以更稳的习惯是先用git status -s看一眼有哪些路径变化再决定是逐个add还是git add -A。另外提醒一句git add .的行为在各版本略有差异现在它等价于把当前目录及子目录变化全部加入但对删除操作是否纳入处理建议直接用语义明确的git add -A全仓库所有变化或git add -u只处理已跟踪文件的修改和删除避免歧义。2.3 git diff 四个常用形态别再搞混git diff是查看修改最直接的窗口但它的四种常用形态分别比较不同层次混用会得出完全错误的结论。下面这张表我建议直接存下来命令比较对象典型用途git diff工作区 vs 暂存区看我还没 add 的改动有哪些git diff --cached等价--staged暂存区 vs HEAD看这次 commit 到底会包含什么git diff HEAD工作区 vs HEAD看从上次提交至今所有改动不管有没有 addgit diff commitA commitB两个提交之间对比历史任两个版本实操中最有用的其实是第二条。提交之前先跑git diff --cached逐行确认这就是我要提交的东西吗能挡掉大量误提交——比如把调试用的日志、临时改的端口号、误删的一行配置一起带上去。我自己的习惯是把它和git diff --cached --stat搭配用先看文件级别的概览再逐文件看细节。还有几个提高信噪比的参数值得记住。git diff -w忽略所有空白变化能帮你判断是不是只有缩进变了git diff --ignore-blank-lines忽略纯空行增删git diff --stat只给统计不给内容。如果 diff 输出里出现^M或者干脆整文件全红全绿那基本是换行符问题先别急着提交翻到第 4 章处理。2.4 git commit 落盘时 Git 做了哪几件事git commit执行的那一瞬间Git 做的事比你想的多。它先把索引里的目录结构写成若干 tree 对象每层目录一个然后创建一个 commit 对象里面记录指向根 tree 的指针、父提交的哈希、作者和提交者信息、时间戳以及提交说明最后把当前分支引用移到这个新 commit 上。整个过程中只有索引里的内容会被写进 tree工作区里那些没add的改动完全不参与它们继续留在工作区git status下一轮依然会报出来。理解这一点很多现象就有了答案。为什么git commit之后还有未提交改动因为那些改动从来没进索引。为什么git commit --amend能修改上一次提交因为它实际上是创建一个新 commit 替换掉旧的旧 commit 从分支上摘下来但仍留在对象库里直到被垃圾回收。为什么git commit -a能省掉add步骤因为它对已跟踪文件的修改和删除自动执行了暂存但注意它不处理新增的未跟踪文件新文件还是得手动add。git commit -m fix: 修正登录态校验顺序 git commit -am fix: 顺手修掉两处已知问题 # 只对已跟踪文件生效 git commit --amend --no-edit # 沿用原提交信息内容换成本次暂存内容注意git commit --amend改的不是提交的内容而是那一次提交本身。它会重写提交哈希如果这个 commit 已经推送到共享分支并被别人拉取过改完再推至少要强推会给别人制造麻烦。这条红线在第 5 章还会细说。3. 撤销修改按“修改停留在哪一层”选工具3.1 只改了工作区restore 与 checkout 的区别如果改动只停在工作区、还没add撤回是最安全的场景因为对象库里没有任何新东西直接让文件回到暂存区登记的那个版本即可。现代 Git 推荐用git restore 文件老写法是git checkout -- 文件两者效果一致但checkout这个名字同时承担了切分支、切提交、恢复文件三种职责语义太杂容易误操作所以新版本把它拆成了git switch和git restore。git restore app.py # 单个文件回退到暂存区版本 git restore . # 当前目录所有文件 git restore --sourceHEAD~2 app.py # 从指定提交取版本覆盖git restore有一个很实用的隐藏玩法--source可以指定任意来源不限于暂存区。比如你不小心把配置文件删了又提交了可以git restore --source某个早点的commit config.yaml把那个版本单独捞回来再重新提交。另一个配套命令是git clean专门清理未跟踪文件先用git clean -nd干跑一遍看看会删什么确认无误再git clean -fd。未经 dry-run 的git clean -fd是新手最容易造成不可逆损失的命令之一被删的文件连对象库都没有备份。3.2 已经 add 进暂存区怎么把修改退回来改动进了暂存区也不代表必须提交只是需要多做一步先把索引恢复到 HEAD 的状态文件的改动会退回工作区继续保留。老写法是git reset HEAD 文件新写法更直白git restore --staged app.py # 只把索引退回工作区内容不动 git restore --staged . # 撤销所有暂存注意git restore --staged之后改动并没有消失它从已暂存变成了未暂存文件内容仍是你在编辑器里写的样子。这正是大多数人想要的效果——我add早了想把几处改动拆成两个提交。拆提交的标准做法就是git restore --staged .全部退回然后git add -p挑第一组相关改动提交一次再挑第二组再提交一次。有一个容易混淆的点如果某个文件是新建后直接add的git restore --staged会把它变成未跟踪状态??因为 HEAD 里本来就没有它索引退回 HEAD 就等于这个条目不存在了。文件本身还在磁盘上不会被删。但如果你接下来手贱跑git clean -fd它就会被清掉——所以两者别连着随手敲。3.3 已经 commitamend、revert、reset 怎么选改动已经进了提交就到了需要动脑的区域核心是先回答两个问题这个提交推出去了吗我想要的结果是历史里没有它还是历史里留下它、但把影响抵消掉四个问题的组合对应不同工具。只改最后一次提交且没推远程用git commit --amend把想加的内容add进去后执行提交信息可以顺手一起改掉。已经推远程但确认分支只有你在用--amend后再git push --force-with-lease也能接受但一定要用--force-with-lease而不是--force前者会在远程有你不知道的新提交时拒绝执行能防止覆盖别人的工作。要撤销中间某次提交且已经共享用git revert commit它生成一个内容相反的新提交历史是往前走而不是往回改最安全团队协作首选。git reset则用来把分支指针往回挪它有三种模式区别大到足以决定你的本地文件是否安全下一节单独拆。3.4 reset 的 soft/mixed/hard一张表说清边界git reset的本质是移动 HEAD 指向的提交同时按模式决定要不要顺带重置暂存区和工作区。三种模式的差异用一张表说得最清楚假设执行git reset --模式 HEAD~1模式HEAD 移动暂存区工作区典型场景--soft是不动不动想合并最近几个提交改动全留暂存区--mixed默认是重置到新 HEAD不动撤销提交并重新挑选要提交的内容--hard是重置到新 HEAD重置到新 HEAD彻底丢弃提交和本地改动慎用--soft的用法举例你连着提交了五次都是一路修修补补想合成一条干净的提交可以git reset --soft HEAD~5五次改动会全部躺在暂存区再一次性git commit就好。--hard是最危险的它会把你工作区里未提交的改动直接抹掉而且这些内容没有进过对象库reflog也救不回来。所以在敲--hard之前我会强制自己先跑一次git stash或者git diff backup.patch把当前状态留个后路哪怕最后用不上也就多花三秒。注意git reset --hard不会删除未跟踪文件它们会原地留下被它清掉的是已跟踪文件的未提交改动和暂存区内容。这两类东西一个是可恢复的靠 reflog 找 commit一个是彻底不可恢复的工作区改动从没进过对象库心里要有这条分界线。4. 容易被忽略的隐式修改重命名、换行符、权限与大小写4.1 重命名在 Git 里其实是“删除加新增”Git 的对象模型里没有重命名这个操作一次重命名在底层就是旧路径被删除、新路径被新增。你看到的R100 app.py - core/app.py是展示层用相似度算法猜出来的不是存储层的真实记录。默认情况下diff.renames是开启的Git 会比较两个文件内容的相似度超过阈值就标注成 rename。相似度阈值可以用-M手动指定例如git diff -M90%要求 90% 以上相似才认作重命名git log --follow 文件则可以在重命名之后继续追踪这个文件的历史。这里有个真实会踩的问题一个大文件如果被改动超过一定比例又同时改了名Git 可能就猜不出重命名了diff 里会显示成删了一个大文件 加了一个大文件review 起来非常痛苦。解决办法是尽量把重命名和内容大改分成两次提交先纯改名提交一次Git 能 100% 识别后续再改内容历史会清爽很多。另外在 Windows 上如果只是把文件名大小写改了比如README.md改成readme.md因为文件系统默认不区分大小写Git 可能压根检测不到变化需要先用两步改名法git mv README.md tmp git mv tmp readme.md或者提前把core.ignorecase设为 false不建议可能引入其他麻烦。4.2 换行符与权限位跨平台协作的两大暗雷这两个坑我几乎每年都要帮同事排查一次。换行符方面Windows 用 CRLFLinux 和 macOS 用 LF。当仓库里存的是 LF、你本地检出时被自动转成 CRLF如果配置不一致就可能出现我只改了一行Git 却报整个文件都改了。判断方法很简单git diff --stat显示某个文件改动行数接近总行数基本就是它。修法是统一配置Windows 开发者常用core.autocrlf true提交时转 LF、检出时转 CRLFLinux/macOS 用input更稳妥的做法是在仓库根目录加.gitattributes写* textauto eollf让规则跟着仓库走而不是跟着每个人的机器走避免新同事入职就被这个问题绊一跤。权限位是另一个隐蔽点。Git 记录的文件模式只有 100644普通文件和 100755可执行文件两类。如果你在 Linux 上chmod x了某个脚本diff 里会出现old mode 100644 / new mode 100755内容一个字都没改却产生了一条修改。反过来如果你的仓库在 Windows 上克隆所有文件都会被当成 100644回到 Linux 一看可能到处都是权限差异。处理办法有两种真需要可执行权限的明确提交这次 mode 变化并在.gitattributes里不作特殊处理不需要的设置git config core.fileMode false让本地忽略权限变化只影响本机不改仓库行为。git config --global core.autocrlf input # Linux / macOS git config --global core.autocrlf true # Windows git config core.fileMode false # 忽略文件权限位变化提示出现整文件全红全绿时别急着git add。先跑git diff --ignore-all-space看看是不是只有空格和换行变了再跑git check-attr -a 文件确认.gitattributes有没有生效。确认是换行符问题就集中修一次配置和.gitattributes一次性提交比每次单独处理划算得多。4.3 文件名大小写与 .gitignore 的修改.gitignore的修改也有讲究。很多人以为把某个路径写进.gitignore就万事大吉其实它只对未跟踪文件生效已经被跟踪的文件即使加进忽略列表Git 依然会继续追踪它的修改。想让某个已跟踪文件停止追踪需要git rm --cached 文件把索引条目删掉但保留磁盘文件再提交一次。这个操作对node_modules、IDE 配置目录、编译产物特别常用但要注意如果团队里其他人本地还有这些文件被跟踪他们拉取后本地文件不会自动删需要各自执行一遍。另外补充一个高频场景改了远程仓库地址。有时候仓库迁移、或者你从个人仓库换到团队仓库就需要改 remote 的 URL。命令行是git remote set-url origin 新地址改完git remote -v确认一下。如果你用的是 GUI 工具在设置里找版本控制里的 Git 配置项通常也能直接改远程地址效果和命令行一致本质都是改.git/config里的[remote origin]段。改完第一次push前建议先git fetch一次确认新地址可达、分支对得上免得推错地方。4.4 二进制与大文件为什么改一下就提交不动了文本文件的修改是行的增删diff 能精确到行二进制文件图片、模型、编译产物、设计稿的修改在 Git 眼里就是整个对象换了一个哈希diff 只能告诉你变了根本没法看内容差异。所以二进制文件的一个小改动就会在对象库里多出完整的一份仓库体积会以肉眼可见的速度膨胀。曾经有个项目因为把几个几十兆的中间产物提交进去两年后仓库拉取要等好几分钟清理起来特别麻烦。处理原则有几条。第一能在构建时生成的产物一律不提交写进.gitignore。第二确实必须版本化的中等体积二进制文件尽量控制改动频率或者考虑用 Git LFSLarge File Storage把大文件内容放到单独的存储、仓库里只留指针。第三如果大文件已经进过历史简单git rm是没用的旧对象还在历史里需要专门的清理工具重写历史——这一步影响所有协作者团队里必须提前沟通好让所有人重新克隆别自己闷头搞完就推上去。5. 修改历史rebase 交互式整理与不可逾越的红线5.1 git rebase -i 的六个动作逐条拆解git rebase -i是整理提交历史的利器但它的心智模型很简单把一串提交摘下来按你的指令逐个重新应用到新基底上所以每个被处理的提交哈希都会变。执行git rebase -i HEAD~4会打开编辑器列出最近四个提交每行前面是一个动作词常用的有六个。pick保留这个提交原样应用。reword保留内容只改提交信息适合修错别字或补描述。edit应用到这里停下让你补充文件改动或拆分提交改完git rebase --continue。squash把当前提交合并进上一个提交信息会把两条拼在一起让你编辑。fixup和squash一样合并但直接丢弃当前提交的信息适合上一条提交忘了加文件这种补丁。drop直接丢弃这个提交。最实用的组合是fixup因为日常开发里提交完发现漏了一个文件太常见了。你可以先git add漏掉的文件再git commit -m 临时然后git rebase -i把这条临时提交标成fixup历史就干净了。整个过程如果中间出错git rebase --abort可以原样退回去不用担心半路卡死。5.2 三条红线什么情况下绝对不能改历史改历史本身没问题问题在于改动会造成哈希变化而哈希变化会让别人的本地记录对不上。三条红线我建议直接背下来。第一条已经推到共享分支、且可能有其他人基于它工作过不要改。你的push会被拒强行推上去别人拉取时会遇到历史分叉处理起来很痛苦。第二条永远不要对主分支做rebase -i后强推哪怕你觉得就我自己在动这个分支只要它是团队主干就这么假设不行。第三条改历史前先建一个备份分支git branch backup-$(date %Y%m%d)一行就够出事时能立刻回来。判断能不能改有一个简单标准问自己这个提交别人可能已经拉过了吗。如果答案是不确定就默认不能改用git revert造反向提交或者干脆新提交一条修正。多一条提交记录不丢人把同事的仓库搞崩才丢人。5.3 改崩了怎么救reflog 与 fsck即使踩了坑也不是没救。Git 的reflog记录了 HEAD 和分支引用的每一次移动默认保留 90 天是最后的安全网。git reflog # 看最近所有引用移动记录 git reset --hard HEAD{3} # 回到三次操作前的状态 git fsck --lost-found # 找回悬空对象去 .git/lost-found 里翻真正救不回来的只有一种情况从没进过对象库的工作区改动被--hard或clean清掉。因为 reflog 靠的是引用移动工作区内容根本没被记录过。所以第 3 章我反复强调执行破坏性命令前先git stash或者导出 patch这是花几秒钟买个安心。至于已经提交过的东西哪怕分支引用被删、提交变成悬空对象git fsck通常也能捞到只是需要点耐心比对哈希。6. 修改相关的排错实录与日常检查清单6.1 高频问题速查表下面这些问题我在实际工作和帮人排查时反复遇到整理成表方便对号入座。现象大概率原因处理方式只改一行diff 整个文件全变换行符 CRLF/LF 不一致统一core.autocrlf与.gitattributes内容没改却出现 mode 变化文件权限位被改确认真需要则提交否则core.fileMode falseadd后继续改提交内容不全索引冻结的是add时版本再git add或养成用git add -p的习惯提交信息写错想改尚未推送git commit --amend提交信息写错且已推送已共享git revert或新提交修正别强推撤回提交后本地改动没了用了reset --hard若改动未提交只能靠备份已提交靠git reflog文件名大小写改了没生效文件系统不区分大小写两步改名法或临时改core.ignorecase想停止追踪已提交的生成物.gitignore对已跟踪文件无效git rm --cached后提交6.2 几个我真实踩过的坑第一个坑发生在早期我在一个已经有几百次提交的分支上跑了git reset --hard想撤掉一次错误的合并结果工作区里花了一下午写还没提交的改动全没了因为那些内容从未进过对象库。从那以后我形成了一个条件反射手指放到--hard上之前先git stash push -m before hard reset。哪怕十次里有九次用不上第九次不算什么第十次能救命。第二个坑是换行符。团队里一位同事用的是配置齐全的编辑器另一位刚装好环境.gitattributes也没配结果一个人提交后另一个人拉下来整个文件都变成了修改状态review 时满屏红绿完全没法看。后来我们把.gitattributes加进仓库并写进新环境初始化步骤问题再没出现。这件事让我意识到凡是能写进仓库的规则就别留在个人机器上靠口头约定。第三个坑跟git add -p有关。有次我在拆提交时挑得太快把一个调试用的临时变量一起提交上去了。后来我固定在提交前跑git diff --cached从头到尾看一遍宁可多花两分钟也不再让这种东西溜进历史。这个习惯一旦养成误提交率会明显下降。6.3 提交前我会固定跑的一遍检查这套动作我每次提交都会走一遍加起来不到一分钟但挡掉的问题相当多。git status -s # 先看两个字符MM 要特别注意 git diff --cached --stat # 确认待提交的文件范围有没有意外文件 git diff --cached # 逐行确认内容检查调试代码和临时改动 git diff # 看还有哪些没 add 的判断是否需要拆成第二个提交如果发现文件范围比预期多立刻git restore --staged把不需要的挑出来如果发现 整文件全变停下来查换行符和权限位别硬着头皮提交。这套流程的核心思想就一句话提交是把暂存区固化成历史既然是历史就值得在固化前多看一眼。这套方法我也逐步沉淀成了自己的操作习惯把git add -p当默认命令把git diff --cached当提交前的最后一道闸把git stash当所有破坏性命令前的保险栓。它们加起来的成本很低但换回来的是一个可以放心回滚、可以放心给别人看的历史记录。