使用 Git 将整个项目重置为特定时间点



在过去的一两个小时里,我基本上把我的 git 历史弄得一团糟。我如何以及是否有可能将整个项目(所有分支)恢复到几个小时前的状态?

我知道有这个git reset --hard master@{"10 minutes ago"}但我想为整个项目和所有分支做这件事

除非你做了你将要做的事情,否则你只会将提交添加到存储库中。 这意味着它真的很容易恢复。 除了当前分支外,Phd的方法通常都可以工作,因为不允许git branch更改存储在当前分支名称中的哈希ID。 要修改当前分支,您必须按照您的建议使用git reset,可能带有--hard

开始之前需要了解的内容

Git 并不是真的关于分支,绝对不是关于文件。 Git 是关于提交的。 我们(人类)用分支名称组织我们的提交,并称它们为"分支"——这让事情变得一团糟,因为我们称多个不同的东西为"分支",就好像它们都是一样的——每个提交都存储文件,但 Git "关心"的事情,可以说是提交。 因此,要使用 Git,你需要在某种本能的直觉层面上知道 Git 提交是什么以及为你做什么。 如果你不知道这一点,你会发现自己有麻烦。

Git 提交:

  • 已编号。 每个提交都有一个唯一的编号,以十六进制表示;Git 将其称为提交的哈希ID,或者更一般地说,对象 ID。 这些事情看起来是随机的,但实际上并不是随机的;它们只是不可预测

    当我说独特时,我的意思是独特:不是"有点独特",也不是"孤立的独特",而是具有"唯一"一词的绝对含义,不包括数字3和5的含义。 (这里的意思5是描述主义者将包括的"独特"的弱版本。1)当你进行新的提交时,它会得到一个以前从未使用过的新数字,之后,它再也不能使用了。

    (Git 内部使用的编号方案注定有一天会失败。 哈希 ID 的大小推迟了这一点——我们希望在我们死后足够长的时间,没有人关心,例如数十亿年——但 Git 正在慢慢从 SHA-1 转向 SHA-256,因为 SHA-1 被证明是不够的,至少在主动破坏的情况下是这样。

  • 是只读的。 编号方案要求这样做:哈希 ID 实际上是内容的加密校验和。 如果要更改内容,则结果是具有不同哈希 ID的新对象,并且原始对象将保留。 因此,我们不能(相当)从存储库中删除任何内容。 我们只能补充它。 (我们可以通过不再使用它来假装删除某些东西,这就是我们要做的。

  • 包含两件事:每个文件的完整快照和一些元数据。 我不会在这里详细介绍,但其中一些非常重要,所以我现在将介绍一些关于元数据的内容。

每个提交中的元数据称为"元数据",因为它是有关该提交的信息。 这包括您的姓名和电子邮件地址(从您的user.nameuser.email设置中复制,一旦存储在提交中,就完全不可更改,就像每个提交的所有内容一样)。 它包括您想要包含的任何日志消息,以告诉自己和/或其他人您进行此特定提交的原因而且,出于 Git 自己的目的,每个提交都包含以前提交哈希 ID 的列表。此列表通常正好是一个元素长,我们将该哈希 ID 称为此提交的父级

正是存储在每次提交中的父级信息构成了历史记录。 结果是提交历史记录:Git 存储库中的历史记录不多于或小于存储库中的提交。 但在这一点上,关键是我们如何找到这段历史。 如果提交数看起来是随机的,而且确实如此,我们将如何找到最新的提交?


1我自己既是描述主义者又是规定主义者。 就像艾伦·帕森斯(Alan Parsons)的歌曲《打开它》(Turn it Up)一样,坐在栅栏上[让我]感到痛苦......


查找最新提交

让我们绘制一个简单的普通(单亲2)提交链,如在典型的小型存储库中找到的那样:

... <-F <-G <-H

在这里,H代表最新提交的哈希 ID。 无论实际的哈希 ID 是什么,Git 都可以使用它来检索提交,从而获取 Git 元数据和快照。 使用元数据,Git 可以告诉您谁进行了提交以及他们的日志消息是什么。 但 Git 也可以使用H中的元数据来查找H提交G的原始哈希 ID。

使用哈希 ID 进行G,Git 可以检索提交G。 这会得到一个快照和日志消息等等。 但这也得到了 Git 早期提交F的原始哈希 ID。 由于F是一个提交,Git 现在也可以获取它,它有一个快照和日志消息以及一个父级,Git 可以从F向后工作,依此类推。 重复足够长的时间,Git 最终会回到第一次提交(与脚注 2 一样,它不会有任何父级,因此git log最终可以停止)。

所以 Git 可以告诉我们这个单分支存储库的整个历史,前提是Git 可以找到哈希 IDH。 它将从哪里获得此哈希 ID? 我们会被迫自己背诵吗? 即使我们记住它,我们也能正确输入它吗?

为了将我们可怜的人脑从这项任务中拯救出来,Git将在分支名称中存储我们一个分支的最新哈希 ID。 我们将选择一些名称,mainmastertrunk或其他名称,并让 Git 以该名称存储上次提交的哈希 ID("分支的提示"):

...--F--G--H   <-- master

如果我们想创建一个新分支,我们只需让 Git 创建一个新名称,也指向提交H,如下所示:

...--F--G--H   <-- br1, master

创建第二个br2分支可得到:

...--F--G--H   <-- br1, br2, master

当然,现在我们需要一种方法来知道我们使用哪个名称来查找提交H,因此我们将让 Git 将一个非常特殊的名称HEAD附加到这三个分支名称之一:

...--F--G--H   <-- br1, br2, master (HEAD)

这意味着我们"在"分支mastergit status会说on branch master。 我们使用提交H来获取文件,但我们通过名称master找到提交H


2在 Git 中,具有两个或更多父级的提交是合并提交。 任何非空仓库中至少有一次提交没有父级:这是任何人进行的第一次提交。 这种提交是根提交。 但是,大多数仓库中的大多数提交都有一个父级,因此是"普通"提交。

请注意,零父根提交或双父合并提交仍然只有所有文件的单个快照。 对于普通提交,父级快照与此提交的快照之间的差异将显示您(或此提交的作者)更改的内容。 在合并提交的情况下,一个父级有一个区别,另一个父级有一个区别,因此不再有单一的明显方法来描述更改的内容。 这就是使合并提交变得棘手的原因。

请注意,git log -p命令根本不会费心将合并提交显示为"更改",而是跳过困难的部分。 这是欺骗性的;小心它。

<小时 />

发展分支

现在让我们切换到分支br1git switch br1或旧git checkout br1(两者都做同样的事情)。 结果是:

...--F--G--H   <-- br1 (HEAD), br2, master

我们仍在使用提交H但现在我们通过名称br1

.我们现在以通常的方式进行新提交(编辑文件、运行git add、运行git commit、输入日志消息)。 我将跳过有关此的大量重要细节,特别是关于所有文件的位置:请记住,存储在提交H中的文件实际上是只读的,因此无法更改,因此我们正在编辑的文件不得在提交H,实际上它们根本不在 Git。 但为了简短地回答,我们不会详细介绍这些细节。 相反,我们只是假设您知道所有这些,并且当您运行git commit时,您知道 Git 从哪里获取新快照。 (这不是你的工作树。

无论如何,当您运行git commit并提供所有详细信息时,Git 会进行新的提交:新的快照和元数据。 这个新提交将获得一个新的、唯一的哈希 ID,永远不会在任何地方的任何 Git 存储库中再次使用。 它看起来是随机的,又大又丑,对人类来说太难了,所以我们把这个新的提交称为I,并把它画进去:

I   <-- ...
/
...--F--G--H   <-- ...

这是偷偷摸摸的部分:由于我们在br1,Git现在更新名称br1以指向I。 因此,我们可以像这样填写上面出现两次的三个点:

I   <-- br1 (HEAD)
/
...--F--G--H   <-- br2, master

特殊名称HEAD仍然附在名称br1上,但名称br1本身不再指向H。 现在它指向I. 提交到H都在所有三个分支上,提交I仅在br1上。

如果我们在br1上进行第二次提交,我们会得到:

I--J   <-- br1 (HEAD)
/
...--F--G--H   <-- br2, master

我们现在可以git switch br2(或像往常一样checkout)并进行仅在br2上进行的新提交,它们将br2扩展br1上面的新提交的方式扩展:

I--J   <-- br1
/
...--F--G--H   <-- master

K--L   <-- br2 (HEAD)

现在,所有三个名称都选择三种不同的提交:master继续像以前一样选择H,但br1选择提交Jbr2选择提交L

这是一个胶囊评论;确保您了解以下每几点:

  • HEAD控制哪个分支名称是当前名称
  • 当前
  • 名称指向当前提交。
  • 从一个名称切换到另一个名称会更改从该提交中签出的文件,进入我们处理/处理文件的区域。
  • 我们在工作区中处理/处理的文件实际上并不在 Git 中。 快照位于Git 中,并通过git switchgit checkout提取,HEAD附加到所需的分支名称。
  • 进行新提交会创建新的快照和元数据,从而获得新的唯一哈希 ID。 这个新提交向后指向我们用来进行新提交的任何提交;一旦 Git 进行了新的提交,它就会将新的哈希 ID 写入当前分支名称中。 因此,提交的行为是分支增长的原因。 增长的分支是我们之前选择的分支,具有git switchgit checkout.提交的父级是我们当时选择的提交。

"删除"提交

由于提交是历史记录且只读的特殊方式,我们实际上无法删除提交。 但是因为我们通过使用分支名称来选择最新的提交来查找提交......好吧,假设我们不是使用"向前移动"的名称,而是强制名称本身"向后移动"? 也就是说,假设我们有:

I   <-- br1 (HEAD)
/
...--G--H   <-- master

我们强制 Gitbr1一个提交后移一个提交,移动到我们进行新提交之前的位置I? 我们将得到:

I   ???
/
...--G--H   <-- br1 (HEAD), master

提交I仍然存在,但如果我们去看历史,我们不会看到它。无论我们要求 Git 从名称br1还是从名称master开始,我们都会看到 commitH,然后提交G,依此类推。 我们永远不会继续看到提交I。 就好像没了一样!

由于保证提交永远不会更改,因此就好像提交I从未发生过一样。

如果我们有这个:

I--J   <-- br1
/
...--F--G--H   <-- master (HEAD)

K--L   <-- br2

我们强迫br1br2都后退一步,我们会得到这个:

I   <-- br1
/
...--F--G--H   <-- master (HEAD)

K   <-- br2

(我根本没有费心去绘制我们再也找不到的提交)。 我们刚刚"删除"的两个提交并没有真正消失,但它们也可能消失:我们不会看到它们。

萎缩并不完全与增长相反

当我们发展分支时,我们通过以下方式做到这一点:

  • 按名称签出分支,选择最新的提交;
  • 处理可编辑的非 Git 文件;
  • 添加更新的文件并提交

这是发展分支的git commit步骤。 它只增长当前分支。

当我们去收缩一个分支时,我们可以强制更改任何非当前分支名称,以使该名称选择我们喜欢的任何提交(包括当前提交)。 我们只是运行:

git branch -f <name> <new-hash-ID>

对于新的哈希 ID,我们可以替换 gitrevisions 文档中描述的任何表达式。 这包括name@{time}配方,例如master@{"10 minutes ago"}。 如果要查看选择哪个提交,请使用:

git show master@{10.minutes.ago}

或:

git log master@{10.minutes.ago}

(我在这里使用了点.而不是空格以避免需要引号,尽管在某些命令行解释器中您可能仍然需要引号;空格与点的技巧可能不会它帮助的那样帮助你。

但这不适用于我们主动检查过的任何分支。 在只有一个工作树的标准 Git 存储库中,这就是我们"在"的一个特定分支;如果使用git worktree add,可能会有其他分支名称像这样"锁定"。 要更改我们所在的分支,我们必须使用git reset,并选择--soft--mixed--hard之一。 这是因为我上面跳过的一些内容:我们需要告诉 Git 如何处理提交中产生的文件的可用副本。 使用--hard告诉 Git 扔掉旧的可用副本并从我选择的新提交中放入新副本,所以这就是我们通常希望这种情况。

由于git reset将在此处丢弃的文件首先不在 Git 中,因此 Git将无法帮助您在丢弃它们后将它们取回。 所以要非常小心git reset --hard

最新更新