paint-brush
自信地重写 Git 历史:指南经过@omerosenbaum
2,019 讀數
2,019 讀數

自信地重写 Git 历史:指南

经过 Omer Rosenbaum18m2023/04/27
Read on Terminal Reader

太長; 讀書

Git 是一个用于及时记录文件系统快照的系统。 Git 存储库具有三个“状态”或“树”:索引、暂存区和工作树。工作目录(ectrory)是我们文件系统上的任何目录,它有一个与之关联的 Git 存储库。
featured image - 自信地重写 Git 历史:指南
Omer Rosenbaum HackerNoon profile picture

作为一名开发人员,您一直在使用 Git。


你有没有到过这样的地步:“呃,哦,我刚刚做了什么?”

当 Git 出现问题时,许多工程师感到束手无策(来源:XKCD)


这篇文章将为您提供自信重写历史的工具。

开始前的注意事项

  1. 我还就这篇文章的内容进行了现场演讲。如果您更喜欢视频(或希望在阅读的同时观看)——您可以找到它.


  2. 我正在写一本关于 Git 的书!您是否有兴趣阅读初始版本并提供反馈?给我发电子邮件:[email protected]

在 Git 中记录更改

在了解如何在 Git 中撤销之前,您应该首先了解我们如何在 Git 中记录更改。如果您已经了解所有术语,请随时跳过这一部分。


将 Git 视为一个用于及时记录文件系统快照的系统非常有用。考虑一个 Git 存储库,它具有三个“状态”或“树”:

Git 存储库的三棵“树”(来源:https://youtu.be/ozA1V00GIT8)


通常,当我们处理我们的源代码时,我们从一个工作目录开始工作。工作目录(电子) (或工作树)是我们的文件系统中具有与之关联的存储库的任何目录。


它包含我们项目的文件夹和文件以及一个名为.git的目录。我在中更详细地描述了.git文件夹的内容以前的帖子.


进行一些更改后,您可能希望将它们记录在您的存储库中。存储库(简称: repo )是提交的集合,每个提交都是项目工作树在过去某个日期的样子的存档,无论是在您的机器上还是在其他人的机器上。


存储库还包括我们的代码文件以外的东西,例如HEAD 、分支等。


在两者之间,我们有索引暂存区;这两个术语可以互换。当我们checkout一个分支时,Git 会用最后检出到我们工作目录中的所有文件内容以及它们最初检出时的样子填充索引。


当我们使用git commit时,提交是根据索引的状态创建的。


因此,索引暂存区是您下一次提交的游乐场。您可以使用索引做任何您想做的事情,向其中添加文件,从中删除内容,然后仅当您准备就绪时,您才能继续并提交到存储库。


是时候动手了🙌🏻


使用git init来初始化一个新的仓库。将一些文本写入名为1.txt的文件中:

启动一个新的 repo 并在其中创建第一个文件(来源:https://youtu.be/ozA1V00GIT8)


在上述三种树状态中, 1.txt现在在哪里?


在工作树中,因为它还没有被引入到索引中。

文件“1.txt”现在只是工作目录的一部分(来源:https://youtu.be/ozA1V00GIT8)


为了暂存它,将它添加到索引中,使用git add 1.txt

使用 `git add` 暂存文件,因此它现在也在索引中(来源:https://youtu.be/ozA1V00GIT8)


现在,我们可以使用git commit将我们的更改提交到存储库。

使用 `git commit` 在存储库中创建一个提交对象(来源:https://youtu.be/ozA1V00GIT8)


您创建了一个新的提交对象,其中包含一个指向描述整个工作树的树的指针。在这种情况下,它只会是根文件夹中的1.txt 。除了指向树的指针之外,提交对象还包括元数据,例如时间戳和作者信息。


有关 Git 中的对象(例如提交和树)的更多信息, 查看我之前的帖子.


(是的,“退房”,双关语😇)


Git 还告诉我们这个提交对象的 SHA-1 值。在我的例子中,它是c49f4ba (这只是 SHA-1 值的前 7 个字符,以节省一些空间)。


如果你在你的机器上运行这个命令,你会得到一个不同的 SHA-1 值,因为你是不同的作者;此外,您将在不同的时间戳上创建提交。


当我们初始化 repo 时,Git 会创建一个新分支(默认名为main )。和Git 中的分支只是对提交的命名引用.所以默认情况下,你只有main分支。如果你有多个分支会怎样? Git 如何知道哪个分支是活动分支?


Git 有另一个指针称为HEAD ,它(通常)指向一个分支,然后指向一个提交。顺便一提, 在引擎盖下, HEAD 只是一个文件。它包括带有一些前缀的分支名称。


是时候对 repo 进行更多更改了!


现在,我想创建另一个。因此,让我们创建一个新文件,并将其添加到索引中,就像以前一样:

文件 `2.txt` 位于工作目录和使用 `git add` 暂存后的索引中(来源:https://youtu.be/ozA1V00GIT8)


现在,是时候使用git commit了。重要的是, git commit做了件事:


首先,它创建一个提交对象,因此在 Git 的内部对象数据库中有一个对象具有相应的 SHA-1 值。这个新的提交对象也指向父提交。这是HEAD在您编写git commit命令时指向的提交。

首先创建了一个新的提交对象——`main` 仍然指向之前的提交(来源:https://youtu.be/ozA1V00GIT8)


其次, git commit移动活动分支的指针——在我们的例子中是main ,指向新创建的提交对象。

`git commit` 还会更新活动分支以指向新创建的提交对象(来源:https://youtu.be/ozA1V00GIT8)


撤消更改

要重写历史,让我们从撤消引入提交的过程开始。为此,我们将了解命令git reset ,一个超级强大的工具。

git reset --soft

所以你之前做的最后一步是git commit ,这实际上意味着两件事——Git 创建了一个提交对象并移动了main ,活动分支。要撤消此步骤,请使用命令git reset --soft HEAD~1


语法HEAD~1指的是HEAD的第一个父级。如果我在提交图中有多个提交,则说“提交 3”指向“提交 2”,这反过来又指向“提交 1”。


并说HEAD指向“Commit 3”。您可以使用HEAD~1来引用“Commit 2”,而HEAD~2将引用“Commit 1”。


所以,回到命令: git reset --soft HEAD~1


此命令要求 Git 更改HEAD指向的任何内容。 (注意:在下图中,我使用*HEAD表示“无论HEAD指向什么”)。在我们的示例中, HEAD指向main 。所以 Git 只会将main的指针更改为指向HEAD~1 。也就是说, main将指向“Commit 1”。


但是,此命令不会影响索引或工作树的状态。因此,如果您使用git status ,您将看到2.txt已暂存,就像您运行git commit之前一样。

将 `main` 重置为“Commit 1”(来源:https://youtu.be/ozA1V00GIT8)


git log?它将从HEAD开始,转到main ,然后到“Commit 1”。请注意,这意味着“Commit 2”不再可从我们的历史记录中访问。


这是否意味着“Commit 2”的提交对象被删除了? 🤔


不,它没有被删除。它仍然驻留在 Git 的内部对象数据库中。


如果您现在推送当前历史记录,通过使用git push ,Git 不会将“Commit 2”推送到远程服务器,但提交对象仍然存在于您的本地存储库副本中。


现在,再次提交——并使用“Commit 2.1”的提交信息来区分这个新对象和原来的“Commit 2”:

创建新提交(来源:https://youtu.be/ozA1V00GIT8)


为什么“Commit 2”和“Commit 2.1”不同?即使我们使用相同的提交消息,即使它们指向同一个树对象(由1.txt2.txt组成的根文件夹),它们仍然具有不同的时间戳,因为它们是在不同时间创建的。


在上图中,我保留了“Commit 2”来提醒你它仍然存在于 Git 的内部对象数据库中。 “Commit 2”和“Commit 2.1”现在都指向“Commit 1”,但只能从HEAD访问“Commit 2.1”。

Git 重置——混合

是时候倒退并进一步撤消了。这一次,使用git reset --mixed HEAD~1 (注意: --mixedgit reset的默认开关)。


此命令与git reset --soft HEAD~1相同。这意味着它获取HEAD现在指向的任何指针,即main分支,并将其设置为HEAD~1 ,在我们的示例中 - “Commit 1”。

`git reset --mixed` 的第一步与 `git reset --soft` 相同(来源:https://youtu.be/ozA1V00GIT8)


接下来,Git 更进一步,有效地撤消了我们对索引所做的更改。也就是说,更改索引以使其与当前HEAD匹配,即在第一步中设置后的新HEAD


如果我们运行git reset --mixed HEAD~1 ,这意味着HEAD将被设置为HEAD~1 (“Commit 1”),然后 Git 会将索引与“Commit 1”的状态匹配——在这种情况下,它意味着2.txt将不再是索引的一部分。

`git reset --mixed` 的第二步是将索引与新的 `HEAD` 匹配(来源:https://youtu.be/ozA1V00GIT8)


是时候用原始“提交 2”的状态创建一个新的提交了。这次我们需要在创建之前再次暂存2.txt

创建“Commit 2.2”(来源:https://youtu.be/ozA1V00GIT8)


Git 重置 -- 硬

继续,撤消更多!


继续运行git reset --hard HEAD~1


同样,Git 从--soft阶段开始,将HEAD指向 ( main ) 的任何内容设置为HEAD~1 (“Commit 1”)。

`git reset --hard` 的第一步与 `git reset --soft` 相同(来源:https://youtu.be/ozA1V00GIT8)


到目前为止,一切都很好。


接下来,进入--mixed阶段,将索引与HEAD匹配。也就是说,Git 撤消了2.txt的暂存。

`git reset --hard` 的第二步与 `git reset --mixed` 相同(来源:https://youtu.be/ozA1V00GIT8)


现在是--hard步骤的时候了,Git 更进一步,将工作目录与索引的阶段相匹配。在这种情况下,这意味着也从工作目录中删除2.txt

`git reset --hard` 的第三步将工作目录的状态与索引的状态相匹配(来源:https://youtu.be/ozA1V00GIT8)


(**注意:在这种特定情况下,文件未被跟踪,因此它不会从文件系统中删除;不过,为了理解git reset这并不是很重要)。


因此,要对 Git 进行更改,您需要三个步骤。您更改工作目录、索引或临时区域,然后提交包含这些更改的新快照。要撤消这些更改:


  • 如果我们使用git reset --soft ,我们撤消了提交步骤。


  • 如果我们使用git reset --mixed ,我们也会撤消暂存步骤。


  • 如果我们使用git reset --hard ,我们撤销对工作目录的更改。

真实场景!

场景#1

因此,在现实生活中,将“我爱 Git”写入文件 ( love.txt ),因为我们都爱 Git 😍。继续,暂存并提交:

创建“Commit 2.3”(来源:https://youtu.be/ozA1V00GIT8)


哦,糟糕!


其实,我不想让你犯的。


我实际上希望你做的是在提交之前在此文件中多写一些情话。

你能做什么?


好吧,克服这个问题的一种方法是使用git reset --mixed HEAD~1 ,有效地撤消您采取的提交和暂存操作:

撤消暂存和提交步骤(来源:https://youtu.be/ozA1V00GIT8)


所以main再次指向“Commit 1”, love.txt不再是索引的一部分。但是,该文件仍保留在工作目录中。您现在可以继续,并向其中添加更多内容:

添加更多爱情歌词(来源:https://youtu.be/ozA1V00GIT8)


继续,暂存并提交您的文件:

创建具有所需状态的新提交(来源:https://youtu.be/ozA1V00GIT8)


干得好👏🏻


你得到了“Commit 2.4”指向“Commit 1”的清晰、漂亮的历史记录。


现在我们的工具箱里有了一个新工具, git reset 💪🏻

git reset 现在在我们的工具箱中(来源:https://youtu.be/ozA1V00GIT8)


这个工具超级好用,你几乎可以用它完成任何事情。它并不总是使用起来最方便的工具,但如果您小心使用它,它几乎可以解决任何重写历史的场景。


对于初学者,我建议几乎任何时候你想在 Git 中撤消时只使用git reset 。一旦您对它感到满意,就可以继续使用其他工具了。

场景#2

让我们考虑另一种情况。


创建一个名为new.txt的新文件;阶段和提交:

创建 `new.txt` 和“Commit 3”(来源:https://youtu.be/ozA1V00GIT8)


哎呀。其实,这是一个错误。你在main上,我希望你在功能分支上创建这个提交。我的坏😇


我希望您从这篇文章中获得两个最重要的工具。第二个git reset 。第一个也是最重要的一个是在白板上标出当前状态与你想要进入的状态。


对于这种情况,当前状态和期望状态如下所示:

场景 #2:当前状态与期望状态(来源:https://youtu.be/ozA1V00GIT8)


你会注意到三个变化:


  1. main在当前状态下指向“Commit 3”(蓝色那个),但在期望状态下指向“Commit 2.4”。


  2. feature分支在当前状态下不存在,但它存在并指向所需状态下的“Commit 3”。


  3. HEAD在当前状态下指向main ,在期望状态下指向feature


如果你能画出这个并且你知道如何使用git reset ,你绝对可以让自己摆脱这种情况。


因此,再次重申,最重要的是深吸一口气,把它画出来。


观察上图,我们如何从当前状态到想要的状态呢?


当然有几种不同的方法,但我只会为每种情况提供一个选项。也可以随意尝试其他选项。


您可以使用git reset --soft HEAD~1开始。这会将main设置为指向之前的提交,“Commit 2.4”:

改变“主要”; “提交 3 是模糊的,因为它仍然存在,只是无法访问(来源:https://youtu.be/ozA1V00GIT8)


再次查看当前与期望图,您可以看到您需要一个新分支,对吧?您可以为它使用git switch -c featuregit checkout -b feature (做同样的事情):

创建 `feature` 分支(来源:https://youtu.be/ozA1V00GIT8)


此命令还会更新HEAD以指向新分支。


由于您使用了git reset --soft ,因此您没有更改索引,因此它当前具有您想要提交的状态——多么方便!您可以简单地提交到feature分支:

致力于 `feature` 分支(来源:https://youtu.be/ozA1V00GIT8)


你达到了想要的状态 🎉

场景#3

准备好将您的知识应用于其他案例了吗?


love.txt添加一些更改,并创建一个名为cool.txt的新文件。暂存它们并提交:

创建“Commit 4”(来源:https://youtu.be/ozA1V00GIT8)


哦,糟糕,实际上我希望你创建两个单独的提交,每个更改一个 🤦🏻

想自己试试这个吗?


您可以撤消提交和暂存步骤:

使用 `git reset --mixed HEAD~1` 撤消提交和暂存(来源:https://youtu.be/ozA1V00GIT8)


执行此命令后,索引不再包含这两个更改,但它们仍在您的文件系统中。所以现在,如果你只暂存love.txt ,你可以单独提交它,然后对cool.txt做同样的事情:

单独提交(来源:https://youtu.be/ozA1V00GIT8)


不错😎

场景#4

创建一个包含一些文本的新文件 ( new_file.txt ),并将一些文本添加到love.txt 。暂存两个更改,并提交它们:

一个新的提交(来源:https://youtu.be/ozA1V00GIT8)


哎呀🙈🙈


所以这一次,我希望它在另一个分支上,但不是一个分支,而是一个已经存在的分支。


所以,你可以做什么?


我会给你一个提示。答案非常简短而且非常简单。我们先做什么?


不,不reset 。我们画。这是要做的第一件事,因为它会让其他一切变得容易得多。所以这是当前状态:

`main` 上的新提交显示为蓝色(来源:https://youtu.be/ozA1V00GIT8)


和理想的状态?

我们希望“蓝色”提交位于另一个“现有”分支上(来源:https://youtu.be/ozA1V00GIT8)


您如何从当前状态进入所需状态,最简单的是什么?


因此,一种方法是像以前一样使用git reset ,但我希望您尝试另一种方法。


首先,移动HEAD指向existing分支:

切换到“现有”分支(来源:https://youtu.be/ozA1V00GIT8)


直觉上,您要做的是采用蓝色提交中引入的更改,并将这些更改(“复制粘贴”)应用到existing分支之上。而 Git 有一个专门用于此的工具。


要让 Git 获取此提交与其父提交之间引入的更改,并将这些更改应用到活动分支,您可以使用git cherry-pick 。此命令获取指定修订中引入的更改并将它们应用于活动提交。


它还创建一个新的提交对象,并更新活动分支以指向这个新对象。

使用 `git cherry-pick`(来源:https://youtu.be/ozA1V00GIT8)


在上面的示例中,我指定了创建的提交的 SHA-1 标识符,但您也可以使用git cherry-pick main ,因为我们正在应用其更改的提交是main指向的提交。


但是我们不希望这些更改存在于main分支上。 git cherry-pick仅将更改应用到existing分支。你怎么能从main中删除它们?


一种方法是switchmain ,然后使用git reset --hard HEAD~1

重置 `main`(来源:https://youtu.be/ozA1V00GIT8)


你做到了! 💪🏻


请注意, git cherry-pick实际上计算了指定提交与其父提交之间的差异,然后将它们应用于活动提交。这意味着有时 Git 将无法应用这些更改,因为您可能会遇到冲突,但这是另一篇文章的主题。


另外请注意,您可以要求 Git cherry-pick任何提交中引入的更改,而不仅仅是分支引用的提交。


我们获得了一个新工具,因此我们拥有git resetgit cherry-pick

场景#5

好的,改天,另一个回购协议,另一个问题。


创建一个提交:

另一个提交(来源:https://youtu.be/ozA1V00GIT8)


并将其push送到远程服务器:

(来源:https://youtu.be/ozA1V00GIT8)


嗯,哎呀😓…


我刚注意到一件事。那里有错字。我写了This is more tezt而不是This is more text 。哎呀。那么现在最大的问题是什么?我push ed,这意味着其他人可能已经pull了这些更改。


如果我使用git reset覆盖这些更改,正如我们目前所做的那样,我们将拥有不同的历史记录,一切都可能一团糟。您可以根据需要重写自己的回购副本,直到您push它为止。


一旦你push更改,如果你要重写历史,你需要非常确定没有其他人获取这些更改。


或者,您可以使用另一个名为git revert的工具。此命令获取您提供给它的提交并从其父提交计算差异,就像git cherry-pick一样,但这次,它计算反向更改。


因此,如果您在指定的提交中添加了一行,则反向将删除该行,反之亦然。

使用 `git revert` 撤消更改(来源:https://youtu.be/ozA1V00GIT8)


git revert创建了一个新的提交对象,这意味着它是对历史记录的补充。通过使用git revert ,您没有重写历史。你承认了你过去的错误,这次提交是承认你犯了一个错误,现在你修正了它。


有人会说这是更成熟的方式。有人会说,如果您使用git reset重写以前的提交,那么历史记录不会那么干净。但这是避免改写历史的一种方式。


您现在可以修复拼写错误并再次提交:

重做更改(来源:https://youtu.be/ozA1V00GIT8)


您的工具箱现在加载了一个新的闪亮工具, revert

我们的工具箱(来源:https://youtu.be/ozA1V00GIT8)


场景 #6

完成一些工作,编写一些代码,然后将其添加到love.txt中。暂存此更改并提交:

另一个提交(来源:https://youtu.be/ozA1V00GIT8)


我在我的机器上做了同样的事情,我使用键盘上的Up箭头键滚动回到之前的命令,然后我按下Enter ,然后……哇。


哎呀。

我只是 `git reset -- hard` 了吗? (来源:https://youtu.be/ozA1V00GIT8)


我只是使用git reset --hard吗? 😨


到底发生了什么? Git 将指针移至HEAD~1 ,因此无法从当前历史记录中访问我所有宝贵工作的最后一次提交。 Git 还取消暂存区中的所有更改,然后将工作目录与暂存区的状态相匹配。


也就是说,一切都符合我的工作……消失的状态。


抓紧时间。吓坏了。

但是,真的,有理由惊慌失措吗?不是真的……我们是放松的人。我们做什么?好吧,直觉上,提交真的真的消失了吗?不,为什么不呢?它仍然存在于 Git 的内部数据库中。


如果我只知道它在哪里,我就会知道标识此提交的 SHA-1 值,我们就可以恢复它。我什至可以撤消撤消,然后reset回此提交。


所以我真正需要的是“已删除”提交的 SHA-1。


所以问题是,我如何找到它? git log有用吗?


好吧,不是真的。 git log会转到HEAD ,它指向main ,它指向我们正在寻找的提交的父提交。然后, git log将通过父链追溯,其中不包括我宝贵工作的提交。

`git log` 在这种情况下没有帮助(来源:https://youtu.be/ozA1V00GIT8)


值得庆幸的是,创建 Git 的非常聪明的人还为我们创建了一个备份计划,称为reflog


当您使用 Git 时,每当您更改HEAD时,您可以使用git reset以及其他命令(如git switchgit checkout ,Git 都会向reflog添加一个条目。


`git reflog` 向我们展示了 `HEAD` 的位置(来源:https://youtu.be/ozA1V00GIT8)


我们找到了我们的承诺!它是以0fb929e开头的。


我们还可以通过它的“昵称”—— HEAD@{1}来联系它。因此,例如 Git 使用HEAD~1来获取HEAD的第一个父级,并HEAD~2来引用HEAD的第二个父级等等,Git 使用HEAD@{1}来引用HEAD的第一个 reflog 父级, HEAD在上一步中指向的位置。


我们还可以要求git rev-parse向我们展示它的价值:

(来源:https://youtu.be/ozA1V00GIT8)


另一种查看reflog方法是使用git log -g ,它要求git log实际考虑reflog

`git log -g` 的输出(来源:https://youtu.be/ozA1V00GIT8)


我们在上面看到reflogHEAD一样指向main ,后者指向“Commit 2”。但是reflog中该条目的父项指向“Commit 3”。


因此,要返回“Commit 3”,您只需使用git reset --hard HEAD@{1} (或“Commit 3”的 SHA-1 值):

(来源:https://youtu.be/ozA1V00GIT8)


现在,如果我们git log

我们的历史回来了!!! (来源:https://youtu.be/ozA1V00GIT8)


我们挽救了局面! 🎉👏🏻


如果我再次使用这个命令会发生什么?并运行git commit --reset HEAD@{1} ? Git 会将HEAD设置为HEAD在上次reset之前指向的位置,意思是“提交 2”。我们可以继续一整天:

(来源:https://youtu.be/ozA1V00GIT8)


现在看看我们的工具箱,里面装满了可以帮助您解决 Git 中出现问题的许多情况的工具:

我们的工具箱非常丰富! (来源:https://youtu.be/ozA1V00GIT8)


使用这些工具,您现在可以更好地了解 Git 的工作原理。有更多的工具可以让你专门重写历史, git rebase ),但你已经在这篇文章中学到了很多东西。在以后的帖子中,我也会深入研究git rebase


最重要的工具,甚至比此工具箱中列出的五个工具更重要的是,将当前情况与所需情况进行白板比较。相信我,这会让每一种情况看起来都不那么令人生畏,解决方案也会更加清晰。

了解更多关于 Git 的信息

我还就这篇文章的内容进行了现场演讲。如果您更喜欢视频(或希望在阅读的同时观看)——您可以找到它.


一般来说,我的 YouTube 频道涵盖了 Git 及其内部的许多方面;欢迎你一探究竟(双关语😇)

关于作者

奥马尔·罗森鲍姆是 CTO 和联合创始人游泳,一种开发工具,可帮助开发人员及其团队使用最新的内部文档管理有关其代码库的知识。 Omer 是 Check Point 安全学院的创始人,并且是 ITC 的网络安全负责人,ITC 是一家培训有才华的专业人员以发展技术职业的教育机构。


Omer 拥有特拉维夫大学的语言学硕士学位,并且是简短的 YouTube 频道.


首次发表于此