git filter-branch 后跟 git push 导致双重提交

use*_*921 1 git github

我试图从 repo 中删除一些大型二进制文件以减少其克隆大小。在研究了这个话题后,我偶然发现了以下脚本:

#!/bin/bash

# this script displays all blob objects in the repository, sorted from smallest to largest
# you may need `brew install coreutils --with-default-names`

git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(objectsize) %(rest)' \
| sed -n 's/^blob //p' \
| grep -vF "$(git ls-tree -r HEAD | awk '{print $3}')" \
| awk '$2 >= 2^20' \
| sort --numeric-sort --key=2 \
| gcut -c 1-12,41- \
| gnumfmt --field=2 --to=iec-i --suffix=B --padding=7 --round=nearest
Run Code Online (Sandbox Code Playgroud)

取自/sf/answers/2978147441/并进行了一些调整。

输出类似于:

0d99bb931299   44MiB other/assets.sketch
2ba44098e28f   44MiB other/assets.sketch
bd1741ddce0d   45MiB other/assets.sketch
Run Code Online (Sandbox Code Playgroud)

下一步是删除不需要的文件。为此,我使用了以下脚本:

0d99bb931299   44MiB other/assets.sketch
2ba44098e28f   44MiB other/assets.sketch
bd1741ddce0d   45MiB other/assets.sketch
Run Code Online (Sandbox Code Playgroud)

摘自/sf/answers/3263090491/

到现在为止还挺好。接下来,我在 master 分支上愚蠢地运行了以下命令,而没有进行任何备份:

# to remove a file (displayed path/to/file in the output)
git filter-branch --index-filter 'git rm --cached --ignore-unmatch path/to/file' --tag-name-filter cat HEAD
Run Code Online (Sandbox Code Playgroud)

这创建了一个名为Merge remote-tracking branch 'origin/master'. 之后,我单击SyncGitHub Desktop 客户端中的按钮,将更改推送到存储库。

再次运行第一个脚本时,我看到文件仍然存在,它们没有被删除。经过进一步调查,我注意到我现在在 repo 中有双重提交。

在此处输入图片说明 在此处输入图片说明

我花了一天时间试图将 repo 恢复到旧状态而没有任何运气,同时我也从我的设备中删除了本地 repo,这意味着我不再拥有git reflog历史记录,也无法访问诸如refs/original/refs/heads/master.

我怎样才能将 repo 恢复到原来的情况?那还有可能吗?

tor*_*rek 6

注意:如果这是 TL;DR,请跳到最后一节,如何修复它(但如果您阅读前面的内容会更有意义)。


您需要了解的是git filter-branch 副本提交。也就是说,它获取每个现有提交,对其应用一些过滤器或过滤器集,并根据结果进行新的提交。这就是你最终得到两组提交的方式。这是必要的,因为任何人(尤其是 Git)都无权更改任何现有提交的任何内容。

过滤后的提交是一个新的历史,很大程度上独立于原始历史。(一些细节取决于精确的过滤器和提交输入。)值得记住的是,Git 存储库不包含文件,确切地说;它包含commits,并且 commits历史。每个提交都包含一个快照——所以从这个意义上说,存储库确实包含文件,但它们比概述低一步,这是在逐个提交的基础上进行的。

每个提交都有一个唯一的哈希 ID。这些是你在git log输出中看到的又长又丑的名字:commit b7bd9486b055c3f967a870311e704e3bb0654e4f等等。这个唯一的 ID 用于 Git 查找提交对象,从而查找文件;但哈希 ID 本身只是提交完整内容的加密校验和。每个提交也列出其父提交(或提交)的哈希 ID,父哈希(和快照哈希)是提交内容的一部分。这就是为什么Git 无法更改提交的任何内容:如果您获取内容,并更改任何内容,甚至是一点,并从中进行新的提交,您将获得一个新的、不同的哈希 ID,这是一个新的,不同的提交。

由于每个提交都包含其父项的 ID,这意味着如果我们以某种方式告诉 Git——通过哈希 ID——哪个提交是最新的,它可以拉出该提交并使用它来查找第二个最新的提交:

...  <--second-newest  <--newest
Run Code Online (Sandbox Code Playgroud)

第二新指向第三新,依此类推。如果链是完全线性的(如果没有分支和合并),我们最终会得到一个非常简单的图片:

A--B--C--D--E--F--G--H   <-- master
Run Code Online (Sandbox Code Playgroud)

在这里,名称master记住了最新提交的实际散列 ID ,我们将调用它H而不是提出其实际散列 ID。Commit 会H记住上一个 commit 的 hash ID G,它会记住 的 ID F,依此类推。CommitA是第一个提交,所以它根本没有父级,这让操作停止。

分支只是在链中挑选一些提交并创建一个不在master. 例如,假设我们离开master它所在的位置,指向H,并I在我们调用的新分支上进行新的提交dev

...--H   <-- master
      \
       I   <-- dev (HEAD)
Run Code Online (Sandbox Code Playgroud)

如果我们然后git checkout master进行新的提交,J我们会得到:

...--H--J   <-- master (HEAD)
      \
       I   <-- dev
Run Code Online (Sandbox Code Playgroud)

请注意,将新提交放入存储库的行为要求我们让 Git 更改其中一个名称。我们提出了新的承诺I中,并取得了Git的更改名称dev哪位用来指向H连同master-所以这dev点(包含散列ID) I。然后我们放入新的提交J,使 Git 更新master指向J而不是H.

(特殊名称HEAD只是附加到我们希望 Git 在运行时更新的分支名称git commit。)

过滤器分支

filter-branch 命令迭代一些提交——通常是所有提交,这取决于你如何使用它;您运行了它,HEAD这意味着当前分支,但也许您只有一个分支名称,master并复制它们。它首先以适当的顺序列出要应用复制过程的每个提交哈希 ID。如果您所拥有的只是一个线性链(如A-B-...-H),那么这些 ID 就是按该顺序排列的。为简单起见,让我们假设这一点。

然后,对于每个这样的提交,过滤分支:

  • 将提交提取到一个临时区域(或假装,以提高速度);
  • 应用您的过滤器;
  • 使用git commit或等效(再次取决于过滤器)进行新的提交,保留每个未更改的位,但保留所做的任何更改。

如果新提交与原始提交 100% 逐位相同,则新的哈希 ID原始哈希 ID。假设这发生在A自己身上:不需要进行任何更改,因此 Git 会重新使用 ID。回购内容现在看起来像这样:

A--B--C--D--E--F--G--H   <-- [original master]
 .
  ...<-- [new master, being built]
Run Code Online (Sandbox Code Playgroud)

然后 Git 移动到列表中的下一个提交哈希 ID,即B. 假设过滤器这次进行了一些更改(删除了一个大文件),以便新提交有一个新的、不同的哈希 ID,我们称之为B'

A--B--C--D--E--F--G--H   <-- [original master]
 \
  B'  <-- [new master, being built]
Run Code Online (Sandbox Code Playgroud)

过滤器分支移至C. 即使它C的快照没有改变, filter-branch现在也被迫做一个改变:它必须创建一个新的C',其父是B',因为发生了一些事情B。所以现在我们得到C'

A--B--C--D--E--F--G--H   <-- [original master]
 \
  B'-C'  <-- [new master, being built]
Run Code Online (Sandbox Code Playgroud)

这对所有剩余的提交重复。它们都获得了新的哈希 ID,部分原因可能是快照中的某些内容发生了变化,但肯定是因为它们的父哈希也发生了变化。最后,git filter-branch重写名称 master本身以指向最终复制的提交,H'

A--B--C--D--E--F--G--H   <-- [original master, now in refs/original/]
 \
  B'-C'-D'-E'-F'-G'-H'  <-- master
Run Code Online (Sandbox Code Playgroud)

所有这些都完全发生在您的本地存储库中——没有其他 Git,没有原始存储库的克隆,知道发生了任何这些。

(请注意,如果您执行多个 filter-branch 操作,每个操作都会复制提交链。一些中间结果可能没有实际价值。Git 最终会垃圾收集未使用和无法访问的提交,通常在大约一个月后。由于filter-branch 复制东西,你会看到空间使用增加一点,而不是减少,直到最终的垃圾收集和随后的包文件重建。)

哪里出错了

事情出错的地方绝对不是你想的那样;我认为问题最有可能发生在这里:

之后我点击了 GitHub Desktop 客户端中的同步按钮

我从来没有用过 GitHub Desktop 软件,所以我不能确定它什么时候用。但这最有可能发生在:

[something] 创建了一个名为 Merge remote-tracking branch 'origin/master' 的新提交

因为git filter-branch不会那样做——好吧,除非你写了一个非常复杂的过滤器。什么没有做到这一点是git merge:你连接到另一个GIT中,仍然有原来的A-B-...-H顺序,你的Git设置您origin/master记住他们H,和你的Git运行,他们的连接合并H到你H'

A--B--C--D--E--F--G--H   <-- origin/master
 \                    \
  B'-C'-D'-E'-F'-G'-H'-I  <-- master
Run Code Online (Sandbox Code Playgroud)

哪里I是有两个父级的合并提交

如何修复

现在您拥有的存储库的唯一副本是“双重提交”版本,您需要做的是:

  • 从那个双版本开始。

  • 使用git branch -fgit reset --hard在您的分支名称处移动以指向合并两个单独历史的合并之前的某个提交。

假设您只有一个master并且您现在已将其签出,这git reset是要走的路。(您只能使用git branch -f对分支机构具有HEAD连接,你只能使用git reset在该分支HEAD连接。)查找承诺要保留,即,将过滤,这将是合并的第一父提交,并告诉 Git 使名称master指向该提交,放弃合并。请注意,这将丢失任何未保存的工作;这也假设您没有在合并之上进行任何提交:

$ git reset --hard HEAD~1   # or HEAD^
Run Code Online (Sandbox Code Playgroud)

现在图片看起来更像这样:

A--B--C--D--E--F--G--H   <-- origin/master
 \
  B'-C'-D'-E'-F'-G'-H'  <-- master
Run Code Online (Sandbox Code Playgroud)

这与您在一系列git filter-branch命令之后的基本相同:唯一真正的区别是我们将名称显示origin/master为您的 Git 查找 commit 的方式H。(Gitorigin正在使用它的名字在它的存储库中master查找提交。你的 Git 正在记住他们的作为你的.)Hmasterorigin/master

如果现在一切看起来都不错,那么您剩下的工作就是说服他们的Git(最后一个)origin接受您的新提交链并移动它们的名称master,使其指向 commit H',即您对原始H. 为此,您将使用git push. 然而...

如果你只是运行git push origin master到他们发送您的副本,并要求他们改变他们master指向承诺H',而不是承诺H,他们会说没有。进行该更改将导致他们的Git“忘记”或“放弃” commit H,这将丢失 commit G,将丢失 commit F,依此类推,一直回到您保留的任何提交(如果有)。但是你可以改变你的礼貌要求,如果可以的话,把你master的命令设置master成一个强有力的命令:设置你的 你用git push --force.

仍然由他们 (GitHub) 决定是否遵守,但如果您在 GitHub 上控制存储库,您显然可以进行设置,这样可以了。但是请注意,拥有原始存储库克隆的任何其他人仍然拥有原始A-B-...-H提交链。 他们可以合并该链,并礼貌地请求 GitHub 或您接受他们拥有而您没有的提交——他们的合并,以及导致提交H本身的所有内容——并将其合并回您的 master。因此,即使您故意丢弃这些提交,它们也很容易回来困扰您。

(在 Git 中很难永远摆脱某些东西。这通常被认为是一个功能。)