合并两个Git仓库而不破坏文件历史记录

335
我需要将两个Git存储库合并到一个全新的第三方存储库中。我已经找到了许多使用子树合并来完成此操作的描述(例如Jakub Narębski's answer on How do you merge two Git repositories?),并且按照这些说明进行大部分工作,但是当我提交子树合并时,旧存储库中的所有文件都被记录为新添加的文件。当我执行git log时,可以看到旧存储库的提交历史记录,但是如果我执行git log <file>,它仅显示该文件的一个提交 - 子树合并。根据上面答案的评论,我不是唯一遇到这个问题的人,但我没有找到任何公开的解决方案。
有没有办法合并存储库并保留各个文件的历史记录?

我不使用Git,但在Mercurial中,如果需要修复要合并的存储库的文件路径,我会首先进行转换,然后将一个存储库强制拉入目标以获取更改集,然后合并不同的分支。这已经经过测试并且有效 ;)也许这可以帮助找到Git的解决方案...与子树合并方法相比,我想转换步骤是不同的,其中历史记录被重写而不仅仅是映射路径(如果我理解正确)。这样可以确保平稳合并,无需特殊处理文件路径。 - Lucero
我也发现这个问题很有帮助https://dev59.com/Q3I-5IYBdhLWcg3wxbjv - nacross
我创建了一个后续问题。可能会有趣:合并两个 Git 代码库并保留主分支历史记录: https://dev59.com/yZ_ha4cB1Zd3GeqPsQgV - Dimitri Dewaele
对我有效的自动化解决方案是https://dev59.com/O3M_5IYBdhLWcg3wUxdB#30781527。 - xverges
10个回答

371
原来答案要简单得多,如果你只是想把两个仓库粘在一起,让它看起来一直都是这样,而不是管理一个外部依赖。你只需要给你的旧仓库添加远程,将它们合并到你的新主分支,将文件和文件夹移动到一个子目录中,提交这个移动操作,然后对所有其他仓库重复这个过程。子模块、子树合并和高级变基是为了解决稍微不同的问题,不适用于我想要做的事情。
这里是一个示例的 Powershell 脚本,用于将两个仓库粘在一起:
# Assume the current directory is where we want the new repository to be created
# Create the new repository
git init

# Before we do a merge, we have to have an initial commit, so we'll make a dummy commit
git commit --allow-empty -m "Initial dummy commit"

# Add a remote for and fetch the old repo
# (the '--fetch' (or '-f') option will make git immediately fetch commits to the local repo after adding the remote)
git remote add --fetch old_a <OldA repo URL>

# Merge the files from old_a/master into new/master
git merge old_a/master --allow-unrelated-histories

# Move the old_a repo files and folders into a subdirectory so they don't collide with the other repo coming later
mkdir old_a
dir -exclude old_a | foreach { git mv $_.Name old_a }

# Commit the move
git commit -m "Move old_a files into subdir"

# Do the same thing for old_b
git remote add -f old_b <OldB repo URL>
git merge old_b/master --allow-unrelated-histories
mkdir old_b
dir –exclude old_a,old_b | foreach { git mv $_.Name old_b }
git commit -m "Move old_b files into subdir"

显然,如果你愿意的话,你可以将old_b合并到old_a中(这将成为新的合并仓库)- 修改脚本以适应这种方式。
如果你想同时带入正在进行中的功能分支,可以使用以下方法:
# Bring over a feature branch from one of the old repos
git checkout -b feature-in-progress
git merge -s recursive -Xsubtree=old_a old_a/feature-in-progress

这是整个过程中唯一不明显的部分 - 这不是一个子树合并,而是对正常递归合并的一个参数,告诉Git我们重命名了目标,并帮助Git正确地对齐所有内容。
我在这里写了一个稍微详细一点的解释here

23
这个使用 git mv 的解决方案效果不是很好。当你后来在一个移动的文件上使用 git log 时,你只能看到移动的提交记录,之前所有的历史记录都丢失了。这是因为 git mv 实际上是 git rm; git add 的组合,只是一步完成 - mholm815
23
在Git中,这个操作和任何其他的移动/重命名操作一样:你可以通过命令行执行 git log --follow 来获取所有的历史记录,或者所有的GUI工具都会自动为你完成这个操作。使用子树合并的方法时,据我所知,无法获取单个文件的历史记录,因此这种方法更好。 - Eric Lee
6
当旧版本的代码库(old_b)合并后,我遇到了很多合并冲突。这是否是预期的?我看到了"CONFLICT (rename/delete)" 的提示。 - Jon
14
当我尝试输入"dir -exclude old_a | %{git mv $_.Name old_a}"时,出现了"sh.exe": dir: command not found"和"sh.exe": git: command not found"的错误提示。使用下面的命令可以正常工作:"ls -I old_a | xargs -I '{}' git mv '{}' old_a/"。 - George
7
感谢你的提示!在ls命令中使用的是数字1(代表数字一),而在xargs命令中使用的是大写字母'I'。 - Dominique Vial
显示剩余20条评论

204

以下是不需要重写任何历史记录的方式,因此所有提交ID仍然有效。最终结果是第二个repo的文件将保存在一个子目录中。

  1. 将第二个repo作为远程库添加:

    cd firstgitrepo/
    git remote add secondrepo username@servername:andsoon
    
  2. 确保你已经下载了secondrepo的所有提交记录:

  3. git fetch secondrepo
    
  4. 从第二个仓库的分支创建本地分支:

  5. git branch branchfromsecondrepo secondrepo/master
    
  6. 将其所有文件移动到子目录中:

    git checkout branchfromsecondrepo
    mkdir subdir/
    git ls-tree -z --name-only HEAD | xargs -0 -I {} git mv {} subdir/
    git commit -m "Moved files to subdir/"
    
  7. 将第二个分支合并到第一个repo的主分支(master branch)中:

  8. git checkout master
    git merge --allow-unrelated-histories branchfromsecondrepo
    

    您的代码库将有多个初始提交,但这不应该成为问题。


1
第二步对我不起作用:致命错误:无效的对象名称:'secondrepo/master'。 - Keith
@Keith:确保你已经将第二个仓库添加为名为“secondrepo”的远程仓库,并且该仓库有一个名为“master”的分支(您可以使用命令git remote show secondrepo在远程仓库上查看分支)。 - Flimm
我必须进行一次获取以将其下载下来。在1和2之间,我执行了git获取secondrepo。 - sksamuel
@monkjack:我已经编辑了我的答案,包括一个git fetch步骤。将来请随意自行编辑答案。 - Flimm
4
对于旧版本的Git,只需省略--allow-unrelated-histories选项。请参阅此回答帖子的历史记录。 - Flimm
显示剩余22条评论

44
假设你想将代码库 a 合并到 b 中(我假设它们是相邻的):
cd b
git remote add a ../a
git fetch a
git merge --allow-unrelated-histories a/master
git remote remove a

如果您想将a放入子目录中,请在上述命令之前执行以下操作:

cd a
git filter-repo --to-subdirectory-filter a
cd ..

为此,您需要安装git-filter-repo(不建议使用filter-branch)。

合并两个大型存储库的示例,并将其中一个放入子目录中:https://gist.github.com/x-yuri/9890ab1079cf4357d6f269d073fd9731

更多信息在这里


能否在不产生合并冲突的情况下完成它? - Bob
@Mikhail 是的,这是可能的,你在要合并的gist里看到了合并冲突吗?如果你遇到了合并冲突,那意味着你在两个代码库中都有a/b/c这个文件。你可以在合并之前重新命名文件,或将其合并到子目录中,或解决冲突。 - x-yuri
好的。谢谢。解决冲突就行了。 - Bob
1
这是保留文件历史记录而不依赖于“--follow”的完美解决方案,谢谢! - Ishmaeel
1
请注意,git filter-repo 将重写历史记录。如果您不想这样做,可以使用 git mv - Guildenstern
1
点赞。谢谢。我在这里写了一个更详细的例子,包含更多细节 - Gabriel Staples

35
几年过去了,已经有很多经过充分投票的解决方案,但我想分享我的解决方案,因为我想将两个远程代码库合并成一个新的代码库,而且不删除之前代码库的历史记录。
  1. 在Github上创建一个新的代码库。

    enter image description here

  2. 下载新建的代码库并添加旧的远程代码库。

    git clone https://github.com/alexbr9007/Test.git
    cd Test
    git remote add OldRepo https://github.com/alexbr9007/Django-React.git
    git remote -v
    
  3. 从旧仓库中获取所有文件,以创建一个新分支。

  4. git fetch OldRepo
    git branch -a
    

    输入图像描述

  5. 在主分支中,执行合并操作以将旧仓库与新创建的仓库合并。

  6. git merge remotes/OldRepo/master --allow-unrelated-histories
    

    输入图像描述

  7. 创建一个新文件夹,用于存储从OldRepo添加的所有新内容,并将其文件移动到此新文件夹中。

  8. 最后,您可以上传来自合并存储库的文件,并安全地从GitHub中删除OldRepo。

希望这对于任何处理合并远程存储库的人都有用。


10
这是我找到的唯一有效的方法来保留 git 历史记录。别忘了用命令 git remote rm OldRepo 删除旧仓库的远程链接。 - Célia Doolaeghe
4
我对此表示赞同。这是一个完美简单、成功合理的解决方案。谢谢!并感谢@Harubiyori做出的最后一步贡献。 - code4meow
1
我发现这个解决方案很容易理解并且赞成。感谢 @abautista。 - Donnacha
这些步骤与被接受的答案中记录的步骤相同,只是您只需要将其中一个存储库移动到子目录中(而不是两个)。 - Guildenstern

8
请看如何使用:

git rebase --root --preserve-merges --onto

将两个历史记录的早期联系在一起。

如果您有重叠的路径,请使用修复它们。

git filter-branch --index-filter

当你使用日志时,请确保你“更难找到副本”,使用以下方法:

git log -CC

那样,您就能查找路径中文件的任何移动。

2
Git文档建议不要进行变基操作... https://git-scm.com/book/zh/v2/Git-分支-变基#_rebase_peril - Stephen Turner
问题要求进行合并,这可能排除了使用 git-rebase(1) 的可能性。 - Guildenstern

7

我将 @Flimm 的解决方案转化为一个 git 别名,并将其添加到我的~/.gitconfig文件中:

[alias]
 mergeRepo = "!mergeRepo() { \
  [ $# -ne 3 ] && echo \"Three parameters required, <remote URI> <new branch> <new dir>\" && exit 1; \
  git remote add newRepo $1; \
  git fetch newRepo; \
  git branch \"$2\" newRepo/master; \
  git checkout \"$2\"; \
  mkdir -vp \"${GIT_PREFIX}$3\"; \
  git ls-tree -z --name-only HEAD | xargs -0 -I {} git mv {} \"${GIT_PREFIX}$3\"/; \
  git commit -m \"Moved files to '${GIT_PREFIX}$3'\"; \
  git checkout master; git merge --allow-unrelated-histories --no-edit -s recursive -X no-renames \"$2\"; \
  git branch -D \"$2\"; git remote remove newRepo; \
}; \
mergeRepo"

15
只是好奇:你真的经常这样做需要一个化名吗? - Parker Coates
3
不,我不会,但是我总是忘记怎么做,所以起一个别名只是为了让我能够记住它。 - Fredrik Erlandsson
2
是啊,但要尝试更换计算机并忘记移动您的别名 ;) - quetzalcoatl
1
$GIT_PREFIX 的值是什么? - neowulf33
'GIT_PREFIX'被设置为通过从原始当前目录运行'git rev-parse --show-prefix'返回的值。请参见linkgit:git-rev-parse[1]。 - Fredrik Erlandsson

4

这个函数会将远程仓库克隆到本地仓库目录:

function git-add-repo
{
    repo="$1"
    dir="$(echo "$2" | sed 's/\/$//')"
    path="$(pwd)"

    tmp="$(mktemp -d)"
    remote="$(echo "$tmp" | sed 's/\///g'| sed 's/\./_/g')"

    git clone "$repo" "$tmp"
    cd "$tmp"

    git filter-branch --index-filter '
        git ls-files -s |
        sed "s,\t,&'"$dir"'/," |
        GIT_INDEX_FILE="$GIT_INDEX_FILE.new" git update-index --index-info &&
        mv "$GIT_INDEX_FILE.new" "$GIT_INDEX_FILE"
    ' HEAD

    cd "$path"
    git remote add -f "$remote" "file://$tmp/.git"
    git pull "$remote/master"
    git merge --allow-unrelated-histories -m "Merge repo $repo into master" --edit "$remote/master"
    git remote remove "$remote"
    rm -rf "$tmp"
}

如何使用:

cd current/package
git-add-repo https://github.com/example/example dir/to/save

注意。此脚本可以重写提交记录,但会保留所有作者和日期信息,这意味着新的提交记录将具有另一个哈希值,如果您尝试将更改推送到远程服务器,只能通过强制键才能成功,并且它将在服务器上重写提交记录。因此,请在启动之前备份。

受益!


我使用的是zsh而不是bash,以及git的v2.13.0版本。无论我尝试什么,都无法让 git filter-branch --index-filter 正常工作。通常情况下,我会收到一个错误消息,提示 .new索引文件不存在。这让你想起了什么吗? - Patrick Beard
@PatrickBeard 我不熟悉zsh,你可以创建一个名为git-add-repo.sh的单独文件,并在文件末尾添加此行代码 git-add-repo "$@"。之后,你可以在zsh中使用它,例如 cd current/git/packagebash path/to/git-add-repo.sh https://github.com/example/example dir/to/save - Andrey Izman
问题在这里讨论过:https://dev59.com/t1zUa4cB1Zd3GeqP10_w 由于mv "$GIT_INDEX_FILE.new" "$GIT_INDEX_FILE"有时会失败,因此您需要添加一个if test - Patrick Beard
1
我不会使用这种方法!我尝试了脚本,天真地照字面意思去做(这部分只能怪我自己),结果它毁坏了我的本地 git 仓库。历史记录看起来大多正确,但是尝试将它们推回 Github 就出现了可怕的“RPC failed; curl 55 SSL_write() returned SYSCALL, errno = 32”错误。我试图修复它,但是它已经无法挽回了。最终我只能在一个新的本地仓库中重建一切。 - Mason Freed
@MasonFreed 这个脚本创建了一个新的 Git 历史记录,其中包含两个仓库的混合内容,因此它无法推送到旧仓库,需要创建一个新仓库或使用强制键进行推送,这意味着它会重写服务器上的你的仓库。 - Andrey Izman

4

如何合并一个或两个或多个仅在本地(或在本地克隆的远程)仓库到另一个仓库,并保留git历史记录

我需要一些与其他答案不同的东西,而且不需要安装任何新工具,并且更详细地了解这里发生了什么,所以这是我的答案:

假设您拥有以下本地文件结构。这里的两个仓库可能只在本地存储,也可能在远程URL上。无论哪种方式,指令都是相同的,只要它们也在本地存储,也就是说,如果它们是远程仓库,您已经将它们在本地克隆。

您当前拥有以下目录结构:

repo1repo2是独立的、单独的,在本地存储或克隆的git仓库。

repo1/
    .git/
    .gitignore
    (other files and folders)

repo2/
    .git/
    .gitignore
    (other files and folders)

你想要的是最终得到这个结果:
new_repo里,repo1repo2只是文件夹(不是仓库或子仓库/子模块)。
repo1/      # will be deleted when done
    ...

repo2/      # will be deleted when done
    ...

new_repo/
    .git/
    .gitignore
    repo1/
        (other files and folders)
    repo2/
        (other files and folders)

详细说明

准备旧的仓库。这可以用于合并任意数量的仓库,无论是1个、2个还是100个仓库。在这个例子中,我将只合并两个仓库,repo1和repo2。为了将它们准备好合并到一个单独的外部仓库中,并保留它们的历史记录,我们首先需要将它们的内容放入与它们的仓库名称相同的子目录中。
所以,我们将从这个状态:
repo1/ .git/ .gitignore (其他文件和文件夹)
repo2/ .git/ .gitignore (其他文件和文件夹)
变成这个状态:
repo1/ .git/ repo1/ .gitignore (其他文件和文件夹)
repo2/ .git/ repo2/ .gitignore (其他文件和文件夹)
运行以下命令来完成此操作:
# 1. 修复repo1
cd path/to/repo1 mkdir repo1 # 将所有非隐藏文件和文件夹移动到`repo1/` mv * repo1/ # 将所有隐藏文件和文件夹移动到`repo1/` mv .* repo1/ # 现在将.git目录移回原来的位置,因为它被上面的命令移动了 mv repo1/.git . # 将所有更改提交到该仓库 git add -A git status git commit -m "将所有文件和文件夹移动到子目录中"
# 2. 修复repo2(与上面的过程相同,只是使用`repo2`代替`repo1`)
cd path/to/repo2 mkdir repo2 mv * repo2/ mv .* repo2/ mv repo2/.git . git add -A git status git commit -m "将所有文件和文件夹移动到子目录中"
如果需要,创建new_repo。如果该仓库已经存在,那么可以直接使用,跳过此步骤。如果需要创建一个全新的仓库,可以按照以下步骤进行:
# 创建new_repo
cd path/to/parentdir_of_repo1_and_repo2 mkdir new_repo cd new_repo
git init # (可选,但建议)将主分支从`master`重命名为`main` git branch -m main # 如果之前没有在全局设置过姓名和电子邮件,请在此仓库中设置 git config user.name "First Last" git config user.email firstlast@gmail.com
# 创建一个空的初始提交以开始git历史记录 git commit --allow-empty -m "初始空提交"
将我们修复好的repo1和repo2仓库的历史记录和内容合并到new_repo中。
cd path/to/new_repo
# -------------------------------------------------------------------------- # 1. 合并repo1的所有文件、文件夹和git历史记录到new_repo中 # --------------------------------------------------------------------------
# 将repo1添加为名为`repo1`的本地“远程”仓库 # - 注意:这假设new_repo、repo1和repo2都位于同一目录级别,并且在同一个父文件夹中。如果不是这种情况,没问题。只需将下面的`"../repo1"`更改为正确的相对路径或绝对路径!例如:"path/to/repo1"。 git remote add repo1 "../repo1" # 查看所有远程仓库 # - 现在你会看到`repo1`作为一个远程仓库,它指向了"../repo1"的本地“URL” git remote -v
# 将repo1的所有文件和历史记录获取到new_repo的.git目录中 # - 注意,这里的`repo1`是你刚刚添加的远程别名的名称 git fetch repo1 # 查看刚刚为你创建的新的本地存储的远程跟踪隐藏分支 # -

参考资料

  1. 我大部分的指导都是从这里学到的:https://blog.jdriven.com/2021/04/how-to-merge-multiple-git-repositories/
  2. 这个非常相似的答案,由@x-yuri提供
  3. Super User: 如何使用cp命令复制包括隐藏文件、隐藏目录及其内容?

另请参阅

除了上述参考资料外,还可以参考:

  1. https://gfscott.com/blog/merge-git-repos-and-keep-commit-history
  2. 主要答案,由@Eric Lee提供

2
跟随以下步骤将一个仓库嵌入到另一个仓库中,通过合并两个git历史记录来拥有一个单一的git历史记录。
  1. 克隆要合并的两个仓库。

git clone git@github.com:user/parent-repo.git

git clone git@github.com:user/child-repo.git

  1. 进入子仓库

cd child-repo/

  1. 运行下面的命令,用目录结构替换路径my/new/subdir(出现3次),这是您想要放置子仓库的目录结构。

git filter-branch --prune-empty --tree-filter ' if [ ! -e my/new/subdir ]; then mkdir -p my/new/subdir git ls-tree --name-only $GIT_COMMIT | xargs -I files mv files my/new/subdir fi'

  1. 进入父仓库

cd ../parent-repo/

  1. 向父仓库添加一个指向子仓库路径的远程仓库
将子仓库添加为远程仓库:

git remote add child-remote ../child-repo/

  1. 获取子仓库

git fetch child-remote

  1. 合并历史记录

git merge --allow-unrelated-histories child-remote/master

如果现在检查父仓库的 git 日志,应该已经合并了子仓库提交。您还可以看到标记,指示提交来源。

以下文章帮助我将一个仓库嵌入到另一个仓库中,通过合并两个 git 历史记录来获得一个单一的 git 历史记录。

http://ericlathrop.com/2014/01/combining-git-repositories/

希望这可以帮到你。 编码愉快!

第三步出现了语法错误,导致失败。缺少分号。请修复 git filter-branch --prune-empty --tree-filter ' if [ ! -e my/new/subdir ]; then mkdir -p my/new/subdir; git ls-tree --name-only $GIT_COMMIT | xargs -I files mv files my/new/subdir; fi' - Yuri L

1

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接