改一行代码,然后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)**是一个二进制索引文件,记录"下次提交时,每个路径应该对应哪个 blob";HEAD则指向当前分支的最新 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表示只改了工作区还没add;M表示改动已入暂存区、工作区跟暂存区一致;??是未跟踪的新文件;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 | 看从上次提交至今所有改动,不管有没有 add |
git 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 --source=HEAD~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 已经 commit:amend、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 用 CRLF,Linux 和 macOS 用 LF。当仓库里存的是 LF、你本地检出时被自动转成 CRLF,如果配置不一致,就可能出现"我只改了一行,Git 却报整个文件都改了"。判断方法很简单:git diff --stat显示某个文件改动行数接近总行数,基本就是它。修法是统一配置,Windows 开发者常用core.autocrlf true(提交时转 LF、检出时转 CRLF),Linux/macOS 用input;更稳妥的做法是在仓库根目录加.gitattributes,写* text=auto eol=lf,让规则跟着仓库走而不是跟着每个人的机器走,避免新同事入职就被这个问题绊一跤。
权限位是另一个隐蔽点。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 LFS(Large 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 false |
add后继续改,提交内容不全 | 索引冻结的是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当所有破坏性命令前的保险栓。它们加起来的成本很低,但换回来的是一个可以放心回滚、可以放心给别人看的历史记录。