作为一名开发人员,您一直在使用 Git。
你有没有到过这样的地步:“呃,哦,我刚刚做了什么?”
这篇文章将为您提供自信重写历史的工具。
我还就这篇文章的内容进行了现场演讲。如果您更喜欢视频(或希望在阅读的同时观看)——您可以找到它
我正在写一本关于 Git 的书!您是否有兴趣阅读初始版本并提供反馈?给我发电子邮件:[email protected]
在了解如何在 Git 中撤销之前,您应该首先了解我们如何在 Git 中记录更改。如果您已经了解所有术语,请随时跳过这一部分。
将 Git 视为一个用于及时记录文件系统快照的系统非常有用。考虑一个 Git 存储库,它具有三个“状态”或“树”:
通常,当我们处理我们的源代码时,我们从一个工作目录开始工作。工作目录(电子) (或工作树)是我们的文件系统中具有与之关联的存储库的任何目录。
它包含我们项目的文件夹和文件以及一个名为.git
的目录。我在中更详细地描述了.git
文件夹的内容
进行一些更改后,您可能希望将它们记录在您的存储库中。存储库(简称: repo )是提交的集合,每个提交都是项目工作树在过去某个日期的样子的存档,无论是在您的机器上还是在其他人的机器上。
存储库还包括我们的代码文件以外的东西,例如HEAD
、分支等。
在两者之间,我们有索引或暂存区;这两个术语可以互换。当我们checkout
一个分支时,Git 会用最后检出到我们工作目录中的所有文件内容以及它们最初检出时的样子填充索引。
当我们使用git commit
时,提交是根据索引的状态创建的。
因此,索引或暂存区是您下一次提交的游乐场。您可以使用索引做任何您想做的事情,向其中添加文件,从中删除内容,然后仅当您准备就绪时,您才能继续并提交到存储库。
是时候动手了🙌🏻
使用git init
来初始化一个新的仓库。将一些文本写入名为1.txt
的文件中:
在上述三种树状态中, 1.txt
现在在哪里?
在工作树中,因为它还没有被引入到索引中。
为了暂存它,将它添加到索引中,使用git add 1.txt
。
现在,我们可以使用git commit
将我们的更改提交到存储库。
您创建了一个新的提交对象,其中包含一个指向描述整个工作树的树的指针。在这种情况下,它只会是根文件夹中的1.txt
。除了指向树的指针之外,提交对象还包括元数据,例如时间戳和作者信息。
有关 Git 中的对象(例如提交和树)的更多信息,
(是的,“退房”,双关语😇)
Git 还告诉我们这个提交对象的 SHA-1 值。在我的例子中,它是c49f4ba
(这只是 SHA-1 值的前 7 个字符,以节省一些空间)。
如果你在你的机器上运行这个命令,你会得到一个不同的 SHA-1 值,因为你是不同的作者;此外,您将在不同的时间戳上创建提交。
当我们初始化 repo 时,Git 会创建一个新分支(默认名为main
)。和main
分支。如果你有多个分支会怎样? Git 如何知道哪个分支是活动分支?
Git 有另一个指针称为HEAD
,它(通常)指向一个分支,然后指向一个提交。顺便一提,HEAD
是时候对 repo 进行更多更改了!
现在,我想创建另一个。因此,让我们创建一个新文件,并将其添加到索引中,就像以前一样:
现在,是时候使用git commit
了。重要的是, git commit
做了两件事:
首先,它创建一个提交对象,因此在 Git 的内部对象数据库中有一个对象具有相应的 SHA-1 值。这个新的提交对象也指向父提交。这是HEAD
在您编写git commit
命令时指向的提交。
其次, git commit
移动活动分支的指针——在我们的例子中是main
,指向新创建的提交对象。
要重写历史,让我们从撤消引入提交的过程开始。为此,我们将了解命令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
之前一样。
git log?
它将从HEAD
开始,转到main
,然后到“Commit 1”。请注意,这意味着“Commit 2”不再可从我们的历史记录中访问。
这是否意味着“Commit 2”的提交对象被删除了? 🤔
不,它没有被删除。它仍然驻留在 Git 的内部对象数据库中。
如果您现在推送当前历史记录,通过使用git push
,Git 不会将“Commit 2”推送到远程服务器,但提交对象仍然存在于您的本地存储库副本中。
现在,再次提交——并使用“Commit 2.1”的提交信息来区分这个新对象和原来的“Commit 2”:
为什么“Commit 2”和“Commit 2.1”不同?即使我们使用相同的提交消息,即使它们指向1.txt
和2.txt
组成的根文件夹),它们仍然具有不同的时间戳,因为它们是在不同时间创建的。
在上图中,我保留了“Commit 2”来提醒你它仍然存在于 Git 的内部对象数据库中。 “Commit 2”和“Commit 2.1”现在都指向“Commit 1”,但只能从HEAD
访问“Commit 2.1”。
是时候倒退并进一步撤消了。这一次,使用git reset --mixed HEAD~1
(注意: --mixed
是git reset
的默认开关)。
此命令与git reset --soft HEAD~1
相同。这意味着它获取HEAD
现在指向的任何指针,即main
分支,并将其设置为HEAD~1
,在我们的示例中 - “Commit 1”。
接下来,Git 更进一步,有效地撤消了我们对索引所做的更改。也就是说,更改索引以使其与当前HEAD
匹配,即在第一步中设置后的新HEAD
。
如果我们运行git reset --mixed HEAD~1
,这意味着HEAD
将被设置为HEAD~1
(“Commit 1”),然后 Git 会将索引与“Commit 1”的状态匹配——在这种情况下,它意味着2.txt
将不再是索引的一部分。
是时候用原始“提交 2”的状态创建一个新的提交了。这次我们需要在创建之前再次暂存2.txt
:
继续,撤消更多!
继续运行git reset --hard HEAD~1
同样,Git 从--soft
阶段开始,将HEAD
指向 ( main
) 的任何内容设置为HEAD~1
(“Commit 1”)。
到目前为止,一切都很好。
接下来,进入--mixed
阶段,将索引与HEAD
匹配。也就是说,Git 撤消了2.txt
的暂存。
现在是--hard
步骤的时候了,Git 更进一步,将工作目录与索引的阶段相匹配。在这种情况下,这意味着也从工作目录中删除2.txt
。
(**注意:在这种特定情况下,文件未被跟踪,因此它不会从文件系统中删除;不过,为了理解git reset
这并不是很重要)。
因此,要对 Git 进行更改,您需要三个步骤。您更改工作目录、索引或临时区域,然后提交包含这些更改的新快照。要撤消这些更改:
git reset --soft
,我们撤消了提交步骤。
git reset --mixed
,我们也会撤消暂存步骤。
git reset --hard
,我们撤销对工作目录的更改。因此,在现实生活中,将“我爱 Git”写入文件 ( love.txt
),因为我们都爱 Git 😍。继续,暂存并提交:
哦,糟糕!
其实,我不想让你犯的。
我实际上希望你做的是在提交之前在此文件中多写一些情话。
你能做什么?
好吧,克服这个问题的一种方法是使用git reset --mixed HEAD~1
,有效地撤消您采取的提交和暂存操作:
所以main
再次指向“Commit 1”, love.txt
不再是索引的一部分。但是,该文件仍保留在工作目录中。您现在可以继续,并向其中添加更多内容:
继续,暂存并提交您的文件:
干得好👏🏻
你得到了“Commit 2.4”指向“Commit 1”的清晰、漂亮的历史记录。
现在我们的工具箱里有了一个新工具, git reset
💪🏻
这个工具超级好用,你几乎可以用它完成任何事情。它并不总是使用起来最方便的工具,但如果您小心使用它,它几乎可以解决任何重写历史的场景。
对于初学者,我建议几乎任何时候你想在 Git 中撤消时只使用git reset
。一旦您对它感到满意,就可以继续使用其他工具了。
让我们考虑另一种情况。
创建一个名为new.txt
的新文件;阶段和提交:
哎呀。其实,这是一个错误。你在main
上,我希望你在功能分支上创建这个提交。我的坏😇
我希望您从这篇文章中获得两个最重要的工具。第二个是git reset
。第一个也是最重要的一个是在白板上标出当前状态与你想要进入的状态。
对于这种情况,当前状态和期望状态如下所示:
你会注意到三个变化:
main
在当前状态下指向“Commit 3”(蓝色那个),但在期望状态下指向“Commit 2.4”。
feature
分支在当前状态下不存在,但它存在并指向所需状态下的“Commit 3”。
HEAD
在当前状态下指向main
,在期望状态下指向feature
。
如果你能画出这个并且你知道如何使用git reset
,你绝对可以让自己摆脱这种情况。
因此,再次重申,最重要的是深吸一口气,把它画出来。
观察上图,我们如何从当前状态到想要的状态呢?
当然有几种不同的方法,但我只会为每种情况提供一个选项。也可以随意尝试其他选项。
您可以使用git reset --soft HEAD~1
开始。这会将main
设置为指向之前的提交,“Commit 2.4”:
再次查看当前与期望图,您可以看到您需要一个新分支,对吧?您可以为它使用git switch -c feature
或git checkout -b feature
(做同样的事情):
此命令还会更新HEAD
以指向新分支。
由于您使用了git reset --soft
,因此您没有更改索引,因此它当前具有您想要提交的状态——多么方便!您可以简单地提交到feature
分支:
你达到了想要的状态 🎉
准备好将您的知识应用于其他案例了吗?
对love.txt
添加一些更改,并创建一个名为cool.txt
的新文件。暂存它们并提交:
哦,糟糕,实际上我希望你创建两个单独的提交,每个更改一个 🤦🏻
想自己试试这个吗?
您可以撤消提交和暂存步骤:
执行此命令后,索引不再包含这两个更改,但它们仍在您的文件系统中。所以现在,如果你只暂存love.txt
,你可以单独提交它,然后对cool.txt
做同样的事情:
不错😎
创建一个包含一些文本的新文件 ( new_file.txt
),并将一些文本添加到love.txt
。暂存两个更改,并提交它们:
哎呀🙈🙈
所以这一次,我希望它在另一个分支上,但不是一个新分支,而是一个已经存在的分支。
所以,你可以做什么?
我会给你一个提示。答案非常简短而且非常简单。我们先做什么?
不,不reset
。我们画。这是要做的第一件事,因为它会让其他一切变得容易得多。所以这是当前状态:
和理想的状态?
您如何从当前状态进入所需状态,最简单的是什么?
因此,一种方法是像以前一样使用git reset
,但我希望您尝试另一种方法。
首先,移动HEAD
指向existing
分支:
直觉上,您要做的是采用蓝色提交中引入的更改,并将这些更改(“复制粘贴”)应用到existing
分支之上。而 Git 有一个专门用于此的工具。
要让 Git 获取此提交与其父提交之间引入的更改,并将这些更改应用到活动分支,您可以使用git cherry-pick
。此命令获取指定修订中引入的更改并将它们应用于活动提交。
它还创建一个新的提交对象,并更新活动分支以指向这个新对象。
在上面的示例中,我指定了创建的提交的 SHA-1 标识符,但您也可以使用git cherry-pick main
,因为我们正在应用其更改的提交是main
指向的提交。
但是我们不希望这些更改存在于main
分支上。 git cherry-pick
仅将更改应用到existing
分支。你怎么能从main
中删除它们?
一种方法是switch
回main
,然后使用git reset --hard HEAD~1
:
你做到了! 💪🏻
请注意, git cherry-pick
实际上计算了指定提交与其父提交之间的差异,然后将它们应用于活动提交。这意味着有时 Git 将无法应用这些更改,因为您可能会遇到冲突,但这是另一篇文章的主题。
另外请注意,您可以要求 Git cherry-pick
任何提交中引入的更改,而不仅仅是分支引用的提交。
我们获得了一个新工具,因此我们拥有git reset
和git cherry-pick
。
好的,改天,另一个回购协议,另一个问题。
创建一个提交:
并将其push
送到远程服务器:
嗯,哎呀😓…
我刚注意到一件事。那里有错字。我写了This is more tezt
而不是This is more text
。哎呀。那么现在最大的问题是什么?我push
ed,这意味着其他人可能已经pull
了这些更改。
如果我使用git reset
覆盖这些更改,正如我们目前所做的那样,我们将拥有不同的历史记录,一切都可能一团糟。您可以根据需要重写自己的回购副本,直到您push
它为止。
一旦你push
更改,如果你要重写历史,你需要非常确定没有其他人获取这些更改。
或者,您可以使用另一个名为git revert
的工具。此命令获取您提供给它的提交并从其父提交计算差异,就像git cherry-pick
一样,但这次,它计算反向更改。
因此,如果您在指定的提交中添加了一行,则反向将删除该行,反之亦然。
git revert
创建了一个新的提交对象,这意味着它是对历史记录的补充。通过使用git revert
,您没有重写历史。你承认了你过去的错误,这次提交是承认你犯了一个错误,现在你修正了它。
有人会说这是更成熟的方式。有人会说,如果您使用git reset
重写以前的提交,那么历史记录不会那么干净。但这是避免改写历史的一种方式。
您现在可以修复拼写错误并再次提交:
您的工具箱现在加载了一个新的闪亮工具, revert
:
完成一些工作,编写一些代码,然后将其添加到love.txt
中。暂存此更改并提交:
我在我的机器上做了同样的事情,我使用键盘上的Up
箭头键滚动回到之前的命令,然后我按下Enter
,然后……哇。
哎呀。
我只是使用git reset --hard
吗? 😨
到底发生了什么? Git 将指针移至HEAD~1
,因此无法从当前历史记录中访问我所有宝贵工作的最后一次提交。 Git 还取消暂存区中的所有更改,然后将工作目录与暂存区的状态相匹配。
也就是说,一切都符合我的工作……消失的状态。
抓紧时间。吓坏了。
但是,真的,有理由惊慌失措吗?不是真的……我们是放松的人。我们做什么?好吧,直觉上,提交真的真的消失了吗?不,为什么不呢?它仍然存在于 Git 的内部数据库中。
如果我只知道它在哪里,我就会知道标识此提交的 SHA-1 值,我们就可以恢复它。我什至可以撤消撤消,然后reset
回此提交。
所以我真正需要的是“已删除”提交的 SHA-1。
所以问题是,我如何找到它? git log
有用吗?
好吧,不是真的。 git log
会转到HEAD
,它指向main
,它指向我们正在寻找的提交的父提交。然后, git log
将通过父链追溯,其中不包括我宝贵工作的提交。
值得庆幸的是,创建 Git 的非常聪明的人还为我们创建了一个备份计划,称为reflog
。
当您使用 Git 时,每当您更改HEAD
时,您可以使用git reset
以及其他命令(如git switch
或git checkout
,Git 都会向reflog
添加一个条目。
我们找到了我们的承诺!它是以0fb929e
开头的。
我们还可以通过它的“昵称”—— HEAD@{1}
来联系它。因此,例如 Git 使用HEAD~1
来获取HEAD
的第一个父级,并HEAD~2
来引用HEAD
的第二个父级等等,Git 使用HEAD@{1}
来引用HEAD
的第一个 reflog 父级, HEAD
在上一步中指向的位置。
我们还可以要求git rev-parse
向我们展示它的价值:
另一种查看reflog
方法是使用git log -g
,它要求git log
实际考虑reflog
:
我们在上面看到reflog
和HEAD
一样指向main
,后者指向“Commit 2”。但是reflog
中该条目的父项指向“Commit 3”。
因此,要返回“Commit 3”,您只需使用git reset --hard HEAD@{1}
(或“Commit 3”的 SHA-1 值):
现在,如果我们git log
:
我们挽救了局面! 🎉👏🏻
如果我再次使用这个命令会发生什么?并运行git commit --reset HEAD@{1}
? Git 会将HEAD
设置为HEAD
在上次reset
之前指向的位置,意思是“提交 2”。我们可以继续一整天:
现在看看我们的工具箱,里面装满了可以帮助您解决 Git 中出现问题的许多情况的工具:
使用这些工具,您现在可以更好地了解 Git 的工作原理。有更多的工具可以让你专门重写历史, git rebase
),但你已经在这篇文章中学到了很多东西。在以后的帖子中,我也会深入研究git rebase
。
最重要的工具,甚至比此工具箱中列出的五个工具更重要的是,将当前情况与所需情况进行白板比较。相信我,这会让每一种情况看起来都不那么令人生畏,解决方案也会更加清晰。
我还就这篇文章的内容进行了现场演讲。如果您更喜欢视频(或希望在阅读的同时观看)——您可以找到它
一般来说,
Omer 拥有特拉维夫大学的语言学硕士学位,并且是
首次发表于此