使用历史记录已更改的远程重新设置本地仓库

Rebase local repo with remote whose history has changed

在此 guide 之后,我通过删除历史上的一些重要文件来缩小我们的项目存储库。这意味着 git 历史已经改变。但是现在的问题是我的其他团队成员如何在不丢失他们对当前不在远程的分支上所做的更改并且不推回已删除的历史记录的情况下获得我们回购协议的新缩小版本。

作者建议克隆或变基:

Anyone else with a local clone of the repository will need to either use git rebase, or create a fresh clone...

全新的克隆意味着放弃任何团队成员在本地所做的所有更改。因此,变基似乎是更好的选择。但是我们怎么办呢?

我在想这样的事情:(假设 master 是新功能分支的基础分支,开发机器上的本地分支有新的工作,并且 master 已经受到历史重写的影响):

$ git checkout master
$ git fetch origin
$ git pull --rebase
$ git checkout new-feature
$ git rebase master

然后确认一切正常

$ git push origin

该指南还不错(尽管有一些技巧可以改进,并且使用 "BFG" 可以更快地 清理存储库 — 请参阅其他 Whosebug 帖子包括 BFG 作者的那些)。而且,这部分是正确的:

Anyone else with a local clone of the repository will need to either use git rebase, or create a fresh clone...

不幸的是,您建议的变基步骤是错误的。实际所需的步骤有点棘手。只有当您了解 Git 的散列、提交图和分发存储库背后的思想时,它们才会变得清晰;你对 git filter-branch 做了什么;以及您可以使用 git rebase 做什么。还有另一种方法,使用 git format-patch 来完全避免 git rebase——但是你需要 知道 才能使用它。

(在使用rebase时,我们可以使用--fork-point,至少在大多数情况下是这样。见下文。)

背景

git filter-branch 所做的在某些方面与 git rebase 所做的相似:它们都复制提交。两者之间的最大区别是哪些提交被复制如何执行复制

关于 Git 提交你需要知道的下一件事——你实际上已经知道这一点,但你需要更好地理解它——每个提交都由它的 唯一标识哈希 ID。 Git 不断向您展示这些丑陋的大 8f60064... 东西。这些 ID 如何 Git 找到每个提交——但关于它们有一个关键事实,即它们是通过计算 内容的加密校验和产生的 提交。提交的内容取决于很多其他的东西,包括 so-called tree——源代码快照;每当您删除一个大但不必要的文件时,您将更改此树 - 以及提交的前一个,或 parent,commit(s).

同样,这个密码哈希 ID 主要取决于提交的内容。但同时,它是 完全确定的 :如果你给 Git 相同的 内容,你将得到 相同 哈希 ID。事实上,所有 Git 的 object 都是如此,而不仅仅是提交。所有四种类型——文件(称为 blob)、树、带注释的标签和提交——使用相同的散列技术,并对 bit-for-bit 相同的文件或树或标签或提交进行散列产生与上次相同的 散列。这意味着,例如,在 50 次提交中保存一个文件的特定版本——不管它有多大——需要 完全一样多 space 将其保存在 one 提交中。但是,一旦您 更改 文件,您就有了一个新版本,不再 bit-for-bit 相同。保存 that 会生成一个新的不同的散列,从而保存文件的新副本。散列只是用来让 Git 在它的 object 数据库中找到 object:你必须 知道 哈希以获取实际内容。随时从内容到散列很容易:只需对内容进行散列即可。而且,如果您 知道 哈希,则很容易 获取 存储的内容:如果哈希,它们就在数据库中(作为值)在数据库中(作为键)。

因为内容决定了哈希 ID,所以我们永远无法更改 任何 Git object .如果我们改变一点点,然后再次散列,我们会得到一个新的、不同的散列 ID。这意味着我们——以及 Git——实际上永远无法改变任何东西。我们只能复制它到一个新的,稍微不同的东西。我们制作副本,散列它,然后查看散列是否在数据库中。如果不是,我们用它的新散列存储副本,现在它在数据库中。


当我们——或Git——将存储库视为一个整体时,我们可以绘制该存储库中所有提交的。有很多不同的方法来绘制图表,但对于 Whosebug 的帖子,我在左侧绘制了 "earlier" 提交,在右侧绘制了 "later" 提交。每个后来的提交 "points back" 到其 parent 较早的提交。使用构成存储库大部分的简单线性链,可以为我们提供如下所示的内容:

A <- B <- C <- D   <-- master

注意 b运行ch 名称 master "points to" 最近的提交,D(我使用单个字母而不是丑陋的 40 个字符哈希,直到我 运行 出单个字母 :-) )。 Git 称其为 b运行ch 的 tip。此最新提交 "points back" 到其 parent 提交 CC 指向 BB 指向 A。由于 A 是有史以来的第一次提交,因此没有更早的提交到 point-to,因此它不会指向任何地方。提交 A 是一个 root 提交,它会停止所有遍历操作。

通常我们不需要内部箭头,所以我把它们画成:

A--B--C--D   <-- master
    \
     E--F    <-- branch

whih 更紧凑,使用线而不是箭头。但请注意,这里 E 的 parent 有 BE "points back" 到 B。虽然没有箭头 head,但你可以从它们跨越连接线的相对位置分辨出哪个提交是 parent,哪个是 child:children在parent的右边,parent在children的左边。

当 Git 开始处理存储库中的提交时——例如 git log 甚至 git merge——它以 提示开始 提交,b运行ch 名称指向的那些。然后它使用内部指针来查找每个以前的提交。即 namesmasterbranch,开始吧。一旦我们开始,Git 使用内部 "connecting arrows" 查找所有 rest 提交。

虽然我在这里使用了单个字母,但每个提交都有一个完整的 cryptographic-hash ID,并且每个提交都存储了前一个提交的 ID。例如,这就是 D 可以 "point back" 到 C 的方式。这些存储的 ID 参与加密哈希,这意味着 C 的 ID 影响 D 的 ID。等价地,我们可以说 D 的 ID 取决于 C 的 ID。还要注意,D 的 ID 取决于 随提交 D 保存的 work-tree 快照。如果CD是第一个引入大文件的地方,我们去删除那个大文件,好吧……现在让我们看看filter-branch是如何工作的。

什么 filter-branch 复制,为什么这很重要

在基本层面上,在任何优化之前,git filter-branch 副本是 存储库中的每个提交 。 (更准确地说,每个提交都可以从指定的 b运行ches 或其他引用到达;对于 --all,这实际上意味着一切,前提是我们做出一些非常安全的假设,或者从一个新的克隆开始,就像在你的指南。)

由于过滤器 b运行ch 正在工作,方式 它复制每个提交是首先将其提取到它的所有组成部分。然后它 运行 是你的过滤器。这些过滤器可以改变东西(毕竟这是过滤器的意义所在)。

无论他们做了什么改变,Git 现在必须 re-compute 哈希 ID。如果事实证明过滤器实际上没有改变任何东西,那么新散列将与旧散列相同——但我在这里有点超前了。这对文件来说是完全正确的:如果你不改变文件,它会保留它的旧散列。如果您更改文件,新内容将获得新的哈希值。对于存储的树(整个 collection 文件的快照)也是如此。但是假设我们正在使用:

A--B--C--D   <-- master
    \
     E--F    <-- branch

Git 要做的是按适当的顺序过滤所有 提交。它会先 A,然后 B。然后它有一个选择:它可以做 E 然后 F 然后回去做 CD,或者它可以做 CD,然后返回E,然后F。 (在图论术语中,Git 必须对图进行 拓扑排序 。)我们不需要担心细节——Git 负责那——但是我们确实需要观察Git复制每个提交时发生的情况。

为了简单和具体起见,假设副本按字母顺序排列(A、B、C、D、E、F)。假设我们的过滤器是 "remove huge file".

现在,假设这个巨大的文件在提交 A 中是 而不是 。 Git 提取 A 并应用过滤器。这试图删除巨大的文件 - 但它不存在!所以实际上什么都没有改变。 Git 现在从剩下的内容进行提交,并且新提交与原始 A bit-for-bit 相同。所以它得到了相同的哈希IDA的副本A.

Git 现在继续提交 B 并重复此过程。如果B没有变化,它的"copy"仍然是B

Git 继续提交 C。这个提交 确实 有大文件——所以我们的过滤器将其删除,并且 Git 进行了新的提交。此提交 不再 bit-for-bit 相同 ,因此它获得了一个新的哈希值,并作为新的不同提交存储在数据库中。由于它是 Ccopy,我们称此提交为 C':

     C'
    /
A--B--C--D
    \
     E--F

现在 Git 继续提交 D。我们将复制提交 D。副本会 bit-for-bit 与原件相同吗?好吧,如果我们必须删除一个文件,当然不会。但是——假设提交 C 的人意识到他们的错误,并且 删除了 大文件。现在副本可能 bit-for-bit 相同。但那将是一个错误,因为提交 D 指向回提交 C我们需要一个指向后方的提交,不是指向 C,而是指向 C'! 因此,无论提交 D 是否有大问题, Git 进行了不同的 new 提交。我们的新副本 D' 不仅省略了大文件——如果它在那里的话——而且还指向我们复制的 C':

     C'-D'
    /
A--B--C--D
    \
     E--F

Git 现在开始复制 EF。如果他们没有大文件,他们的副本就是他们的原件。如果 E 确实 有大文件,它的副本是一个新的提交 E',并且强制 Git 将 F 复制到F' 也是。如果只有 F 有大文件,Git 可以 re-use 原来的 E 但需要一个新的副本 F'.

这归结为 每个更改的下游提交也会更改("downstream" 这里表示 "is a child, or grandchild, or other further descendant")。一旦我们复制了 一个 提交,这个变化就会在图表的其余部分向下冒泡。

如果我们必须修改 BB 下游的每个提交也会被复制。如果我们必须先修改 A,那么 每个 提交都会被复制,从而得到:

A--B--C--D
    \
     E--F

A'-B'-C'-D'
    \
     E'-F'

(这是一个有效的提交图!它由两个 so-called disjoint sub-graphs 组成。Git 处理这种事情没问题。)

git filter-branch做的最后一件事是移动所有b运行ch名称(而且,如果我们有一个--tag-name-filter,相应的标签名称)。它将这些 b运行ches 移动到复制的 tip 提交。如果我们只复制 CD,这是我们的最终图表,标签指向:

     C'-D'   <-- master
    /
A--B--C--D   [abandoned]
    \
     E--F    <-- branch

虽然提交 CD 实际上 仍在存储库中 ,但它们现在 无法访问 .他们没有名字 master 可以找到他们。

为了真正缩小存储库,disk-space-wise,我们现在必须说服 Git 丢弃原来的 CD(通过它们的哈希 ID) . Git 通常最终会自行完成此操作,除了:

  1. 我们不想等待,而且
  2. 为了以防万一,git filter-branch 将原始名称保留为 refs/original

因此,我们必须删除这些名称(如您的指南中所示),然后使用更多 "maintenance-y" Git 命令使过期立即发生,而不是最终发生。

git rebase 的作用

你现在明白了 git filter-branch 是如何工作的,复制每一次提交,有时以副本 bit-for-bit 相同而结束,因此实际上 相同原始的,但有时必须更改已更改提交的每个提交 "downstream"。现在您确实明白了,git rebase似乎简单得可笑。

变基命令,如filter-branch,复制 提交。但是,通常至少,它首先将每个提交变成一个 补丁 ——或者更准确地说,一个 patch-with-some-history,或一个 git cherry-pick(这些都是我们不需要在这里讨论的方式略有不同。

回顾一下我们绘制的提交图。每个提交都有一些 parent(s)。大多数提交只有一个 parent。一些(至少一个)可以有 no parents,而一些 - Git 调用 *merge 提交 - 有两个或更多 parent s.

对于任何只有一个 parent 的提交,这是其中的大部分,我们可以 运行 git diff 比较 新的 child 提交旧的 parent,看看 改变了什么 git diff 的输出是一组指令:"to change parent commit into child commit, remove these lines and add these other lines." 这是一个 补丁 。由于它现在是一个补丁——一组更改,而不是快照——提交的这个补丁版本可以应用于不同源快照。

这不适用于合并提交,因为它们至少有 两个 parent。 (我没有在上面绘制任何合并提交。)因此 git rebase 通常只是 完全跳过它们 。它也不适用于 root 提交;通常你也不会改变它们的基数(首先它没有多大意义)。

通过将每个 commit-to-rebase 变成一个补丁,git rebase 可以将多个提交复制到图中的 新位置 。例如,给定:

A--B--C--D   <-- master
    \
     E--F    <-- branch

我们可以复制 E--F 以便新副本在之后 D:

A--B--C--D     <-- master
          \
           E'-F'  <-- branch

为此,我们告诉 git rebase 哪个提交复制:

git checkout branch   # i.e., end the copy with the tip of branch

并且提交复制:

git rebase master     # i.e., *don't* copy commits that are on master

将副本放在哪里:

git rebase master     # i.e., put the copies after the tip of master

请注意 git rebase<upstream> 参数在这里做了 两件事,即指定 不要复制的内容副本放在哪里.

这适用于大多数(但不是全部)常规变基。它对我们不起作用,原因有二。一个很容易使用 --onto 来处理,我们将看到。另一个比较棘手。

大多数 Git 命令通过哈希 ID 计算出所有内容。 git rebase 也是如此:它知道哪些命令是复制的候选者,哪些是不是,通过他们的 哈希 ID。但是我们 运行 git filter-branch 并将提交复制到新的提交,具有 不同的 哈希 ID。现在,rebase 确实有一些额外的 smart-stuff 来解决一些复制提交的情况,但它们对我们的帮助还不够,我们稍后会看到。

过滤后变基

现在,我们的问题不同了。其他人——不是我们——运行 git filter-branch 在某个集中存储库上并将我们的 A-B-C-D 变成了 A-B-C'-D'。我们可能也可能没有 E-F 关闭 B。但是——这是棘手的部分——我们有我们自己的存储库,它与集中式存储库分开,后者有我们自己的 提交 G-H:

           G--H   <-- feature
          /
A--B--C--D        <-- master, origin/master
    \
     E--F         <-- origin/branch

Some clown :-) 已经完成了 运行 filter-branchcentral 存储库中并将 C-D 替换为 C'-D'.我们现在 运行 git fetch 不使用 git pull— 在那个中央存储库上获取他们的新提交。这给了我们他们的新承诺,而我们保留了自己的承诺。我们现在有:

           G--H   <-- feature
          /
A--B--C--D        <-- master
   |\
   | \
   |  C'-D'       <-- origin/master
    \
     E--F         <-- origin/branch

注意我们自己的master没有动。我们自己的 CD 的原件也仍然在我们自己的存储库中。他们的副本 C'D' 现在 添加到 我们的 collection,我们的 origin/master 已经移动到记住他们的新 master。我们没有自己的 branch,只有 origin/branch,但这次没有改变。

我们现在需要做的是复制我们的G-H提交。这些在 b运行ch 上,其尖端名为 feature。但是我们的 CD 原件在这个 b运行ch 上是 也是 。他们的名字 master 指向他们。

这是你的命令序列有什么问题

您建议我们 运行 git rebase 在我们的 b运行ch master 上。 (这就是 git pull --rebase 所做的:首先 运行s git fetch,然后 运行s git rebase 而不是 运行ning git merge ).让我们看看如果我们这样做会发生什么。

这是我们的起始图,减去我们不关心的 origin/branch

           G--H   <-- feature
          /
A--B--C--D        <-- master
    \
     \
      C'-D'       <-- origin/master

我们 运行 git checkout master; git rebase origin/master,这或多或少是你用 git pull 建议的。我们说我们想要复制 master 上的提交——基于当前 b运行ch,来自 git checkout——而 excluding 提交在 origin/master 上。但那些是提交 AB。所以我们将复制 C,也许 D,并将我们的副本放在 origin/master:

之后
           G--H   <-- feature
          /
A--B--C--D
    \
     \
      C'-D'       <-- origin/master
          \
           C''    <-- master

让我们谈谈 git rebase 的 "smart" 部分:它知道不能盲目地信任提交哈希 ID。它所做的是将一堆提交也变成 git patch-id ID。无需详细说明,此 可能 git rebase 避免复制 D。不过,它绝对不适用于 C

记住 C' 的来源:它是 C 减去大文件。大文件的删除破坏了补丁 ID 的智能:Git 查看 B-vs-C 和 B-vs-C',它们看起来不同。所以 rebase 决定它必须再次复制 CC''。那re-adds大文件.

是否将 D 复制到 D'' 取决于 C-vs-D 中的内容以及 C'-vs-D' 中的内容。也许它确实被复制了,也许没有,但不管怎样,损坏已经造成:大文件又回来了!就在你以为它不见了的时候!

好的,所以我们不这样做——但是我们做什么

我们想要的是复制G-H。这就是 git rebase--onto 有用的地方——但我们还需要更多。

请记住 git rebase<upstream> 参数指定了 不复制的内容 放置副本的位置 .使用 --onto,我们可以告诉 rebase 将副本 .

放在哪里

我们知道将副本放在哪里:它们应该放在 origin/master 之后。所以我们将添加 --onto origin/master。副本现在将在提交 D'.

后进行

至于什么复制:好吧,这实际上很简单,只要我们还没有触及我们自己的master 我们想复制 feature 上不在 our master 上的提交。也就是说,我们要排除提交 D 和更早的所有内容。所以,这就是我们应该为 <upstream>.

提供的内容

这给了我们最终的 git rebase 命令序列:

git checkout feature
git rebase --onto origin/master master

git checkout 表示 "work on feature, i.e., commits ending at H"。第二部分,实际的 rebase 命令,表示 "omit commits that are on our master, while putting the copies after origin/master".

这是结果:

           G--H   [abandoned]
          /
A--B--C--D        <-- master
    \
     \
      C'-D'       <-- origin/master
          \
           G'-H'  <-- feature

正在清理

现在还剩下一件事要做,一旦我们复制了我们关心的所有提交。我们现在必须重置 master 以匹配 origin/master。为此,我们将使用 git reset --hard:

git checkout master
git reset --hard origin/master

请注意,我们仅在 我们使用保存的 master 完成变基后才这样做,以确保我们不会复制提交 D。最后一个 reset 的最终结果是:

           G--H   [abandoned]
          /
A--B--C--D        [abandoned]
    \
     \
      C'-D'       <-- master, origin/master
          \
           G'-H'  <-- feature

这就是我们想要的。

但是等等,如果我们没有自己的怎么办feature?

我们在 git fetchgit checkout 之后执行 git rebase --onto 时得到了这张图:

           G--H   <-- feature
          /
A--B--C--D        <-- master
    \
     \
      C'-D'       <-- origin/master

但是如果我们直接在 master 上提交 GH 会怎么样?然后我们会有这个:

A--B--C--D--G--H   <-- master
    \
     \
      C'-D'        <-- origin/master

如果我们处于这种情况,我们的工作就会困难得多。我们必须弄清楚哪些提交被复制了,即哪些提交是 CD,哪些是 C'D'.

如果我们坐下来画这张图,它会很明显。但是 real-world Git 图表非常混乱。 (这就是我们首先使用 b运行ch 名称的原因:计算机可以为我们跟踪混乱情况。)

原来Git的reflogs是我们的救星。当我们 运行 git fetch 拿起 C'-D' 时,这会将我们的 origin/master 从指向 D 移动到指向 D'。 reflog 条目 origin/master@{1} 仍然指向 D:

           G--H   <-- master, feature
          /
A--B--C--D        <-- reflog: origin/master@{1}
    \
     \
      C'-D'       <-- origin/master

这意味着我们可以使用以下命令修复我们的 feature b运行ch:

git checkout feature
git rebase --onto origin/master origin/master@{1}

(虽然取决于您的 shell,您可能需要在最后一个参数周围加上引号:shell 可能会尝试吃掉 {1} 部分并用它做一些事情)。在Git 2.0 及以后的版本中,这种巧妙的做法是使用--fork-point 内置到git rebase 中,因此您可以使用:

git rebase --fork-point origin/master

这适用于许多情况,并且通常是在上游重写(无论重写是 git filter-branch 还是 git rebase)后变基的技巧。

无论如何,无论您如何变基,都值得在推送之前仔细检查您的新 "outgoing" 提交。要检查这些提交:

git fetch origin
git log -p origin/master..feature

(假设你的 feature 最终会被推到 master)。

使用git format-patch

我上面提到你可以使用 git format-patch 而不是 git rebase。这对某些人来说可能更舒服,因为这让您有机会检查每个补丁,并且您可以将您的工作提取为一堆补丁,然后 re-clone 原始存储库(而不是更新现有但现在过时的克隆,来自过滤后的)。

我们知道 git rebase 将每个 to-be-rebased 提交变成一个补丁。我们只能自己做。将一些提交变成补丁的命令是 git format-patch.

假设我们的存储库中有我们的 b运行ch feature,基于我们的 master。我们知道有人过滤了中央存储库,我们还不想得到过滤后的存储库(或者我们已经在别处单独克隆了它)。我们现在想要的是生成每个 feature 提交,那些在我们 master 之后的提交作为补丁,所以我们只是 运行:

git format-patch --stdout master..feature > /tmp/as-a-patch

现在我们可以查看文件以查看我们有哪些提交以及它们做了​​什么。这基本上相当于每次提交时 运行ning git show

一旦我们检查了补丁并确定它们是正确的,我们就可以转到新的、过滤后的存储库的新克隆并创建一个新功能 b运行ch:

git clone <url>                  # clone the filtered repo
cd new-clone                     # switch to the new clone
git checkout -b feature master   # make a new feature branch
git am /tmp/as-a-patch           # apply the patches

这个东西用于将补丁从一个帐户通过电子邮件发送到另一个帐户,因此得名 git amapply e邮件。

因为我们从不将旧的 pre-filtered 克隆与新的 post-filtered 克隆 混合,我们仔细检查我们的 "email" 补丁文件,没有意外 re-introducing 大文件的危险。