0


Git | 如何在保留历史记录的情况下,把代码从一个仓库搬运到另一个仓库?

算是一篇踩坑记录,或者一篇爬坑教程吧

(之所以称之为坑,是因为大多数情况下,直接复制代码就完事了

需求

现有两个Git仓库,A和B,A是项目的主要仓库,为了方便管理,现在要把A中功能较为独立的一块代码搬运到B,同时,希望能够保留这部分代码的历史记录。

解决思路

最简单的情况

先不考虑其他因素,只考虑“把A仓库的代码和历史记录一起搬运到B仓库”这个需求,很快就能想到一个解决方案:

# Step 1: 克隆一份A的本地副本,以下操作均在该仓库A的本地副本内执行
git clone <repo A 的地址>

# Step 2: 将仓库B设置为该git本地仓库的一个远端
git remote add repoB <repo B 的地址>

# Step 3: 把不想搬过去的代码都删掉,并commit

# Step 4: 执行git push,把所有仓库A的历史记录推到仓库B的code-from-A分支
git push repoB --set-upstream HEAD:code-from-A

如果你要处理的只是一个内容简单的个人项目,那么以上操作就足以满足需求了。

然而,现实总是残酷的,实际的应用场景不会这么简单,我们还要考虑以下几个问题:

  1. 对于仓库B来说,它只关心要搬运的一小块代码,但是上面的做法却推了整个仓库A的历史记录上去,存在数据冗余。

  2. 仓库B中,code-from-A分支与master分支不同基(not sharing the same base),因此,如果要把code-from-A merge到master,会报unrelated history的错。如果是个人项目的仓库还好,我们还可以通过配置allow unrelated history绕开这个问题,但在管理比较严格的企业里,这是不被允许的。这就迫使我们对code-from-A进行rebase。

  3. 如果仓库B并不是空白仓库,在rebase过程中极有可能发生conflict。冲突重灾区是build.gradle, README等名字烂大街的文件。而仓库A作为一个合格的企业项目,作为一座代代员工呕心沥血堆成的千层金山,可能有几千个commit,一个个手动解决冲突得干到天荒地老。

  4. 以上三个问题都指向一点,我们在往仓库B push仓库A的内容的时候,应该尽量保持历史记录的简洁,不要把无关紧要的commit也一起放上去。也就是说,我们希望对仓库A的历史记录进行一定的删减。

我们可以借助git filter-branch命令来完成这一操作,它的功能是可以根据一定条件对某条分支的历史记录进行重写。该命令提供了不同类型的filter,它们的区别在于关注点不同,举个例子,msg-filter可以用来重写commit message,env-filter可以用来重写作者、提交时间等信息。对于本文所关心的问题,我们可以用到的有subdirectory-filter和index-filter两种。

*注:根据官方文档的说法,更推荐使用git filter-repo来代替filter-branch,本人由于公司内部环境不允许,因此没研究过。感兴趣的小伙伴可以尝试一下。

subdirectory-filter

首先介绍subdirectory-filter,它的功能是遍历指定branch,只保留与指定目录相关的代码与历史记录。

--subdirectory-filter <directory>
Only look at the history which touches the given subdirectory. The result will contain that directory (and only that) as its project root
英文版解释引自官方文档

示例如下:

git filter-branch --subdirectory-filter module-a --prune-empty -f

# 执行前:
# src/
#   .git/
#   module-a/
#     src1/
#     src2/
#   module-b/
#     src3/
# 执行后:
# src/
#   .git/
#   src1/
#   src2/   

subdirectory-filter的缺点很明显,就是只能作用于单个目录,如果我们想保留的文件散落在好几个不同的目录下,或者说过滤规则较为复杂,那么这个过滤器是没有能力办到的。

另外还有一个不太容易发现但很致命的缺点,就是无法追踪被rename过的文件。假设有一个文件曾经位于module-b下面,后来才被转移到module-a,通常来说,git log或git blame是能够推断出该文件存在于module-b时期的历史记录的。然而subdirectory-filter没那么智能,根据module-a执行过滤会导致丢失该文件在module-b时期的历史记录。

如果你不在乎这两个缺点,那么subdirectory-filter是最好的选择,因为它的用法很简单。

index-filter

接下来讲讲index-filter,顾名思义,它作用于git的暂存区(stage/index)。用大白话来描述它的行为,就是先逐个遍历branch上的每个commit,把commit的内容放到暂存区,然后对暂存区施加我们自己指定的这个<command>指令。

--index-filter <command>
This is the filter for rewriting the index. It is similar to the tree filter but does not check out the tree, which makes it much faster. Frequently used with git rm --cached --ignore-unmatch ...
, see EXAMPLES below.
英文版解释引自官方文档

示例如下:

git filter-branch --index-filter 'git rm --cached --ignore-unmatch filename' HEAD --prune-empty -f

# 作用:遍历HEAD分支上的每个commit,删除与文件filename相关的记录
# 插播一句,因为index-filter是作用于暂存区的,所以要用git rm --cached而不是rm;反之,对于tree-filter,因为它是作用在工作区的,所以要用rm而不是git rm
# --prune-empty:如果一个commit中包括的所有文件都被删掉了,那么这个空commit本身也会被清除
# -f:无视执行过程中产生的临时文件夹.git-rewrite/是否为空

总的来说,index-filter除了稍微有点复杂,还是很好用的(非常全能)。理论上<command>这个位置可以安排所有作用于暂存区的命令,比如git add,git update-index等,再不济还可以结合管道、shell脚本一起使用。

精确控制要过滤的文件

在我实际工作的项目中,存在一个很坑爹的情况:要搬运的那些文件曾经被移动、重命名过很多次,复杂的历史变迁甚至已经没人记得清楚了。比如a.txt这个文件,它的移动轨迹可能是这样的:src1/a.txt -> xxx/a.txt -> module-b/a.txt -> module-a/src1/a.txt

假设我们的需求是将module-a搬运到新的仓库,并保留里面文件完整的历史记录。由于诸如a.txt这样的文件曾经属于其他目录,因此简单地通配符移除module-a之外的文件夹(git rm --cached module-b* src1* xxx*)无法达成我们的目的,因为这会导致src1/a.txt,xxx/a.txt,和module-b/a.txt时期记录的丢失。

因此,我们的需求变成了,列出当前module-a里的所有文件,以及所有他们曾用过的路径和名称(哪怕这些曾用名不属于module-a)。

为了达成这个目的,我没有找到特别简单直白的方式,因此解决方案大致分成两步:先借助git log找到所有rename记录;再利用dfs从中筛选出需要的内容。

下面的这些操作我是借助python脚本来完成的,当然,如果数据量少,也可以人肉操作。

# 拿到所有rename相关的记录,并过滤出文件名
git log --diff-filter=R | grep rename

# 以上命令的输出大概会长这样:
# rename {src1 => xxx}/a.txt
# rename {xxx => module-b}/a.txt
# rename {module-b => module-a/src1}/a.txt
# 经过(人工or脚本)处理,这些缩写可以被展开为
# src1/a.txt => xxx/a.txt
# xxx/a.txt => module-b/a.txt
# module-b/a.txt => module-a/src1/a.txt
# 眼尖的朋友们一定发现了,这不就是张有向图嘛!因此,我们可以用邻接表的形式存储上面的信息,
# 另外,因为我们等会要从文件的最新名称倒推历史名称,我在存储的时候把箭头指向反过来了
# xxx/a.txt -> src1/a.txt
# module-b/a.txt -> xxx/a.txt
# module-a/src1/a.txt -> module-b/a.txt

# 接下来,利用git ls-files列出当前module-a下所有文件的名称
git ls-files | grep -E "^module-a"

# 拿到当前module-a下的文件名列表后,我们就可以用DFS算法从rename记录图中读取每个文件所有的历史名称了

到这一步,我们已经拿到了“module-a下所有的文件名称”+“这些文件的所有历史名称”,组合起来,就是一个包括所有需要保留历史记录的文件名的列表。

接下来就可以借助index filter来过滤我们想要的信息了。在这里需要解释一下,因为index filter+git rm最为常见,加上到这里我已经懒得花时间去研究其他命令结合index filter的用法了(下班的召唤),因此我的过滤逻辑是“删掉所有不需要的文件”,而不是“保留所有需要的文件”。

这就需要多做一步:拿到所有被git管理的文件名,用这个列表减去我们要保留的文件名的列表,就可以得到所有要删除的文件名的列表。在python里的实现非常简单,就是集合减法。

为了拿到所有被git管理的文件名,我们可以使用rev-list命令:

# 用rev-list拿到所有git对象名称,并过滤出blob类型的对象
# 科普:tree对象表示目录,blob对象表示文件
git rev-list --objects --all | grep blob

最后,我把所有要删除的文件名称存在了一个文件to_delete.txt里(因为太多了,两千多个),然后执行index filter就可以啦:

git filter-branch --index-filter "cat to_delete.txt | xargs git rm --cached --ignore-unmatch" --prune-empty -f 

到这里我们就得到了一份干净的、准备迁移的历史记录。然后根据本文《最简单的情况》一节中介绍的方式,把这份记录推到目标仓库就可以了。

标签: git github

本文转载自: https://blog.csdn.net/Q93068/article/details/129642854
版权归原作者 Iulia 所有, 如有侵权,请联系我们删除。

“Git | 如何在保留历史记录的情况下,把代码从一个仓库搬运到另一个仓库?”的评论:

还没有评论