Git 回滚 Commit:你需要知道的一切 – wiki基地


Git 回滚 Commit:你需要知道的一切

在软件开发的征程中,Git 已经成为不可或缺的版本控制工具。它强大、灵活,但也带来了其复杂性。开发者在日常工作中,难免会遇到需要撤销或修改提交(Commit)的情况。可能是因为引入了 Bug,可能是因为提交了错误的文件,或是仅仅改变了主意。这时,Git 的“回滚”或者说撤销提交功能就显得尤为重要。

然而,“回滚”在 Git 中并非一个单一的操作,它根据你想要达到的效果、提交所处的状态(是否已推送)、以及你对历史记录的处理方式,有多种不同的实现手段。理解这些手段的差异、何时使用它们、以及它们带来的影响,是每一个 Git 用户进阶的必修课。

本文将深入探讨 Git 中回滚提交的各种方法,包括最常用的 git revertgit reset,以及相关的辅助工具,帮助你全面掌握 Git 的撤销艺术。

1. 理解 Git 的历史记录模型

在深入探讨回滚之前,我们需要先理解 Git 是如何记录历史的。这有助于我们理解为什么不同的回滚方法会有不同的效果。

Git 的历史记录不是一系列的差异(Diff),而是一系列相互关联的快照(Snapshot)。每一次提交(Commit)都是整个项目在某个特定时间点的一个完整快照(虽然 Git 在存储时会进行优化,但概念上是这样)。

每一个 Commit 对象都包含:
* 一个指向项目文件和目录结构的树对象(Tree)。
* 一个指向上一个 Commit 的指针(Parent Commit)。对于初始提交,没有父指针;对于合并提交(Merge Commit),有两个或多个父指针。
* 作者、提交者、时间戳信息。
* 提交信息(Commit Message)。

通过父指针,Commit 形成了一个有向无环图(DAG),代表了项目的历史演变路径。分支(Branch)本质上只是一个指向某个特定 Commit 的可变指针。HEAD 是一个特殊的指针,它指向你当前工作的分支的最新 Commit(或者直接指向一个特定的 Commit,称为“分离头指针”)。

当我们在 Git 中“回滚”一个提交时,我们并不是真的删除它(除非进行垃圾回收),而是在这个不可变的快照链条上进行操作,比如添加一个新的反向操作快照 (revert),或者移动分支指针到链条的某个位置 (reset)。

2. 核心回滚方法:git revert vs git reset

这是 Git 中最主要、也是最容易混淆的两种回滚命令。它们都能“撤销”某个提交的影响,但它们的工作原理和带来的后果却截然不同。

2.1 git revert: 创建一个新的提交来撤销更改

git revert 是 Git 中“安全”的回滚方式,尤其适用于已经分享(推送)到远程仓库的提交。

工作原理:

git revert <commit> 命令不会移除指定的 <commit> 及其之后的任何提交。相反,它会创建一个新的提交。这个新的提交会应用一系列反向的更改,这些更改恰好抵消了目标 <commit> 所引入的变化。从效果上看,就像是“撤销”了目标提交,但通过添加新的历史记录来实现,而不是修改已有的历史记录。

为什么要用 git revert

  • 不修改历史: 这是它最大的优点。由于 git revert 只是在历史记录的末尾添加了一个新的提交,它不会改变任何现有的提交对象。这意味着你可以在任何时候、在任何分支上安全地使用它,包括那些已经被推送到共享仓库的提交。
  • 协作友好: 当你在一个多人协作的分支上工作时,修改(重写)历史记录可能会给其他协作者带来麻烦(他们需要执行额外的操作来同步)。git revert 保持了历史的完整性,避免了这些问题。
  • 可追溯性: 你可以清楚地看到“某个提交被回滚了”这一事件本身,这提高了历史记录的可读性和可追溯性。

何时使用 git revert

  • 当你需要撤销一个已经推送到远程仓库(或共享给他人)的提交时。
  • 当你希望保留完整的历史记录,即使是错误的提交。
  • 当你只想撤销历史中某个特定提交的更改,而不影响它之后的其他提交。

基本用法:

bash
git revert <commit-hash>

这会创建一个新的提交,撤销 <commit-hash> 所引入的更改。Git 会打开你的编辑器,让你输入这个回滚提交的信息(默认信息会说明是哪个提交被回滚了)。保存并关闭编辑器后,新的回滚提交就会被添加到当前分支的 HEAD。

常用选项:

  • -n--no-commit: 执行回滚操作,但不自动创建提交。这会将撤销的更改应用到你的暂存区(Index)和工作目录(Working Directory)。你可以检查这些更改,进行额外的修改,然后手动提交。这对于批量回滚多个提交后只创建一个汇总提交非常有用。
    bash
    git revert <commit1> <commit2> --no-commit
    # 检查并修改文件
    git commit -m "Revert changes from commit1 and commit2"
  • -e--edit: 这是默认行为,允许你在提交前编辑回滚提交信息。
  • --continue: 在解决回滚过程中发生的冲突后,继续回滚操作。
  • --abort: 在回滚过程中发生冲突后,放弃回滚操作,回到回滚前的状态。
  • -m <parent-number>: 用于回滚合并提交。合并提交有两个或多个父提交。Git 需要知道你是想撤销合并提交引入的更改,使得代码回到哪个父提交的状态。<parent-number> 通常是 1(对应 HEAD 所在的分支)或 2(对应被合并的分支)。例如,git revert -m 1 <merge-commit-hash> 表示撤销合并,保留当前分支的修改,丢弃被合并分支的修改。

处理冲突:

就像合并(merge)或变基(rebase)一样,git revert 也可能导致冲突。如果回滚的提交更改了文件的一部分,而之后的其他提交又修改了同一部分,Git 将无法自动应用反向更改,从而发生冲突。

解决冲突的步骤:
1. 运行 git revert <commit-hash>
2. Git 提示发生冲突。
3. 使用 git status 查看冲突文件。
4. 手动编辑冲突文件,解决冲突标记(<<<<<<<, =======, >>>>>>>)。
5. 使用 git add <conflicted-file> 将解决后的文件添加到暂存区。
6. 使用 git revert --continue 完成回滚过程,Git 会自动创建一个新的提交。
7. 如果想放弃,使用 git revert --abort

优点总结:

  • 安全,不修改历史。
  • 适用于共享分支。
  • 操作可追溯。

缺点总结:

  • 会引入新的提交,可能使历史记录显得有些“冗余”,特别是回滚多个提交时。
  • 如果回滚后又决定取消回滚,你需要再次进行回滚操作(即回滚那个回滚提交),这会引入更多的提交。

2.2 git reset: 移动分支指针,重写历史

git reset 命令主要用于修改当前分支 HEAD 指向的 Commit,从而“撤销”一个或多个最近的提交。与 revert 不同,reset修改历史的命令(至少是本地历史)。

工作原理:

git reset <commit> 命令会将当前分支的指针移动到指定的 <commit>。这 Effectively 丢弃了 <commit> 之后的所有提交。此外,git reset 还会影响你的暂存区(Index)工作目录(Working Directory),具体影响取决于你使用的模式(--soft, --mixed, --hard)。

为什么要用 git reset

  • 清理本地历史: 当你提交了一些本地的、还没有推送的提交,但你觉得这些提交有问题或者想把它们合并成一个时,reset 非常有用。
  • 丢弃未提交的更改: 可以用来快速丢弃暂存区或工作目录中的所有未提交更改。
  • 简单撤销最近的提交: 如果你刚刚提交了一个错误,并且这个提交还没有推送到远程,reset 是最简单、最直接的撤销方法。

何时使用 git reset

  • 当你需要撤销尚未推送到远程仓库的本地提交时。
  • 当你想要丢弃工作目录或暂存区的更改时。
  • 当你想要合并多个最近的提交为一个(配合 --soft--mixed)。

基本用法及模式:

git reset 命令最关键的部分在于其不同的模式,它们决定了 reset 如何影响暂存区和工作目录:

  1. git reset --soft <commit>:

    • 效果: 将当前分支的 HEAD 指针移动到指定的 <commit>
    • 暂存区(Index): 不变。保留了 HEAD 移动前(即你reset之前)的暂存区状态。这意味着 HEAD 移动后被“丢弃”的那些提交所引入的更改,现在会全部位于你的暂存区中,标记为待提交状态。
    • 工作目录(Working Directory): 不变。保留了 HEAD 移动前的工作目录状态。
    • 用例: 你提交了一些东西,现在想撤销最后的几个提交,但保留这些提交的更改,以便重新组织或合并成一个新的提交。例如,git reset --soft HEAD~3 会回退到当前 HEAD 前三个提交,但保留这三个提交的所有更改在暂存区,你可以 git commit 重新提交为一个。
  2. git reset --mixed <commit>:

    • 效果: 将当前分支的 HEAD 指针移动到指定的 <commit>
    • 暂存区(Index): 重置。将暂存区的内容重置为与新的 HEAD(即 <commit>)相同。这意味着 HEAD 移动后被“丢弃”的那些提交所引入的更改,将从暂存区移除,但会保留在工作目录中。
    • 工作目录(Working Directory): 不变。保留了 HEAD 移动前的工作目录状态。
    • 用例: 这是 git reset默认模式。当你运行 git reset <commit> 而不指定模式时,就是 --mixed。它常用于撤销最近的提交,并将这些更改放回工作目录,以便重新编辑和暂存。例如,git reset HEAD~1 会撤销最近一次提交,将文件更改移回工作目录,但取消暂存。
  3. git reset --hard <commit>:

    • 效果: 将当前分支的 HEAD 指针移动到指定的 <commit>
    • 暂存区(Index): 重置。将暂存区的内容重置为与新的 HEAD(即 <commit>)相同。
    • 工作目录(Working Directory): 重置。将工作目录的内容也重置为与新的 HEAD(即 <commit>)相同。这将永久丢失所有未提交(包括已暂存和未暂存)的更改以及 HEAD 移动后被“丢弃”的提交所引入的更改!
    • 用例: 你想彻底放弃最近的提交以及所有未提交的更改,让你的工作区和暂存区与历史上的某个特定提交完全一致。这是一个危险的命令,请慎用!例如,git reset --hard origin/main 会让你的本地分支与远程的 main 分支完全同步,丢弃所有本地独有的提交和未提交更改。git reset --hard HEAD~1 会彻底丢弃最近一次提交及其所有更改。

特殊用法:撤销暂存区和工作目录的更改

  • git reset HEAD <file>git restore --staged <file>: 撤销对特定文件 file 的暂存(从暂存区移到工作目录)。保留工作目录的修改。
  • git reset --hard HEAD: 丢弃所有暂存区和工作目录的未提交更改,让它们与最新的 Commit 完全一致。这是另一个危险的命令!
  • git restore <file>: 撤销对特定文件 file 在工作目录的修改,使其恢复到暂存区中的状态(如果暂存区是空的,则恢复到 HEAD 的状态)。
  • git restore .: 撤销工作目录中所有文件的修改,使其恢复到暂存区的状态。

推送问题:

由于 git reset 重写了历史记录,如果回退的提交已经被推送到了远程仓库,直接 git push 会因为远程仓库的历史与你的本地历史不一致而被拒绝(Non-fast-forward update)。在这种情况下,你需要使用 git push --forcegit push --force-with-lease 来强制推送你的新的历史记录。

警告:强制推送会覆盖远程仓库的历史,这会影响到所有使用这个分支的协作者。 只有在你 确定 没有其他人基于你回退的提交工作,或者你已经与其他协作者协调好的情况下,才应该强制推送。在共享分支上,通常强烈不推荐使用 git reset 并强制推送。

优点总结:

  • 简洁,直接移除历史记录(本地)。
  • 可以用来精细控制暂存区和工作目录的状态。
  • 是清理本地开发历史的强大工具。

缺点总结:

  • 修改历史,对共享分支不安全。
  • --hard 模式会永久丢失未提交更改。
  • 使用不当容易导致数据丢失或协作问题。

3. git revert 和 git reset 的对比总结

特性 git revert <commit> git reset <commit>
工作原理 创建一个新提交,撤销指定提交的更改。 移动分支指针到指定提交,丢弃后续提交。
对历史的影响 添加新的历史记录,不修改现有记录。 重写(移除)指定提交之后的历史记录。
安全性 安全,尤其对于已推送的提交。 不安全(对于已推送的提交),需要强制推送。
协作影响 协作友好,不影响协作者同步历史。 对协作者不友好,可能需要强制同步。
提交数量 通常会增加一个提交(或使用 --no-commit)。 通常会减少提交数量。
使用场景 撤销已推送的提交;保留完整历史;只撤销特定提交。 撤销本地未推送的提交;清理本地历史;合并本地小提交;丢弃未提交更改。
对 Index/Working Dir 的影响 应用反向修改,可能产生冲突,需手动提交(或 --no-commit)。 取决于模式 (--soft/--mixed/--hard):
--soft: Index/WD 不变
--mixed: Index 重置,WD 不变
--hard: Index/WD 都重置
是否需要强制推送 通常不需要。 通常需要(如果回退的提交已推送)。

核心原则:

  • 黄金法则: 永远不要在已经推送到共享仓库的提交上使用 git reset <commit> 然后强制推送,除非你非常清楚你在做什么,并且已经与其他协作者沟通协调好。在共享分支上,回滚已推送的提交总是优先使用 git revert
  • git reset 主要用于处理本地、私有的历史或工作区状态。

4. 回滚合并提交 (Reverting Merge Commits)

回滚合并提交是一个比较特殊且容易出错的场景。合并提交有两个(或多个)父节点,它将来自不同分支的修改整合在一起。

当你想回滚一个合并提交时,你需要告诉 Git,你希望撤销合并带来的更改,使得分支状态看起来像是没有进行这次合并,并且代码回到合并前的某个状态。由于合并通常整合了两个分支的修改,你需要指定你希望回退到哪个父分支的状态。

git revert -m <parent-number> <merge-commit-hash>

<parent-number> 指的是合并提交信息中 Merge: 后面列出的父提交的顺序(从 1 开始)。通常,第一个父节点 (-m 1) 是执行 git merge 命令时所在的分支(接收合并的分支),第二个父节点 (-m 2) 是被合并的分支。

示例:

假设你在 main 分支上,执行 git merge feature/abc,生成一个合并提交 M。这个合并提交的第一个父节点是合并前的 main 分支的 HEAD,第二个父节点是 feature/abc 分支的 HEAD。

  • git revert -m 1 M: 创建一个提交,撤销合并 M 的更改,使代码状态回到合并前 main 分支的样子。这会“取消”来自 feature/abc 分支的修改。
  • git revert -m 2 M: 创建一个提交,撤销合并 M 的更改,使代码状态回到合并前 feature/abc 分支的样子。这会“取消”来自 main 分支在合并前后的修改,保留 feature/abc 的修改。注意: 这个操作可能不太直观,因为它实际上是取消了所有“非 -m 2 父节点”引入的修改。在大多数情况下,你可能更希望回到合并前 main 的状态,所以 -m 1 更常用。

重要注意事项:

回滚合并提交后,不要尝试再次合并同一个分支。因为 Git 会认为这个分支(feature/abc)的修改已经在之前的合并中引入,并在随后的回滚中被撤销。如果你再次合并 feature/abc,Git 可能会认为没有任何新的修改需要合并,或者产生复杂的冲突。

如果你回滚了一个合并,之后又想重新引入被回滚的那个分支的修改,正确的做法是:
1. 回滚那个回滚合并的提交 (git revert <revert-commit-hash>),这会重新应用原合并的修改。
2. 或者,更好地方法通常是创建一个新的分支,从原合并点之后开始,cherry-pickfeature/abc 分支上那些你想重新引入的提交,然后合并这个新分支。

回滚合并是一个相对复杂的操作,理解 -m 参数的含义至关重要。

5. 回滚的“后悔药”:git reflog

如果你不小心使用了 git reset --hard,丢弃了重要的本地提交或未暂存的更改,别慌!Git 通常不会立即删除这些对象,你可以通过 git reflog 命令找回它们。

git reflog 是什么?

git reflog(Reference Log)是 Git 记录的一个本地日志。它跟踪了 HEAD 和其他分支指针在你的本地仓库中移动过的每一个位置。每一次提交、分支切换、合并、变基,甚至是 git reset 操作,都会在 reflog 中留下记录。

你可以把它看作是你的 Git 命令执行历史,记录了你的 HEAD 指向过的所有 Commit。

如何使用 git reflog 找回丢失的提交?

  1. 运行 git reflog
    bash
    git reflog

    你会看到类似这样的输出:
    a1b2c3d HEAD@{0}: commit (amend): fixup: add feature X
    e4f5g6h HEAD@{1}: commit: add feature X
    i7j8k9l HEAD@{2}: reset: moving to HEAD~1
    m0n1o2p HEAD@{3}: checkout: moving from feature/abc to main
    q3r4s5t HEAD@{4}: commit (merge): Merge branch 'feature/abc'
    ...

    每一行代表 HEAD 曾经指向的一个状态。HEAD@{n} 表示 n 个操作之前的 HEAD 状态。前面的哈希值是当时 HEAD 指向的 Commit 的完整或部分哈希。

  2. 找到你丢失的提交:浏览 reflog 输出,根据提交信息或操作类型 (commit, merge, reset, checkout 等) 找到你误操作之前,包含你想要找回的提交的那个状态。记下那个状态对应的 Commit 哈希(或 HEAD@{n} 引用)。例如,如果你不小心 reset --hard HEAD~3 丢弃了最近的 3 个提交,你可以找到执行 reset 命令之前那个 commitmerge 操作对应的哈希。

  3. 恢复提交:一旦找到了丢失的提交的哈希(假设是 e4f5g6h),你可以使用 git resetgit cherry-pick 来恢复它。

    • 使用 git reset 恢复整个分支状态: 如果你想回到丢失提交时的整个分支状态,可以使用 git reset --hard <commit-hash>git reset --hard HEAD@{n}。例如,git reset --hard e4f5g6hgit reset --hard HEAD@{1}注意: 再次使用 --hard 会丢弃当前工作区和暂存区的更改。
    • 使用 git cherry-pick 恢复单个提交: 如果你只想将丢失的某个特定提交重新应用到当前分支,可以使用 git cherry-pick <commit-hash>。例如,git cherry-pick e4f5g6h 会将 e4f5g6h 这个提交的更改作为新的提交应用到当前分支。

git reflog 是一个非常重要的安全网,了解并知道如何使用它,可以让你在使用像 git reset --hard 这样有风险的命令时更加安心。

6. 其他相关的撤销操作

除了 revertreset,Git 还提供了其他一些用于撤销不同粒度更改的命令。虽然它们不直接用于回滚“历史上的某个 Commit”,但它们与“撤销”的概念紧密相关。

  • 撤销工作目录的修改 (git restore)
    • git restore <file>: 放弃工作目录中对特定文件 <file> 的所有修改,使其恢复到暂存区或 HEAD(如果暂存区为空)的状态。
    • git restore .: 放弃工作目录中所有文件的修改。
  • 撤销暂存区的修改 (git restore --staged)
    • git restore --staged <file>git reset HEAD <file>: 将特定文件 <file> 从暂存区移除,放回工作目录。保留工作目录的修改。
    • git restore --staged .git reset HEAD .: 将所有文件从暂存区移除,放回工作目录。
  • 丢弃未跟踪的文件 (git clean)
    • git clean -n: 预演(Dry Run),显示哪些文件会被删除。
    • git clean -f: 强制删除工作目录中所有未跟踪的文件(Untracked files)。
    • git clean -fd: 强制删除未跟踪的文件和未跟踪的目录。
  • 修改最新的提交 (git commit --amend)
    • git commit --amend: 修改最近一次提交。你可以添加新的暂存文件、移除已暂存文件、或者修改提交信息。修改后的提交会替换原有的最新提交(拥有新的哈希值)。注意: 如果最新的提交已经推送,不要对其使用 --amend,否则会重写已共享的历史。

这些命令用于处理更“本地”和更“即时”的撤销需求,它们不会像 revertreset 那样直接操作历史上的 Commit。

7. 最佳实践与注意事项

  • 在共享分支上,优先使用 git revert 这是黄金法则,可以避免给团队成员带来同步历史的麻烦。
  • 谨慎使用 git reset --hard 这是一个强大的命令,会永久丢失未提交的更改。在使用前请三思,并确认你不需要那些更改。如果需要,先备份。
  • 理解 git reset 的三个模式。 soft, mixed, hard 的区别至关重要,它们决定了暂存区和工作目录的状态。
  • 了解 git reflog 这是你的救命稻草,可以帮助你从错误的 reset 或其他操作中恢复丢失的提交。
  • 避免在已推送的提交上使用 git reset 并强制推送。 如果你必须这样做(例如清理私人 feature 分支的历史),请确保你理解其影响,并与团队成员沟通。
  • 理解回滚合并提交的复杂性。 特别是避免在回滚合并后简单地再次合并同一个分支。
  • 经常使用 git status 在执行回滚或撤销操作前后,使用 git status 检查当前仓库的状态(工作目录是否干净、暂存区有什么、HEAD 指向哪里),这有助于你理解命令的效果并避免意外。
  • 在不确定时,先进行预演或备份。 例如,对于 git clean 可以先用 -n 选项。对于复杂的 reset 操作,可以先创建一个临时分支指向当前 HEAD 进行备份。

8. 总结

Git 的回滚功能是其强大之处的体现,但也需要深入理解才能安全有效地使用。

  • git revert 是用于撤销已共享提交的首选方式,它通过创建一个新的提交来撤销更改,不修改历史,安全友好。
  • git reset 是用于撤销本地未推送提交或管理工作区/暂存区的强大工具。它通过移动分支指针来重写历史,具有 --soft, --mixed, --hard 三种模式,其中 --hard 模式具有破坏性。

理解这两种命令的根本差异——revert 是添加历史,reset 是修改历史——是掌握 Git 回滚的关键。

同时,git reflog 为意外情况提供了重要的恢复机制,而 git restore, git clean, git commit --amend 等命令则处理更局部或更即时的撤销需求。

通过熟练掌握这些工具,并遵循在共享历史上的安全原则,你可以自信地在 Git 中穿梭,修正错误,管理好你的项目历史。记住,多实践,多查阅文档,你对 Git 的理解就会越来越深入。

希望这篇详细的文章能够帮助你全面了解 Git 的提交回滚机制,成为更高效的 Git 用户!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部