笔记——深入探究 git 背后的 commit 重写机制
文章目录
- 现场还原
- 先说结论
- git commit --amend 不是原地修改
- 从 commit 对象理解 amend
- 一次 amend 操作背后发生了什么
- 常见使用场景
- 场景一:修改最近一次提交说明
- 为什么 amend 只能修改最近一次提交
- 场景二:修改倒数第二个 commit
- 场景三:合并多个零散 commit 整理成一个提交
- amend、reset、revert 的区别
- 已经 push 后为什么要谨慎使用 amend
- amend 后旧 commit 去哪里了
- 实际开发建议
- 常用命令整理
- 最后总结
现场还原
在日常开发中,经常会遇到这样一种情况。代码写完之后,执行提交:
gitadd.gitcommit-m"fix bug"提交完成后,突然发现 commit message 写得太随意,或者少提交了一个文件,也可能是刚才的代码还有一个小地方需要修改。比如第一次提交之后,通过下面命令查看提交记录:
gitlog--oneline结果如下:
a1b2c3d fix bug这时候发现提交信息不够清楚,想改成:
fix customer address encoding issue第一反应可能是:“那我再提交一次不就行了吗?” 于是继续执行:
gitadd.gitcommit-m"fix message"这样确实可以产生一个新的 commit,但是提交历史会变成:
b2c3d4e fix message a1b2c3d fix bug从代码结果来看,问题可能已经解决了。但是从 Git 历史来看,这两个 commit 并不一定都具有独立意义。第二个提交只是为了修正第一个提交,并不是一个真正完整的业务变化。如果只是为了修正上一次提交,那么更合适的方式应该是:
gitcommit--amend或者直接指定新的提交说明:
gitcommit--amend-m"fix customer address encoding issue"这就是git commit --amend最常见的使用场景。它表面上看是“修改最近一次提交”,但从 Git 内部机制来看,它并不是在原来的 commit 上直接修改,而是重新创建了一个新的 commit 来替换原来的 commit。
先说结论
git commit --amend的作用可以理解为:修改最近一次 commit,并用一个新的 commit 替换掉原来的 commit。常见使用方式主要有三类。
第一类是修改最近一次提交的 message:
gitcommit--amend-m"新的提交说明"第二类是补充遗漏文件,并且保留原来的提交说明:
gitaddxxx.javagitcommit--amend--no-edit这里的--no-edit表示不重新编辑 commit message,只把当前暂存区里的内容合并进最近一次提交。
第三类是修改最近一次提交里的代码。比如提交之后发现代码少了一个判断条件,可以先修改代码,然后执行:
gitadd.gitcommit--amend如果不想修改原来的提交说明,也可以写成:
gitadd.gitcommit--amend--no-edit需要注意的是,git commit --amend只能修改最近一次提交,也就是当前HEAD指向的 commit。如果要修改倒数第二个或者更早的 commit,就不能直接使用 amend,而要使用交互式 rebase:
gitrebase-iHEAD~n比如修改倒数第二个 commit,可以执行:
gitrebase-iHEAD~2然后根据需要把对应 commit 前面的pick改成reword或edit。
git commit --amend 不是原地修改
刚开始理解 amend 时,很容易把它理解成“在原来的 commit 上改一下”。比如现在有一个提交历史:
A --- B ^ HEAD执行下面命令后:
gitcommit--amend-m"new message"直觉上好像是把 B 的提交信息改掉了。但实际上,Git 并不会原地修改 B,而是创建了一个新的 commit B’,然后让当前分支指向 B’:
执行前: A --- B ^ HEAD 执行 amend 后: A --- B' ^ HEAD原来的 B 并不是被直接修改了,而是被新的 B’ 替换掉了。所以 amend 之后再次查看提交记录,会发现 commit id 发生了变化。这也是为什么说 amend 本质上是一种 commit 重写操作。
从 commit 对象理解 amend
想理解为什么 amend 后 commit id 会变化,需要先理解 Git 中的 commit 对象。在 Git 里,一个 commit 不只是保存代码快照,它还包含 tree、parent、author、committer、message 等信息。可以通过下面命令查看当前 commit 的内部结构:
gitcat-file-pHEAD可能会看到类似结果:
tree 6f2b8d4c1f5a0c9b... parent 2d4f8a8c9e1b3f7... author zhangsan <zhangsan@example.com> 1710000000 +0800 committer zhangsan <zhangsan@example.com> 1710000000 +0800 fix bug这里的tree可以理解为当前项目目录结构和文件内容的快照,parent表示当前 commit 是基于哪个父 commit 产生的,author是代码作者,committer是真正执行提交动作的人,message就是提交说明。
Git 的 commit id 不是随机生成的,而是根据 commit 对象内容计算出来的哈希值。也就是说,只要 commit 对象里的内容发生变化,commit id 就会变化。即使文件内容没有变,只是修改了 commit message,commit 对象本身也已经变了,所以 commit id 一定会重新生成。
例如原来的提交是:
a1b2c3d fix bug执行:
gitcommit--amend-m"fix customer address encoding issue"之后可能变成:
e4f5g6h fix customer address encoding issue这里不是a1b2c3d这个 commit id 被修改成了e4f5g6h,而是旧 commit 仍然存在过,只是当前分支已经不再指向它。Git 新建了一个 commit,然后把当前分支指针移动到了新的 commit 上。
一次 amend 操作背后发生了什么
假设当前提交历史是:
A --- B ^ main HEAD现在执行:
gitcommit--amend-m"new message"Git 大致会做几件事:先读取当前HEAD指向的 commit,也就是 B;然后拿到 B 的 parent,也就是 A;接着根据当前暂存区内容生成新的 tree。如果 amend 前没有新的暂存内容,那么新的 tree 可能和 B 原来的 tree 一样;如果 amend 前执行过git add xxx.java,那么新的 tree 就会包含这些新暂存的修改。
随后 Git 会创建一个新的 commit。这个新 commit 的 parent 仍然是 A,但是它的 message、committer 时间、tree 等信息可能已经发生变化,因此 commit id 也会重新计算。最后 Git 会把当前分支从 B 移动到 B’:
A --- B' ^ main HEAD旧的 B 不再被当前分支引用,这就是 amend 背后的 commit 重写过程。
常见使用场景
场景一:修改最近一次提交说明
最常见的场景是修改最近一次提交说明。比如一开始提交得比较随意:
gitcommit-m"fix"查看日志:
gitlog--oneline结果是:
a1b2c3d fix如果这个提交说明太简单,不利于后续维护,可以执行:
gitcommit--amend-m"fix customer address encoding issue"再次查看日志时,可能变成:
e4f5g6h fix customer address encoding issue可以看到,commit message 变了,commit id 也变了。
第二个常见场景是补充遗漏文件。比如第一次提交时只提交了UserService.java:
gitaddUserService.javagitcommit-m"add user service"提交之后发现UserController.java忘记提交了。如果直接再提交一次:
gitaddUserController.javagitcommit-m"add user controller"提交历史会多出一个 commit。但从功能角度看,UserService.java和UserController.java本来就属于同一个功能,这时候可以把遗漏文件补进上一次提交:
gitaddUserController.javagitcommit--amend--no-edit最终提交历史里还是一个 commit,但是这个 commit 里已经同时包含了UserService.java和UserController.java。
第三个场景是提交后发现代码还有一个小问题。比如提交了用户校验逻辑之后,发现少了一个空值判断,可以修改代码后执行:
gitadd.gitcommit--amend--no-edit这样新的代码修改会被合并进最近一次提交,提交说明保持不变。
为什么 amend 只能修改最近一次提交
git commit --amend的目标是当前HEAD,所以它只能重写当前分支指向的最后一个 commit。假设提交历史如下:
A --- B --- C ^ HEAD此时执行:
gitcommit--amend只能修改 C,因为 C 是当前HEAD。如果想修改 B,就不能直接使用 amend。原因在于 C 的 parent 是 B,如果 B 被改写成 B’,那么 C 的 parent 也需要跟着变化。否则就会出现这样的结构:
A --- B --- C \ B'也就是说,修改中间某个 commit 后,它后面的 commit 也必须重新生成一遍。这个过程已经不是单纯修改当前提交,而是重写一段历史,所以需要使用git rebase -i。
场景二:修改倒数第二个 commit
假设当前提交历史是:
A --- B --- C ^ HEAD现在想修改 B,也就是倒数第二个 commit,可以执行:
gitrebase-iHEAD~2执行后会进入编辑界面,内容大概是:
pick bbb222 commit B pick ccc333 commit C如果只是想修改 B 的提交说明,把 B 前面的pick改成reword:
reword bbb222 commit B pick ccc333 commit C保存退出后,Git 会让你重新编辑 B 的 commit message。修改完成后,Git 会继续把 C 应用上去,最终历史会变成:
A --- B' --- C'这里不只是 B 变成了 B’,C 也会变成 C’,因为 C 的 parent 从 B 变成了 B’,所以 C 自己也必须重新生成。
如果不仅要修改提交说明,还要修改 B 里的代码,可以把pick改成edit:
edit bbb222 commit B pick ccc333 commit CGit 会在 B 这个位置停下来。此时修改代码,然后执行:
gitadd.gitcommit--amendgitrebase--continue可以这样理解:git rebase -i负责把历史回放到你想修改的位置,而git commit --amend负责修改当前位置的这个 commit。
场景三:合并多个零散 commit 整理成一个提交
除了修改最近一次提交,git rebase -i在实际开发中还有一个非常常见的使用场景:功能分支合并前,清理分支上的提交历史。
比如开发一个功能时,刚开始可能提交得比较随意:
a1b2c3d add user service b2c3d4e fix typo c3d4e5f add controller d4e5f6g fix null pointer e5f6g7h update code从开发过程来看,这些提交是正常的,因为写代码时经常会边写边修。但是从最终合并到develop或main的角度来看,fix typo、fix null pointer、update code这类提交不一定有独立保留的价值。它们更像是开发过程中的中间修正,而不是完整的功能变更。
如果不清理直接合并,主分支历史可能会变得很碎。以后排查问题时,看到一堆fix、update、change,很难快速判断每个 commit 的真实含义。所以在功能分支合并前,可以使用交互式 rebase 把这些零散提交整理一下。
假设当前功能分支是从develop拉出来的,可以先更新远程引用:
gitfetch然后执行:
gitrebase-iorigin/develop这条命令的意思是:把当前分支上相对于origin/develop新增的提交拿出来,进入交互式整理界面。进入后可能看到:
pick a1b2c3d add user service pick b2c3d4e fix typo pick c3d4e5f add controller pick d4e5f6g fix null pointer pick e5f6g7h update code如果希望最终只保留一个比较完整的提交,可以把后面的提交改成squash或fixup:
pick a1b2c3d add user service fixup b2c3d4e fix typo squash c3d4e5f add controller fixup d4e5f6g fix null pointer fixup e5f6g7h update code这里squash和fixup都可以把当前 commit 合并到前一个 commit 中,区别是squash会保留提交说明并让你重新编辑最终 message,而fixup会直接丢弃当前 commit 的 message,只保留代码内容。实际开发中,如果后面的提交只是“修错别字”“补文件”“修空指针”这类过程性修改,通常用fixup就可以;如果某个提交说明里还有值得保留的信息,可以用squash。
整理完成后,最终提交历史可能会变成:
f6g7h8i add user service也可以整理成多个有意义的提交,比如:
f6g7h8i add user base service g7h8i9j add user controller and validation这取决于这个功能本身是否适合拆分。如果一个分支只做了一件完整的事情,压成一个 commit 会比较清晰;如果这个功能里包含多个相对独立的变更,整理成两三个有明确含义的 commit 会更合适。清理提交历史的目的不是强行把所有 commit 合成一个,而是让每个 commit 都有清楚的意义。
在整理过程中,如果出现冲突,需要正常解决冲突,然后执行:
gitadd.gitrebase--continue如果中途发现整理错了,也可以放弃本次 rebase:
gitrebase--abort如果这个功能分支已经推送到远程,rebase 后本地提交历史会发生变化。此时普通git push通常会失败,需要使用:
gitpush --force-with-lease这里仍然不建议直接使用git push -f。因为--force-with-lease会先检查远程分支是否有别人新的提交,如果远程已经被别人更新,它会拒绝覆盖,相对更安全。
需要注意的是,这种清理操作更适合自己的功能分支,尤其是在提交合并请求之前。如果这个分支已经有其他人共同开发,或者别人已经基于你的提交继续开发,就不要随意 rebase 和强推,否则容易造成大家的提交历史不一致。
简单来说,git commit --amend更适合修正最近一次提交,git rebase -i更适合在合并前整理一段提交历史。它们的目标都是让最终进入主分支的提交更加清晰,而不是把开发过程中的每一次临时修正都原样保留下来。
amend、reset、revert 的区别
git commit --amend、git reset和git revert都和提交历史有关,但它们解决的问题不同。amend是修改最近一次提交,本质是创建一个新的 commit 来替换旧 commit;reset是移动当前分支指针,可以把分支退回到某个提交;revert是新增一个反向提交,用来撤销某个历史提交带来的修改。
比如:
gitcommit--amend-m"new message"适合修正最近一次提交。
gitreset--softHEAD~1会撤销最近一次 commit,但保留暂存区内容。
gitreset--hardHEAD~1会撤销最近一次 commit,并且丢弃工作区修改,这个命令风险比较高,执行前一定要确认。
gitrevert a1b2c3d不会重写历史,而是新增一个 commit 来反向撤销a1b2c3d这个提交。对于已经推送到远程、多人协作的分支来说,revert通常比reset或 amend 更安全。
已经 push 后为什么要谨慎使用 amend
如果 commit 还没有 push 到远程,使用 amend 一般只影响本地历史,风险相对较小。但是如果这个 commit 已经推送到了远程,再执行 amend,本地和远程的提交历史就会不一致。
原来本地和远程都是:
A --- B本地执行 amend 后变成:
A --- B'而远程仍然是:
A --- B这时候普通的git push通常会失败,因为 Git 会发现远程和本地历史不一致。如果确实需要推送 amend 后的历史,一般需要使用:
gitpush --force-with-lease不建议直接使用:
gitpush-f因为-f会强制覆盖远程分支,而--force-with-lease会先检查远程分支是否被别人更新过。如果远程已经有了别人的新提交,它会拒绝覆盖,相对更安全。
所以在团队开发中,如果某个 commit 已经被别人拉取,或者已经推送到公共分支,比如main、develop、release,就要谨慎使用 amend。因为 amend 本质上是历史重写,一旦别人已经基于旧 commit 开发,就可能导致双方历史不一致。
amend 后旧 commit 去哪里了
执行 amend 后,旧 commit 并不是立刻从仓库里消失,只是当前分支不再指向它。比如原来是:
A --- B ^ mainamend 后变成:
A --- B' ^ main旧的 B 变成了暂时没有分支引用的 commit。如果想找回,可以通过git reflog查看 HEAD 的移动记录:
gitreflog可能会看到类似结果:
e4f5g6h HEAD@{0}: commit (amend): init amend demo a1b2c3d HEAD@{1}: commit: init demo如果确实要回到旧提交,可以使用:
gitreset--harda1b2c3d不过git reset --hard会丢弃当前工作区修改,执行前一定要确认是否还有未保存的代码。
实际开发建议
实际开发中,如果只是本地刚提交完,发现 message 写错了、文件漏提了,或者代码需要补一个小修改,可以直接使用git commit --amend。这种场景下 amend 能让提交历史更干净,避免出现很多“fix again”“update”“补文件”之类没有独立意义的 commit。
提交后也可以先检查一下当前状态和提交内容:
gitstatusgitlog--onelinegitshow--stat确认文件是否完整、提交说明是否准确、有没有把不该提交的配置文件带进去。如果发现问题,在 push 之前用 amend 修正是比较自然的做法。
如果要修改的不是最近一次提交,而是倒数第二次或者更早的提交,就使用:
gitrebase-iHEAD~n只修改提交说明用reword,需要修改代码用edit。如果 commit 已经推送到远程,尤其是多人协作分支,就要谨慎处理,必要时优先和团队确认,再考虑是否使用git push --force-with-lease。
常用命令整理
修改最近一次提交说明:
gitcommit--amend-m"新的提交说明"补充遗漏文件并保留提交说明:
gitaddxxx.javagitcommit--amend--no-edit修改最近一次提交内容和说明:
gitadd.gitcommit--amend查看当前 commit 对象:
gitcat-file-pHEAD查看 HEAD 移动记录:
gitreflog修改倒数第二个 commit:
gitrebase-iHEAD~2amend 后推送远程:
gitpush --force-with-lease最后总结
git commit --amend表面上看是修改最近一次提交,但从 Git 内部机制来看,它并不是原地修改旧 commit,而是创建一个新的 commit,然后让当前分支指向这个新的 commit。也正因为如此,amend 后 commit id 会发生变化。
可以简单理解为:
amend:修改最近一次 commit,本质是重写当前 commit rebase -i:修改历史中的某一次或多次 commit reset:移动分支指针 revert:新增一个反向提交,不重写历史在本地开发阶段,amend 是一个非常实用的工具,可以用来修正提交说明、补充遗漏文件、合并小修改,让提交历史保持清晰。但如果 commit 已经推送到远程,尤其是公共分支,就要谨慎使用,因为它会改写历史。理解这一点之后,再看git commit --amend,它就不只是一个修改提交信息的命令,而是理解 Git commit、HEAD、branch、parent 之间关系的一个很好入口。