在每种情况下,没有一个答案会让每个人都完全满意。那是因为您确实无法将文件历史从一个Git存储库复制到另一个,原因很简单,Git没有文件历史。您不能出于不同但相关的原因从(现有)历史记录中删除文件。但是你可以得到的可能就足够了。
Git 历史是 提交,并且提交是不可变的
正如我之前多次说过的,Git 的raison d'être 是提交。 Git 所做的是存储提交,加上一些额外的东西以使它们更有用。 extra 部分意味着有时,你可以做一些足以满足你想要的事情——尽管这当然取决于你想要什么——或者,也许,你会接受什么。让我们仔细看看提交,看看它们是怎样的历史记录。
每次提交都是一个独立的实体。提交保存了所有文件的完整快照——即提交时的所有文件——以及一些元数据。每个唯一提交都由其哈希 ID 唯一标识。这里是an actual commit from the Git repository for Git itself(@ 改为空格,可能会减少垃圾邮件):
$ git cat-file -p b5101f929789889c2e536d915698f58d5c5c6b7a | sed 's/@/ /'
tree 3f109f9d1abd310a06dc7409176a4380f16aa5f2
parent a562a119833b7202d5c9b9069d1abb40c1f9b59a
author Junio C Hamano <gitster pobox.com> 1548795295 -0800
committer Junio C Hamano <gitster pobox.com> 1548795295 -0800
Fourth batch after 2.20
Signed-off-by: Junio C Hamano <gitster pobox.com>
这当然不是 GitHub 显示它的方式,但它是存储提交的完整的内部 Git 对象。保存的快照是通过tree 行获取的。 parent 行列出了the commit that comes before this commit,它本身就是一个合并提交,所以它有两个parent 行。
这里重要的事情是:
this 的意思是 Git 只需要 last 提交的哈希 ID。假设我们用一个像H(代表hash)这样的单个字母来表示这个丑陋的大哈希ID。我们说提交H 存储了其父级的哈希ID,我们将其表示为G,而不是另一个丑陋的大字符串。然后commit H 指向 commit G:
G <-H
但是G 是一个提交。这意味着它存储了 its 父级的哈希 ID,我们可以称之为 F:
... <-F <-G <-H
当然F 将E 的哈希ID 存储在一个向后看的链中。链可以分叉和重新组合,如果我们向前而不是向后,当我们创建分支时会发生分叉,当我们合并分支时会发生重新组合。但是由于 Git 实际上是向后工作的,所以分叉发生在合并处;当我们用完合并的东西时会发生重新组合:
I--J
/ \
...--F--G--H M--N--...--T <-- master
\ /
K--L
无论如何,这条链是 Git 历史。如上图所示,提供链中last 提交的哈希 ID 的项是 分支名称,例如 master。
这就是 Git 的全部。 没有文件历史记录,只有提交。我们从 tip 提交开始找到提交,例如 T,我们通过名称找到其哈希 ID,例如 master。我们通过创建一个 parent 为 T 的新提交 U,然后更改名称 master 来向存储库添加新的历史记录(新提交)到新的提交U。
提交是不可变的,因为它们的真实名称——它们的哈希 ID——是通过对提交的所有内容运行加密校验和计算得出的。所有。如果我们要接受上面的提交并更改关于它的任何内容 - 例如author 或committer 行上存储的日期戳,或日志消息,或快照tree ——我们必须对新数据计算一个新的校验和。该校验和会有所不同,而不是更改 现有 提交H,我们只需要一个新的提交H':
...--F--G--H--I--J <-- master
\
H' <-- need-a-name-here
这个新的提交H' 有G 作为它的父,所以H' 只是一个分支。我们现在必须发明一个分支名称,以便存储新提交 H' 的哈希 ID,它是 H 的副本,但有所更改。我们没有更改任何提交,我们只是添加了一个新提交。
但我可以运行git log --follow somefile.ext,这不是文件历史记录吗?
也许是吧!但它没有存储在 Git 中。 Git 中存储的是提交。 git log 所做的就是从某个分支名称开始,例如 master,并在那里找到提交——分支的 tip 提交。该提交具有哈希 ID、日志消息和快照。当然,Git 能够找到该提交的 parent 提交,保存在提示提交中。
现在是棘手的部分。这一切都发生在一个大循环中,处理每个提交,一次提交一个。 Git 选择是否显示它正在处理的提交,以及git log somefile.ext:
没有--follow,这就是git log somefile.ext 所做的全部。您会看到一个合成的“文件历史记录”,其中包含文件从父级更改为子级的提交历史的子集。就是这样!您看到的是选定的提交历史记录。如果愿意,您可以调用该“文件历史记录”,但它是根据 Git 实际保存的提交历史记录动态计算的。
添加--follow 告诉git log 再做一件事:在比较两个提交时,检查比较是否表明在父提交中somefile.ext 有一个不同的路径名。例如,如果父提交调用文件oldname.dat,git log --follow切换名称,当它在提交历史中后退一步时。
这里有一些问题,尤其是在合并提交方面。合并提交是具有 两个 父级的提交,而不仅仅是一个。 Git 从字面上看不能同时显示两条路径——它在提交历史记录中前进,一次提交一个。因此,当它遇到这些合并时——这是历史分歧的地方,因为 Git 向后工作——它通常只选择历史的一条腿来跟随。
(这里的细节变得相当复杂。请参阅git log 文档的历史简化部分,但它很繁重。当运行 没有 特定文件名时,以显示所有提交, git log 默认情况下会沿着合并的两条条进行,这种方式有点难以正确描述:我们必须在此处引入优先级队列的概念。线性历史,没有合并,避免了所有这些混乱,并且更容易思考。)
现在回到手头的问题
让我们回到最初的、简洁的、对期望结果的总结:
我需要将几个文件从一个存储库移动到另一个存储库,同时保留它们的更改历史记录。
也就是说,我们希望从 RepoA 的提交中获取的文件以某种方式出现在 RepoB 中的提交中。
我们可以立即看到问题:这些文件的历史实际上是 RepoA 中的所有提交,或者充其量是来自 RepoA 的一些提交子集。这些提交中的每一个都是其所有文件的完整快照。
此外,如果我们将这些快照(无论是作为一个整体,还是以某种简化形式)并将它们放在 RepoB 中,那些快照与 任何 不同> RepoB 中的现有快照。让我们举一个简单的具体示例,其中 RepoA 有四个快照 A-B-C-D 在一个很好的线性链中,而 RepoB 有另外四个 E-F-G-H 同样:
RepoA:
A--B--C--D <-- master
RepoB:
E--F--G--H <-- master
如果我们只是将所有从 RepoA 的提交原封不动地复制到 RepoB,我们会在 RepoB 中得到这个:
E--F--G--H <-- master
A--B--C--D <-- invent-a-name-here
这显然不是我们想要的。我们可以做一些事情,这就是你一直在寻找的所有答案。
我们可以在这里做什么
如果我们希望 somefile.ext 脱离 RepoA,并且它首先在提交 B 中创建,然后在提交 D 中进行修改,那么我们可以做的是创建两个新提交 I和J,只有一个文件。我们可以在任何地方制作它们——所有 Git 都是平等的——所以让我们通过克隆 RepoA 来制作 RepoC,然后在 RepoC 中制作它们,主要是为了说明:
$ git clone <url-of-RepoA> repo-c
$ cd repo-c
$ git checkout --orphan for-transplanting
$ git rm -rf . # empty the index and work-tree
$ git checkout <hash-of-B> -- somefile.ext # get the first copy of the file
$ git commit -m 'initial commit of somefile.ext' # and commit it
$ git checkout master -- somefile.ext # get the 2nd and last copy
$ git commit -m 'update somefile.ext' # and commit that one
现在 RepoC 包含:
A--B--C--D <-- master, origin/master
I--J <-- for-transplanting
我们现在可以将提交 I 和 J 复制到 RepoB:
$ cd <path-to-repo-B>
$ git fetch <path-to-repo-C> for-transplanting:for-transplanting
这在 RepoB 中为我们提供了这个:
E--F--G--H <-- master
I--J <-- for-transplanting
在哪里提交 I 和 J 有我们想要的文件。
该文件位于 J-then-I-then-stop 历史记录中,其中包含这两个提交。 (git checkout --orphan 技巧确保当我们提交 I 时,它没有父提交——它是根提交,就像我们在新的空存储库中进行的第一次提交一样。记住,所有提交,带有它们唯一的哈希 ID,在 每个 Git 存储库中是通用的:你要么拥有那个提交,带有它的哈希 ID,要么你没有。RepoB 没有它们,现在,在git fetch 之后, RepoB 有它们。)
这些历史显然是不相关的:没有办法从J 跳转到H-and-back 链,反之亦然。但是我们现在可以告诉 Git “嫁给”提交 H 和 J,创建一个 new 提交 K:
$ git checkout master
$ git merge --allow-unrelated-histories for-transplant
这使用(不存在,通过empty tree 伪造)真正空 提交作为合并基础,因此H 中的所有文件都是新创建的,@987654408 中的所有文件@(只是一个文件)是(是)新创建的。它结合了这些更改——将所有文件添加到任何内容和将somefile.ext 添加到任何内容——这很容易做到,将这些更改应用于没有文件的空树,并将结果提交为新的提交K:
E--F--G--H--K <-- master
/
I---------J <-- for-transplanting
现在可以通过查看K 找到新文件somefile.ext 的合成“文件历史记录”,发现该文件存在于J 但不存在于H 中,然后沿着这条腿向后。该文件存在于I 和J 中并且是不同的,所以提交J 被显示。然后 Git 转到I。该文件在I 之前的不存在提交中不存在,因此在I 和提交I 中明显不同。然后没有更多的提交可以返回,所以git log 停止。
请注意,我们可以直接在RepoA 中生成I 和J。或者,我们可以将 RepoA 的所有提交 (A-B-C-D) 复制到 RepoB,然后在 RepoB 中创建 I 和 J,然后删除导致提交 A-B-C-D 的所有名称痕迹。现在未使用/未引用的提交最终将真正消失(通常在 30 天后的某个时间),同时您将看不到它们,它们也不会打扰您;他们只会消耗一点点磁盘空间。使用RepoC 的真正优势是我们可以在那里进行实验,如果出现问题,只需将整个事情都解决掉,然后重新开始。
现在你有一个更难的问题
最后,如果所有内容都复制正确到目标存储库,我需要一种干净的方式从源存储库中删除(隐藏?)原始文件。
没有。只有肮脏的方式。 脏到什么程度,或者脏到什么程度,取决于你的需要。
同样,原始存储库具有其所有提交。 所有他们都有所有个文件。在我们的示例中,我们做了一个简化的假设,即有四个提交:
A--B--C--D <-- master
somefile.ext首先出现在B中,在D中保持不变,然后以不同的内容存储在D中。
由于文件不在A 中,您可以保留提交A。但是您必须构建一个替换 B',它类似于 B — 与以前一样具有相同的元数据,包括父 A — 但保存的快照省略了该文件:
A--B--C--D <-- master
\
B' <-- ??? (we'll get to this)
在从B 生成B' 之后,您现在需要进行一个新的提交C',它类似于C,除了两件事:
- 它的父级是
B'而不是B,并且
- 它省略了
somefile.ext
一旦您制作了 C' 的 C 的副本,您就有:
A--B--C--D <-- master
\
B'-C' <-- ??? (we'll get to this)
现在你必须以同样的方式将D复制到D':
A--B--C--D <-- master
\
B'-C'-D' <-- ??? (we'll get to this)
现在是时候讨论问号中的分支名称是什么了。
显而易见的做法是从提交D 中剥离分支名称master 并使其指向D':
A--B--C--D [abandoned]
\
B'-C'-D' <-- master
现在出现并查看此存储库的任何人都将从 name master 开始获取 D' 的哈希 ID。他们甚至不会注意到D' 的哈希ID 与D 完全不同。他们将查看D' 并返回C',然后从那里查看B',然后返回A。
好吧,几乎任何人。如果另一个 Git 出现怎么办?如果那个 other Git 已经有A-B-C-D 怎么办? 那个 Git 拥有它们并且通过它们的哈希 ID 了解它们。哈希 ID 是 Git 交易所的通用货币。
可能出现的其他 Git 是 any 您从原始存储库中复制的。 所有 RepoA 的克隆都具有原始哈希 ID,列在它们自己的名称 master 下。您现在必须说服所有这些克隆人将他们的master 从D 切换到新的替代D'。
如果您愿意这样做(他们也愿意),那么您就有了答案:对 RepoA 这样做并让每个人都转换。只剩下必要的机制:如何你将如何对 RepoA 执行此操作,并且就此而言,如果你不这样做,如何将正确的提交提交给 RepoC手动?
git filter-branch
Git 有一个内置命令可以执行此操作:git filter-branch。 filter-branch 命令通过 复制 提交来工作。从逻辑上讲(尽管除了最慢的过滤器--tree-filter,物理上没有),filter-branch 所做的是:
- 检查每个提交;
- 应用您的过滤器;
-
map 根据 map-so-far 的原始提交的父哈希;和
- 从过滤后的结果构建一个新的提交,然后在映射中输入 。
如果新的提交是 100%,与 原始 提交逐位相同,则它最终 成为 原始提交。映射条目说提交A 仍然是提交A。提交B 的过滤器进行了更改——它删除了文件。所以下一次提交的父级是A(因为A映射到A)但是新的提交得到一个新的哈希ID,B',现在映射显示A=A但是@ 987654487@=B'。现在发生了 C 的过滤器,删除文件并使新提交的父级成为 B',因此结果是新提交 C' 并进入映射。最后,D 的过滤器发生,新提交 D' 与父 C'。
现在所有的提交都被过滤了,git filter-branch 使用构建的映射来替换存储在master 下的哈希 ID。地图上说D 变成了D',所以filter-branch 将D' 的散列存储在master 的名称下,我们得到了我们想要的。
同样的技术也可以用在 RepoC 中。请记住,RepoC 是暂时的,我们可以在其中进行任何我们喜欢的破坏。除了删除somefile.ext,我们想要在过滤器中删除所有exceptsomefile.ext。我们几乎肯定也需要--prune-empty 参数。
--prune-empty 所做的足以描述。让我们从没有 --prune-empty 的情况开始。在复制过程中,每个原始提交都会被复制到一个新提交中。这是真的即使新的提交,在应用过滤器之后,没有做任何改变。如果我们有一个像C 这样的提交不接触somefile.ext,它可能会接触其他文件。 (Git 通常不会让您连续进行两次具有相同内容的提交——您必须使用 git commit --allow-empty 才能做到这一点。)但是如果我们删除所有 other 文件.. . 好吧,那么我们实际上让B 和C 是相同,所以在我们将B 复制到B' 之后只有 somefile.ext,我们会将C 复制到C' 以仅 somefile.ext。两个副本将匹配。默认情况下,filter-branch 无论如何都会生成C',因此C 可以映射到。
添加 --prune-empty 告诉 Git:不要创建 C',只需将 C 映射到 B'。 当我们这样做时,我们得到的正是我们想要的:Git 没有根本不生成A',而是从B 生成B'(我们将其称为I),而I 具有no 父级,不 生成C',并使用D 生成D'——我们称之为J——使用B',呃,I,作为其父级:
RepoC:
A--B--C--D [abandoned]
I-----J <-- master
还有什么事情要做
剩下的就是弄清楚如何为git filter-branch 编写过滤器。这就是您正在阅读的现有答案的内容。
易于使用的过滤器是--tree-filter。当你使用这个过滤器时,Git 会在一个临时目录中运行你的 shell 脚本片段。该临时目录包含来自被过滤的提交的所有文件(但没有.git 目录,并且不是您的工作树!)。您的过滤器只需要修改文件,或删除一些文件或添加一些文件。 Git 将从您的过滤器在该临时目录中留下的任何内容进行新的提交。
到目前为止,这也是 最慢 过滤器。在大型存储库上使用它时,请准备好等待数小时或数天。 (它有助于使用-d 参数将git filter-branch 指向一个基于内存的“文件系统”来完成所有工作,但它仍然很慢。)所以大多数答案都集中在弄清楚如何调动另一个更快的过滤器来完成这项工作。
您可以选择使用它们,或者使用非常慢的--tree-filter。无论哪种方式,如果您使用 filter-branch,您现在就知道自己在做什么以及为什么。