深入理解 Git:在 cu/curriculum 中掌握历史改写、远程协作与分支指针原理
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
本篇文章聚焦于开源课程仓库 cu/curriculum(The open curriculum for learning web development)中「更深入地看 Git」一课所讲的核心能力:在
git add、git commit、git push之外,学会用git commit --amend、git rebase -i、git reset、git push --force等命令安全地改写提交历史,并理解“分支是指针”这一底层模型。读完本文,你将掌握近程与远程历史改写的完整命令操作、这些操作的危险边界与最佳实践,并能从源码级视角(提交快照 + 指针链)解释 Git 的工作方式。
从“会用”到“看懂”:为什么需要对 Git 有更深一层的认知
无论你是业余爱好者还是志在成为专业 Web 开发者,Git 都是一项至关重要的技能。它就像“加了特效的保存按钮”(the "save" button on steroids),能够支持无缝的团队协作。Git 需要掌握的命令其实并不多,但真正的难点往往在于可视化地想象正在发生的事情——文件、暂存区、提交、分支、远程仓库之间究竟是如何关联起来的。
本文要解决的就是这个“可视化”问题:不再停留在git add .、git commit、git push这三个高频命令上,而是深入讨论三大主题:
- Remotes(远程仓库):本地历史与远端历史如何交互,何时可以安全地强制推送;
- Pointers(指针):提交与分支在底层究竟是什么;
- Changing Git History(改写历史):如何修改最近或较久远的提交。
这一点在本课程中尤其重要:课程项目(从基础 JavaScript 到 Ruby on Rails、React 全栈项目)的复杂度正在不断上升,使用有纪律的 Git 工作流不再是可选项,而是必需项。在继续推进课程之前,非常有必要先掌握本文内容。
本课学习目标
- 掌握会改写历史的 Git 命令;
- 掌握改写历史的不同方式(改最近提交、改多个提交、重排、合并、拆分);
- 学会借助远程仓库改写历史;
- 认清历史改写操作的危险;
- 掌握历史改写操作的最佳实践;
- 理解“指针(Pointers)”这一底层概念。
动手准备:搭建一个安全的 Git 演练场
理论先放到一边,我们先创建一个可以放心“搞破坏”的 Git 演练场,所有历史改写操作都可以在这里安全地跟着做。
- 像往常一样,在 GitHub 上新建一个仓库,名字随意,并
git clone到本地; - 进入克隆下来的仓库目录,创建几个新文件,并按照下面的命令依次提交。
注意:请刻意保留命令中的拼写错误(
Create send file中的send应为second),后续的交互式改史操作会用到它。
touch test{1..4}.md git add test1.md && git commit -m 'Create first file' git add test2.md && git commit -m 'Create send file' git add test3.md && git commit -m 'Create third file and create fourth file'这样我们就得到了三个提交的线性历史,其中第二个提交的消息里藏着一个拼写错误,而第三个提交一口气“打包”了两个文件(test3.md与test4.md)——这两处“瑕疵”正是后面练习rebase、squash与拆分提交的素材。
先把默认编辑器配置好
在继续之前,建议先完成一个非常影响体验的配置:将 Git 的提交信息编辑器切换为你的代码编辑器。因为git commit --amend、git rebase -i这类命令会打开文本编辑器让你编辑信息,如果使用默认的 CLI 编辑器(如 Vim),不熟悉时很容易卡在编辑器里无法保存退出。
本仓库的基础 Git 课程在 git/foundations_git/git_basics.md 中给出了具体配置方法(“Changing the Git commit message editor”一节),核心命令如下(以 VS Code 为例):
git config --global core.editor "code --wait"执行后没有输出即表示配置成功。此后既可以用git commit -m "消息"快速提交,也可以用git commit打开 VS Code 编写多行提交信息。
另外,现代 Git 仓库的默认分支通常叫main而不是master。如果你的 Git 版本低于 2.28,建议执行git config --global init.defaultBranch main将本地默认分支名设为main,以便与课程中的命令保持一致(新版课程 git/intermediate_git/a_deeper_look_at_git.md 也专门用提示框强调了这一点)。
修改最近的提交:git commit --amend
假设你已经习惯了规范的提交信息、也习惯了用分支组织工作流,但人非圣贤,写代码时总会出点状况:也许你提交得太早漏了一个文件,也许某条提交信息里漏掉了关键细节。
场景:查看git status和git log,发现最后一条提交忘记包含test4.md了。我们先把漏掉的文件加入暂存区,再执行git commit --amend:
git add test4.md git commit --amend这里发生了什么?我们首先更新暂存区(staging area),把缺失的文件包含进来;然后 Git用一个新的提交整体替换掉原来的最后一条提交,新提交同时包含原来的内容与补充的文件。如果你想顺便修改这条提交的信息,只需在--amend打开的编辑器里改写消息即可,它会覆盖旧提交的消息。
重要警告:只能 amend 尚未推送到任何地方的提交!原因在于
git commit --amend并不是“编辑”最后一条提交,而是用一条全新的提交替换它。如果你 amend 了其他开发者正在基于其上工作的提交,就相当于摧毁了他们可能依赖的提交。每次改写历史时,都要确保操作方式安全,并且让协作者知晓你在做什么。
修改多个提交:git rebase -i
如果我们要修改的是更久之前的历史呢?这就轮到优雅的rebase命令出场了。先只介绍它的基本用法,更复杂的部分会在后面逐步展开。
git rebase -i(交互式 rebase)允许我们在每个想修改的提交之后停下来,然后做出任意想要的改动。我们需要告诉它“最后一个要编辑的提交”是谁,例如git rebase -i HEAD~2表示允许编辑最近的两个提交。
git log git rebase -i HEAD~2执行后会打开一个交互式编辑器,其中列出了最近两次提交。有两点值得注意:
- 交互式工具里提交的排列顺序与
git log正好相反——最早的提交排在最上面; - 每个提交前都有一个动词(默认是
pick),工具会列出全部可选操作,建议花点时间通读一遍。
常用的操作动词包括:
| 动词 | 含义 |
|---|---|
pick | 保留该提交(默认行为) |
reword | 保留提交内容,但修改提交信息 |
edit | 停下来,允许修改该提交 |
squash | 将该提交合并到上一个提交中 |
fixup | 类似 squash,但丢弃被合并提交的消息 |
drop | 删除该提交 |
如果想把某个pick改成edit,直接在对应行修改即可;想删除某个提交,就把它从列表里整行移除;想调整顺序,就调换这些行的位置。下面是一个把第二条提交改为edit的例子(注意哈希值仅作示意,实际操作时请以你本地的哈希为准,不要直接复制粘贴):
edit eacf39d Create send file pick 92ad0af Create third file and create fourth file保存并退出编辑器后,Git 会暂停在Create send file这条提交上,并给出类似下面的提示:
You can amend the commit now, with git commit --amend Once you're satisfied with your changes, run git rebase --continue于是我们依次执行:
git commit --amend # 修改提交信息,把 "send" 修正为 "second" git rebase --continue # 继续完成剩余的重放最后用git log查看成果——历史已经如你所愿地被改写了。整个过程看起来很简单,但这是一把非常危险的利器,一旦误用后果严重。最重要的是记住:如果你不得不在共享仓库中 rebase 提交,一定要有充分的理由,并且让协作者知晓。
合并提交:Squash
用squash合并提交是保持 Git 历史整洁的好方法。在某些开发团队中,squash 甚至是合并功能的默认标准流程。它的价值在于:当一个 feature 分支被合并进主分支后,main的历史往往会堆积大量“过程性”提交——这些提交在功能开发期间很重要,但对于阅读主分支整体历史的人来说并不必要。把多个提交压成一个,能让项目历史更容易被他人理解。
假设我们想把列表中的第二条提交(Create second file)合并进第一条提交(Create first file)。首先 rebase 到根提交,以便操作最底层的历史:
git rebase -i --root然后在编辑器中把第二条提交的动词改为squash,让它“压进”它上面的那条提交:
pick e30ff48 Create first file squash 92aa6f3 Create second file pick 05e5413 Create third file and create fourth file保存并退出后,编辑器会再次打开,里面是被合并的几条提交的消息。把消息改成只有一行:Create first and second file,保存退出即完成 squash 与整个 rebase。运行git log,可以看到前两条提交已经合并成了一条。
拆分提交:git reset 家族
在进入 Remotes 之前,先认识一个非常实用的命令:git reset。
场景:我们盯着Create third file and create fourth file这条提交——它一次性描述了太多内容。假如这两个文件各自包含独立功能,这条提交就应该被拆成两条更小的提交。
方法依旧是使用交互式 rebase:把要拆分的提交的动词改为edit。但与修改信息不同,这次我们要执行的是git reset HEAD^(即把 HEAD 重置到它的上一个提交),然后就可以逐个文件地add和commit了:
git reset HEAD^ git add test3.md && git commit -m 'Create third file' git add test4.md && git commit -m 'Create fourth file'(注:HEAD^与HEAD~1等价,都表示“HEAD 的父提交”。新版课程 git/intermediate_git/a_deeper_look_at_git.md 中写作git reset HEAD~,含义相同。)
这里发生了什么?git reset做了两件事:
- 移动 HEAD 指针:把当前分支的 HEAD 指向上一个提交;
- 同步更新索引(暂存区):把暂存区重置为 HEAD 新指向的提交的内容。
正因为暂存区也被一并重置了,我们才能把两个文件分开、分别提交,从而把一条大提交拆成两条小提交。
git reset还有两个重要变体:
git reset --soft:只移动 HEAD,不触碰暂存区。它只执行reset的第一部分。你可以把它理解成“更强大的 amend”——不再局限于修改最后一条提交,而是可以回退多个提交,并把它们的所有改动合并成一条新提交。git reset --hard:执行reset的全部步骤——移动 HEAD、更新索引,并且同时覆盖工作目录。它会把工作目录里的文件改写成 HEAD 最终指向位置对应的内容。这会带来潜在的数据销毁风险:硬重置会直接覆盖工作目录中的文件。与git commit --amend类似,硬重置是破坏性的、会覆盖历史的命令。这不意味着在团队共享仓库中要彻底回避它,但你必须清楚地知道自己为什么用它,并且让协作者了解你使用它的方式和原因。
与远程仓库协作:push --force 与安全替代
到目前为止,你在完成课程项目时已经通过 push/pull 与自己的 GitHub 仓库打了不少交道。本节讨论一些更进阶、你可能还没遇到过的远程操作。
为什么普通 push 会被拒绝
假设你不再独自开发一个项目,而是与别人协作。你想把改动过的分支推送到远程仓库。正常情况下,只有当你已经用远程的最新提交更新过本地分支时,Git 才会允许 push。
如果本地分支没有更新,而你要 push 的提交会在远程造成冲突,Git 会返回错误信息。这其实是件好事!这是一道安全机制:防止你覆盖协作者创建的提交(那将是灾难性的)。你收到错误,正是因为你的本地历史已经过时了。
push --force:一个危险的故事
当你搜索解决方案时,很可能会找到git push --force。这条命令会用你自己的本地历史直接覆盖远程仓库的历史。那如果我们和别人协作时用了它,会发生什么?先看看“和自己协作”时会发生什么。
执行下面的命令,当交互式 rebase 工具弹出时,删掉Create fourth file这条提交:
git push origin main git rebase -i --root git push --force git log有意思——本地已经看不到test4.md了。再去 GitHub 仓库看看,test4.md还在吗?
不在了。我们刚刚摧毁了它。这正是危险所在:你也可能就这样摧毁协作者的成果。因此git push --force是非常危险的命令,与他人协作时必须谨慎使用。
更稳妥的做法是:当遇到“历史过时”的错误时,先用fetch+merge更新本地历史,再重新push,而不是直接强制推送:
git fetch origin git merge origin/main git push origin main(顺带一提:git fetch后跟git merge本质上等价于一次git pull,课程在 git/intermediate_git/using_git_in_the_real_world.md 中专门拆开演示,是为了更明确地展示每一步在做什么。)
撤销已推送的提交:用 revert 而不是 force
再看另一个场景:
touch test4.md git add test4.md && git commit -m "Create fifth file" git push origin main git log推送后一看提交信息,糟糕,我们犯了个错误。此时你很可能又想用强制推送来“撤销”这条提交。但请再想想——这是个非常危险的命令。每次考虑使用它时,都要先检查它是否恰当、是否有更安全的替代方案。
如果与他人协作,想撤销一条刚刚推送的提交,更安全的方式是git revert:
git revert HEAD git push origin maingit revert并不会删除历史中的那条提交,而是创建一条新的提交,其内容正好反向应用 HEAD 的改动。然后把这条新提交推送到当前分支(本例中是main,实际工作中通常是 feature 分支)。由于历史是“追加”而不是“重写”,协作者可以安全地拉取,无需强制推送。
什么时候才该用 push --force
既然git push --force如此危险,它为什么还存在?什么时候该用它?
- 最常见的场景:更新 Pull Request。当你基于 review 意见不断 amend/rebase 自己的 PR 分支并希望以最新状态更新 PR 时,强制推送几乎是必须的(更深度的协作内容在 git/intermediate_git/using_git_in_the_real_world.md 等课程中另行讲解)。
- 较少见的场景:敏感信息泄漏。比如密码或密钥被意外提交到仓库,需要把所有出现过的痕迹全部清除时,可能需要改写历史并强制推送。
核心结论是:--force选项只应在你确信它恰当时使用。
更安全的选择:git push --force-with-lease
这里必须特别提一下git push --force-with-lease——它在一些公司里甚至是默认选项。原因在于它是一个故障保险(fail-safe):它会先检查你要推送的目标分支是否已被他人更新,如果更新了就直接报错,而不是盲目覆盖。这给了你一个机会,像前面提到的那样先fetch并更新本地仓库,再重新推送。
换句话说:当你确实需要强制推送时,优先使用--force-with-lease而不是--force。更详细的操作说明还可以参考仓库中专门讲解远程历史改写的 git/intermediate_git/working_with_remotes.md。
危险与最佳实践
回顾全文,你会发现一条共同的主线:amend、rebase、reset、push --force在团队协作时都格外危险——这些命令可能摧毁协作者创建的成果。因此每次想要改写历史时,都要先检查所用命令的危险性,并遵循以下最佳实践:
- 如果是团队项目,确保改写历史是安全的,并且让其他人知道你在做这件事;
- 理想情况下,只在你自己独立工作的分支上使用这些命令;
- 使用
-f标志去“强制”某件事应该让你感到警惕,你必须有一个真正充分的理由; - 不要每提交一次就 push 一次;应当尽可能避免修改已经发布(published)的历史;
- 针对本文涉及的具体命令:
- 对于
git commit --amend:永远不要 amend 已经推送到远程仓库的提交; - 对于
git rebase:永远不要 rebase 别人可能基于其上工作的仓库; - 对于
git reset:永远不要 reset 已经推送到远程仓库的提交; - 对于
git push --force:只在恰当时使用、谨慎使用,并且最好默认改用git push --force-with-lease。
- 对于
这些命令并不是禁区,它们是专业开发者日常工具箱的一部分——关键在于知道何时、为何、在哪些分支上使用。
分支是指针:理解 Git 的底层模型
虽然本课的重心是改写历史的进阶工具,但还有一个许多初学者觉得难以理解的进阶主题——指针(Pointers)。你已经知道分支保存着文件的不同“平行现实”版本(在 foundations/javascript_basics/revisiting_rock_paper_scissors.md 等课程中已有接触),现在我们来看看这在底层到底意味着什么。
提交:快照 + 指针
先聊聊提交。在基础 Git 课程(git/foundations_git/git_basics.md)里,提交被描述为快照(Snapshots)。你可以非常字面地去理解它:每次执行git commit,计算机都会对你用git add暂存的所有文件内容拍一张“照片”——换句话说,整个被跟踪的工作区都会被复制下来。
分支:指向单个提交的指针
那么分支又是什么?基于你已有的经验,你可能把分支想象成一“组”提交。这其实不对!分支实际上是指向单个提交的指针!
听到这里你可能会想:“如果分支只是指向单个提交的一根手指,那单个提交怎么知道它之前的所有提交呢?”答案非常简单:每个提交本身也是一个指针,指向它之前的那条提交。
也就是说:
- 一条提交 = 一份快照 + 一个指向其父提交的指针;
- 一个分支 = 一个指向某条特定提交的指针;
- HEAD = 一个特殊指针,用来记录你当前所在的分支,指向当前分支的最新提交。
指针如何驱动 HEAD~N
理解了这一点,回头看看本课用到的git rebase -i HEAD~3,你就能猜出 Git 是如何知道要编辑哪三条提交的了——没错,靠的正是指针链:
- 从
HEAD出发,它指向当前分支最近的那条提交; - 那条提交指向它前面的一条(我们称为 commit 2);
- commit 2 同样指向它前面的一条(commit 3)。
于是git rebase -i HEAD~3从 HEAD 指针出发,沿着指针链依次找到要编辑的三条提交。(新版课程 git/intermediate_git/a_deeper_look_at_git.md 中该示例已统一为与前面 rebase 练习一致的HEAD~2,原理完全相同。)
小结
- 分支:一个指向单个提交的指针;
- 提交:一份快照,同时是指向其前驱提交的指针;
- HEAD:记录当前所在分支的特殊指针。
仅此而已。这套“指针链”模型正是理解 rebase、reset、checkout 等一切 Git 高级操作的地基。
在仓库中的版本演进:一份可对照的“第二版”课程
值得说明的是,本篇文章依据的是归档课程 archive/ruby/git/lesson_a_deeper_look_at_git.md,而仓库中还存在一份更新版本:git/intermediate_git/a_deeper_look_at_git.md。对比两份文档,可以看到课程团队在修订时做了这些值得留意的调整:
- 编辑器配置被提升为独立的“Setting up the code editor”小节,强调
git commit --amend、git rebase -i依赖正确的编辑器配置; - 拆分提交示例由
git reset HEAD^改为git reset HEAD~,并新增“git reset --soft是更强大的 amend”这一表述; - 明确提示现代 Git 默认分支名为
main而非master; - 指针示例由
HEAD~3统一为HEAD~2,与前面的练习保持一致。
阅读时若两份文档存在出入,以更新版本 git/intermediate_git/a_deeper_look_at_git.md 为准。此外,git/intermediate_git/working_with_remotes.md 专门承接了本文“与远程协作改写历史”的部分,archive/git/git_remotes.md 则系统讲解了git remote命令与远程仓库的基础概念,可作为延伸阅读。
知识自检
请尝试回答以下问题,检验对本文的理解;答不上来就回到对应章节复习:
- 如何修改(amend)你的最后一条提交?(见修改最近的提交)
- 有哪些改写历史的不同方式?
- 如何将两条提交 squash 成一条?(见合并提交)
- 如何把一条提交拆分成两条更小的提交?(见拆分提交)
git reset、git reset --soft、git reset --hard有什么区别?- 将历史改动安全地推送到远程仓库的方式是什么?(见更安全的选择)
- 历史改写操作有哪些危险?(见危险与最佳实践)
- 历史改写操作的最佳实践是什么?
- 如何解释“分支是指针”?(见分支是指针)
练习建议
- 在真实的课程项目推进中,你迟早会遇到合并冲突(merge conflict)。冲突本身并不可怕,关键是要掌握两种解决路径:在 GitHub 网页上解决,以及在命令行本地解决。建议现在就花时间通读一遍相关内容,为将来遇到冲突时做好准备。
- 尝试用“Think Like (a) Git”的思维方式来巩固对指针模型的理解:把每次操作都还原为“指针如何移动”“提交如何被创建或替换”,你会发现 Git 的一切行为都变得可预测。
- 参考仓库内的 git/foundations_git/git_basics.md 中的命令速查表(Cheatsheet)与原子提交(atomic commit)实践,把“小而清晰的提交”与本文的“按需改写历史”结合起来,形成完整的个人工作流。
【免费下载链接】curriculumThe open curriculum for learning web development项目地址: https://gitcode.com/GitHub_Trending/cu/curriculum
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考