【问题标题】:How to retain pre-rename history while moving few files from one GIT repository to another?如何在将少量文件从一个 GIT 存储库移动到另一个存储库时保留预重命名历史记录?
【发布时间】:2019-03-24 17:56:34
【问题描述】:

问题摘要

我需要将几个文件从一个存储库移动到另一个存储库,同时保留它们的更改历史记录。我已经将它们在源存储库中移动到带有git mv 的专用文件夹中(根据 Greg Bauer 被广泛引用的post,这导致在遵循 Greg 的脚本时,所有预文件夹移动历史都没有复制到目标存储库中。

我在所涉及的每个存储库中都只有 master 分支。

在第一个源存储库的情况下,原始文件用于在移动到专用文件夹之前驻留在根文件夹中。

在第二个源存储库的情况下,(其他)原始文件曾经驻留在一级文件夹中,该文件夹还存储许多其他文件(我不需要移动)。

目标存储库已经有一些我需要保留的其他文件和文件夹,以及它的提交历史。

最后,如果所有内容都复制正确到目标存储库,我需要一种干净的方法从源存储库中删除(隐藏?)原始文件。

2019-03-25 12:00 UTC 更新:关于我的情况的更多详细信息,请关注torek's brilliant explanation

  1. 我曾经是并且现在是所有三个相关存储库(源存储库和一个目标存储库)的唯一用户;每个源存储库都在单个工作站上使用
  2. 一个源代码库托管在 GitHub; GitLab 的另一个(作为私人项目)。目标存储库作为私有项目托管在 GitLab。如果我正确理解“存储库”的具体含义,那么此时没有“存储相同提交的多个存储库”。
  3. 这些存储库的本地“.git”文件夹非常小;最大的磁盘只有 12 MB,2.5K 文件。所以性能似乎不是一个大问题。
  4. 我最感兴趣的是在目标存储库中看到的是:(a) 必要的,有问题的文件的“前后”差异; (b) 足够重要,原始更改的时间戳; (c) 很高兴,原始提交者的姓名(总是我自己)
  5. 在未来的情况下,我需要从私有存储库(包含其他私有文件)迁移到不应提及这些私有文件或其内容的公共存储库。然而,在我今天的具体情况下,这不是一个 casr。

我考虑过的东西——但没有使用“现成的”:

我不熟悉 GIT 存储库的结构,所以 'git ls-files ... | grep ... INDEX_FILE ... git update-index ... 来自 Stage 1, step 5 对我来说听起来很神奇。

来自answer to another question,尚不清楚它是否有助于已移动到专用文件夹中的单个文件(和/或在迁移之前回滚移动是否安全)。

另外,我如何决定不使用/使用these steps

git reflog expire --expire=now --all
git reset --hard
git gc --aggressive
git prune

我还在努力从this post 中的一组 sn-ps 编译单个脚本,这似乎也有点相关。

【问题讨论】:

  • 保留“预重命名历史”和“保留其更改历史”是什么意思? Git 没有每个文件的历史记录。
  • @evolutionxbox 在目标存储库中,我想查看我从源存储库移动的每个文件的完整历史记录,最重要的是在存储库中的原始位置对文件所做的更改(即它的位置是在移动到专门为在存储库之间迁移而创建的专用文件夹之前)。

标签: git


【解决方案1】:

在每种情况下,没有一个答案会让每个人都完全满意。那是因为您确实无法文件历史从一个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 行。

这里重要的事情是:

  • 提交由其哈希 ID 标识,例如,b5101f929789889c2e536d915698f58d5c5c6b7a。这就是宇宙中任何一个 Git 知道它是否有 this 提交的方式:要么你有那个哈希 ID,所以你有 this 提交,或者你没有,所以你不要。

  • 提交列出了一个tree,即保存的快照。

  • 提交列出了其父级或父级的哈希 ID。

this 的意思是 Git 只需要 last 提交的哈希 ID。假设我们用一个像H(代表hash)这样的单个字母来表示这个丑陋的大哈希ID。我们说提交H 存储了其父级的哈希ID,我们将其表示为G,而不是另一个丑陋的大字符串。然后commit H 指向 commit G:

          G <-H

但是G 一个提交。这意味着它存储了 its 父级的哈希 ID,我们可以称之为 F:

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

当然FE 的哈希ID 存储在一个向后看的链中。链可以分叉和重新组合,如果我们向前而不是向后,当我们创建分支时会发生分叉,当我们合并分支时会发生重新组合。但是由于 Git 实际上是向后工作的,所以分叉发生在合并处;当我们用完合并的东西时会发生重新组合:

             I--J
            /    \
...--F--G--H      M--N--...--T   <-- master
            \    /
             K--L

无论如何,这条链 Git 历史。如上图所示,提供链中last 提交的哈希 ID 的项是 分支名称,例如 master

这就是 Git 的全部。 没有文件历史记录,只有提交。我们从 tip 提交开始找到提交,例如 T,我们通过名称找到其哈希 ID,例如 master。我们通过创建一个 parentT 的新提交 U,然后更改名称 master 来向存储库添加新的历史记录(新提交)到新的提交U

提交是不可变的,因为它们的真实名称——它们的哈希 ID——是通过对提交的所有内容运行加密校验和计算得出的。所有。如果我们要接受上面的提交并更改关于它的任何内容 - 例如authorcommitter 行上存储的日期戳,或日志消息,或快照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

  • Git 将父提交快照提取到临时区域。

  • Git 将提交的快照提取到一个临时区域。

    (它并没有真正提取提交,但如果你这样想,它可能更有意义。实际上它只是比较树内哈希 ID,这就足够了。稍后,如果您要求git log 显示差异,它确实会进行部分提取。但这只是一种优化,真的。)

  • 现在git log 比较两个快照。 somefile.ext 改变了吗?如果是这样,显示这个提交。

  • 已显示或未显示此提交,移动到提交的父级。

没有--follow,这就是git log somefile.ext 所做的全部。您会看到一个合成的“文件历史记录”,其中包含文件从父级更改为子级的提交历史的子集。就是这样!您看到的是选定的提交历史记录。如果愿意,您可以调用该“文件历史记录”,但它是根据 Git 实际保存的提交历史记录动态计算的。

添加--follow 告诉git log 再做一件事:在比较两个提交时,检查比较是否表明在父提交中somefile.ext 有一个不同的路径名。例如,如果父提交调用文件oldname.datgit 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 中进行修改,那么我们可以做的是创建两个新提交 IJ只有一个文件。我们可以在任何地方制作它们——所有 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

我们现在可以将提交 IJ 复制到 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

在哪里提交 IJ 有我们想要的文件。

该文件位于 J-then-I-then-stop 历史记录中,其中包含这两个提交。 (git checkout --orphan 技巧确保当我们提交 I 时,它没有父提交——它是根提交,就像我们在新的空存储库中进行的第一次提交一样。记住,所有提交,带有它们唯一的哈希 ID,在 每个 Git 存储库中是通用的:你要么拥有那个提交,带有它的哈希 ID,要么你没有。RepoB 没有它们,现在,在git fetch 之后, RepoB 有它们。)

这些历史显然是不相关的:没有办法从J 跳转到H-and-back 链,反之亦然。但是我们现在可以告诉 Git “嫁给”提交 HJ,创建一个 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 中,然后沿着这条腿向后。该文件存在于IJ 中并且是不同的,所以提交J 被显示。然后 Git 转到I。该文件在I 之前的不存在提交中不存在,因此在I 和提交I 中明显不同。然后没有更多的提交可以返回,所以git log 停止。

请注意,我们可以直接在RepoA 中生成IJ。或者,我们可以将 RepoA 的所有提交 (A-B-C-D) 复制到 RepoB,然后在 RepoB 中创建 IJ,然后删除导致提交 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 下。您现在必须说服所有这些克隆人将他们的masterD 切换到新的替代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 文件.. . 好吧,那么我们实际上让BC相同,所以在我们将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,您现在就知道自己在做什么以及为什么。

【讨论】:

  • 感谢您提供如此详尽的解释,它对您有很大帮助!需要澄清的几件事:(1)我对原始问题添加了一些更新,以便他们使问题更加具体; (2) 我知道这听起来很懒惰,但如果你提出一些命令序列,它会让我的答案更加实用。合成更改相关文件的提交,b。将这些提交合并到目标仓库中,c。对原始仓库进行更改以“隐藏”正在移动的文件; [在下一条评论中继续]
  • [接上一条评论] (3) git filter-branch --prune-empty 创建的提交将仅限于迁移文件,并且不会包含即将保留在源存储库中的文件的快照(请参阅#5 更新问题的原因); (4) 这些合成提交是否包含原始存储库中的原始时间戳和提交注释?
  • 我省略了 (2),因为它已经在链接的答案中;这篇文章正在解释这些答案背后的原则。至于(3)和(4),这取决于您的过滤器。默认设置是尽可能多地保留原始元数据,但某些过滤器会故意让您更改它。树和索引过滤器没有,所以只要你坚持使用它们就可以了。
  • 再次感谢!将尝试从答案中编译脚本。如果我在几个小时或明天在聊天中发布草稿,你介意让我看看吗?
  • 我可能没空,我有一份日常工作:-)
猜你喜欢
  • 2013-06-26
  • 1970-01-01
  • 1970-01-01
  • 2012-05-18
  • 1970-01-01
  • 2011-06-30
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多