如何将某些文件从一个git仓库移动到另一个(不是克隆),并保留历史记录。

653

我们的Git仓库最初是单个巨大的SVN仓库的一部分,其中每个项目都有自己的树形结构,如下所示:

project1/branches
        /tags
        /trunk
project2/branches
        /tags
        /trunk

显然,使用svn mv很容易将文件从一个位置移动到另一个位置。但是在Git中,每个项目都在自己的存储库中,今天我被要求将project2的子目录移动到project1中。我做了类似于以下的操作:

$ git clone project2 
$ cd project2
$ git filter-branch --subdirectory-filter deeply/buried/java/source/directory/A -- --all
$ git remote rm origin  # so I don't accidentally overwrite the repo ;-)
$ mkdir -p deeply/buried/different/java/source/directory/B
$ for f in *.java; do 
>  git mv $f deeply/buried/different/java/source/directory/B
>  done
$ git commit -m "moved files to new subdirectory"
$ cd ..
$
$ git clone project1
$ cd project1
$ git remote add p2 ../project2
$ git fetch p2
$ git branch p2 remotes/p2/master
$ git merge p2 # --allow-unrelated-histories for git 2.9+
$ git remote rm p2
$ git push

但这似乎相当复杂。一般情况下有更好的方法来做这样的事情吗?或者我采取了正确的方法吗?

请注意,这涉及将历史记录合并到现有存储库中,而不是仅从另一个存储库的一部分创建新的独立存储库(如在早期的问题中)。


1
这对我来说听起来是一个合理的方法;我想不出任何明显的方法来显著改进你的方法。很好,Git确实使这变得容易(例如,在Subversion中,我不想尝试在*不同的存储库之间移动文件目录)。 - Greg Hewgill
1
@ebneter - 我已经手动完成了这个操作(将一个svn仓库的历史记录移动到另一个仓库),使用了shell脚本。基本上,我将特定文件/目录的历史记录(差异、提交日志消息)重新应用到第二个仓库中。 - Adam Monsen
1
我想知道为什么你不用 git fetch p2 && git merge p2 而是用 git fetch p2 && git branch .. && git merge p2?编辑:好的,看起来你想在一个名为p2的新分支中获取更改,而不是当前分支。 - Lekensteyn
1
有没有办法防止 --filter-branch 破坏目录结构?那个 "git mv" 步骤会导致一个包含文件删除和创建的大量提交。 - Edward Falk
9
2021年,使用git filter-repo是进行此操作的正确工具,而非filter-branch - Ed Randall
显示剩余7条评论
16个回答

438
如果你的历史记录是正常的,你可以将提交记录提取为补丁,并在新的代码库中应用它们。
cd repository
git log \
  --pretty=email \
  --patch-with-stat \
  --reverse \
  --full-index \
  --binary \
  -m \
  --first-parent \
  -- path/to/file_or_folder \
  > patch
cd ../another_repository
git am --committer-date-is-author-date < ../repository/patch 

或者用一行来表示
git log --pretty=email --patch-with-stat --reverse --full-index --binary -m --first-parent -- path/to/file_or_folder | (cd /path/to/new_repository && git am --committer-date-is-author-date)

提示:如果源项目的子目录中的提交应该提取到一个新的存储库根目录中,可以给git am传递一个参数,比如-p2,以从补丁中删除额外的目录。
(摘自Exherbo文档

40
对于我需要移动的三到四个文件,这比已被接受的答案更为简单。最终,我使用查找替换在补丁文件中裁剪路径,以适应我的新代码库目录结构。 - Rian Sanderson
5
这是我一直在使用的另一种类似方法:http://blog.neutrino.es/2012/git-copy-a-file-or-directory-from-another-repository-preserving-history/。 - Karol
11
对于已移动/重命名的文件无法工作。我认为你需要对每个文件制作单独的补丁,并在git log命令中添加--follow选项(该选项仅适用于单个文件)。 - Daniel Golden
11
历史记录中的合并提交会破坏 "am" 命令。你可以在上面的 git log 命令中添加 "-m --first-parent",然后这对我有用。 - Gábor Lipták
8
@Daniel Golden,我已经解决了文件被移动导致的问题(这是由于“git log”中的一个bug引起的,因此它无法同时正确使用“--follow”和“--reverse”)。我使用了这个答案,并且这里有一个完整的脚本,我现在用来移动文件 - tsayen
显示剩余17条评论

93

尝试多种方法将文件或文件夹从一个Git仓库移动到另一个仓库,唯一可靠的方法如下所述。

它涉及克隆要从中移动文件或文件夹的仓库,将该文件或文件夹移动到根目录,重写Git历史记录,克隆目标仓库并直接将带有历史记录的文件或文件夹拉取到该目标仓库中。

第一阶段

  1. Make a copy of repository A as the following steps make major changes to this copy which you should not push!

    git clone --branch <branch> --origin origin --progress \
      -v <git repository A url>
    # eg. git clone --branch master --origin origin --progress \
    #   -v https://username@giturl/scm/projects/myprojects.git
    # (assuming myprojects is the repository you want to copy from)
    
  2. cd into it

    cd <git repository A directory>
    #  eg. cd /c/Working/GIT/myprojects
    
  3. Delete the link to the original repository to avoid accidentally making any remote changes (eg. by pushing)

    git remote rm origin
    
  4. Go through your history and files, removing anything that is not in directory 1. The result is the contents of directory 1 spewed out into to the base of repository A.

    git filter-branch --subdirectory-filter <directory> -- --all
    # eg. git filter-branch --subdirectory-filter subfolder1/subfolder2/FOLDER_TO_KEEP -- --all
    
  5. For single file move only: go through what's left and remove everything except the desired file. (You may need to delete files you don't want with the same name and commit.)

    git filter-branch -f --index-filter \
    'git ls-files -s | grep $'\t'FILE_TO_KEEP$ |
    GIT_INDEX_FILE=$GIT_INDEX_FILE.new \
    git update-index --index-info && \
    mv $GIT_INDEX_FILE.new $GIT_INDEX_FILE || echo "Nothing to do"' --prune-empty -- --all
    # eg. FILE_TO_KEEP = pom.xml to keep only the pom.xml file from FOLDER_TO_KEEP
    

第二阶段

  1. Cleanup step

    git reset --hard
    
  2. Cleanup step

    git gc --aggressive
    
  3. Cleanup step

    git prune
    

您可能希望将这些文件导入到B仓库的一个目录而不是根目录中:

  1. Make that directory

    mkdir <base directory>             eg. mkdir FOLDER_TO_KEEP
    
  2. Move files into that directory

    git mv * <base directory>          eg. git mv * FOLDER_TO_KEEP
    
  3. Add files to that directory

    git add .
    
  4. Commit your changes and we’re ready to merge these files into the new repository

    git commit
    

Stage Three

  1. Make a copy of repository B if you don’t have one already

    git clone <git repository B url>
    # eg. git clone https://username@giturl/scm/projects/FOLDER_TO_KEEP.git
    

    (assuming FOLDER_TO_KEEP is the name of the new repository you are copying to)

  2. cd into it

    cd <git repository B directory>
    #  eg. cd /c/Working/GIT/FOLDER_TO_KEEP
    
  3. Create a remote connection to repository A as a branch in repository B

    git remote add repo-A-branch <git repository A directory>
    # (repo-A-branch can be anything - it's just an arbitrary name)
    
    # eg. git remote add repo-A-branch /c/Working/GIT/myprojects
    
  4. Pull from this branch (containing only the directory you want to move) into repository B.

    git pull repo-A-branch master --allow-unrelated-histories
    

    The pull copies both files and history. Note: You can use a merge instead of a pull, but pull works better.

  5. Finally, you probably want to clean up a bit by removing the remote connection to repository A

    git remote rm repo-A-branch
    
  6. Push and you’re all set.

    git push
    

1
我已经按照这里概述的大部分步骤进行了操作,但似乎只复制了文件或目录在主干上的提交历史记录(而不是来自任何其他分支)。是这样吗? - Bao-Long Nguyen-Trong
1
我按照这些步骤进行了操作(感谢您对细节的关注!),但是我注意到在GitHub上,除了合并提交之外,它不会显示任何文件的历史记录。然而,如果我使用blame或gitk,我可以看到提交历史记录。有什么想法为什么会这样? - Newtang
1
  1. 我猜你的答案类似于 Greg Bayer 的博客 (https://gbayer.com/development/moving-files-from-one-git-repository-to-another-preserving-history/)。
  2. 在第二阶段中,我没有运行前三个命令,我转移到了将文件移动到新目录的步骤。我是否需要将 .git 文件夹也移动到新目录中?
  3. 我不理解第二阶段的“prune”步骤。还有其他分支存在,我不想操作它们。
- Kaushik Acharya
1
在第三阶段的步骤4中应用时,使用命令git pull repo-A-branch repo-B-branch --allow-unrelated-histories时出现以下错误:fatal: Couldn't find remote ref repo-B-branch。但是,在_repo B_中确实存在repo-B-branch分支。 - Kaushik Acharya
3
很遗憾,尽管它看起来是可靠的方法,但这并不可靠。它与其他所有解决方案一样存在同样的问题-它无法保留重命名之前的历史记录。 在我的情况下,第一个提交是我重命名目录/文件时进行的。在那之后的一切都丢失了。 - xZero
显示剩余14条评论

62

是的,关键是使用--subdirectory-filter进行filter-branch操作。你使用它的事实基本证明了没有更简单的方法 - 因为你想要最终只得到一部分(重命名后)文件,并且根据定义这将改变哈希值,所以你别无选择只能重写历史。由于标准命令中没有任何一个(例如pull)会重写历史,因此你无法使用它们来完成这个任务。

当然,你可以改进细节 - 一些克隆和分支并不是必需的 - 但整体方法是好的!很遗憾它很复杂,但当然,git的目的不是让重写历史变得容易。


2
如果您的文件已经通过多个目录移动,并且现在位于其中一个目录中,那么子目录过滤器仍然有效吗?(即,我假设如果我只想移动一个文件,我可以将其移动到自己的子目录中,这样会起作用吗?) - rogerdpack
1
@rogerdpack:不,这不会跟随文件重命名。我相信它看起来像是在移动到所选子目录时创建的。如果您想选择一个文件,请查看filter-branch手册中的--index-filter - Cascabel
11
有没有任何关于如何跟踪重命名的方法? - Night Warrier
3
我认为维护和管理历史记录是Git的主要目标之一。 - artburkart
1
关于以下重命名:https://stackoverflow.com/questions/65220628/git-extract-directory-from-repository-commit-history-prior-to-renaming-is-miss(目前还没有答案,但希望将来会有) - Maciej Krawczyk
1
来自@Tapuzi的git filter-repo答案使这个过程变得更简单,并避免了filter-branch发出的警告所带来的不确定性。 - Ed Randall

46

使用git-filter-repo可以让这变得更简单。

为了将project2/sub/dir移动到project1/sub/dir

# Create a new repo containing only the subdirectory:
git clone project2 project2_clone --no-local
cd project2_clone
git filter-repo --path sub/dir

# Merge the new repo:
cd ../project1
git remote add tmp ../project2_clone/
git fetch tmp master
git merge remotes/tmp/master --allow-unrelated-histories
git remote remove tmp

为了安装这个工具,只需简单地输入以下命令:pip3 install git-filter-repo (更多详细信息和选项请参见 README)

# Before: (root)
.
|-- project1
|   `-- 3
`-- project2
    |-- 1
    `-- sub
        `-- dir
            `-- 2

# After: (project1)
.
├── 3
└── sub
    └── dir
        └── 2

1
git remote addgit merge 之间,您需要运行 git fetch 命令,以使目标存储库了解源存储库中的更改。 - sanzante
1
我在临时克隆(project2)上一次性进行了筛选和重命名:git filter-repo --path sub/dir --path-rename sub:newsub,以获得一个 /newsub/dir 的树形结构。这个工具使整个过程变得非常简单。 - Ed Randall
3
如果文件之前被移动或重命名,这不会自动保留移动/重命名前的历史记录。然而,如果您在命令中包含原始路径/文件名,那么该历史记录将不会被删除。例如: git filter-repo --path CurrentPathAfterRename --path OldPathBeforeRenamegit filter-repo --analyze 会生成一个文件 renames.txt,可以帮助确定这些问题。或者,您可能会觉得像这个脚本有用。 - Joel Leach
1
这也适用于移动单个文件。在 git filter-repo 命令参数中,只需为要移动的每个单独的文件或目录添加一个 --path 参数即可。 - HairOfTheDog
3
在Windows上,注意在git filter-repo --path sub/dir/中使用斜杆(/)而不是反斜杠(\)进行路径设置。否则,它会使你留下一个空文件夹,因为没有匹配项。 - Olivier de Rivoyre
这个回答值得更多的赞。截至2023年2月3日仍然有效。在尝试了许多其他方法之后,我终于找到了这个可行的方法。这个方法真的很好用。虽然其他方法可能不适用于我,但你可以自己尝试。 - pauljohn32

31

我发现Ross Hendrickson的博客非常有用。这是一种非常简单的方法,您可以在新存储库中创建应用的补丁。有关更多详细信息,请参见链接页面。

它只包含三个步骤(从博客复制):

# Setup a directory to hold the patches
mkdir <patch-directory>

# Create the patches
git format-patch -o <patch-directory> --root /path/to/copy

# Apply the patches in the new repo using a 3 way merge in case of conflicts
# (merges from the other repo are not turned into patches). 
# The 3way can be omitted.
git am --3way <patch-directory>/*.patch

我遇到的唯一问题是无法使用xx一次性应用所有修补程序

git am --3way <patch-directory>/*.patch

在Windows下,我收到了一个InvalidArgument错误。因此,我不得不一个接一个地应用所有的补丁。


对我来说不起作用,因为某些时候缺少sha哈希值。这个链接对我有所帮助:https://dev59.com/t2Qm5IYBdhLWcg3wuhHS - dr0i
1
与“git log”方法不同,这个选项对我非常有效!谢谢! - AlejandroVD
3
尝试了不同的方法将项目移动到新的仓库。这是唯一一个对我有效的方法。无法相信这样一个常见的任务必须如此复杂。 - Chris_D_Turk
1
这是非常优秀的解决方案,然而,它再一次遭受了所有其他解决方案相同的问题——在重命名之后它将无法保留历史记录。 - xZero
1
这让我感到困惑,因为所有的日期都丢失了。每个提交都显示为这些步骤运行的日期,而不是原始提交的日期。 - gman
显示剩余2条评论

11

3
如果您在使用git log命令时显示的是彩色输出,那么 grep ^commit 命令可能无法正常工作。如果出现这种情况,请在git log命令中添加 --no-color 参数(例如:git log --no-color $reposrc)。 - Kurt
1
这个对我有用。我在Windows上,在运行git am之前,我必须运行git config core.longpaths true以避免在应用补丁时出现“长文件名”错误。 - Bruno Negrão Zica

9

保留目录名称

子目录筛选器(或更短的命令git subtree)效果很好,但不适用于我,因为它们会从提交信息中删除目录名称。在我的情况下,我只想将一个仓库的部分内容合并到另一个仓库中,并保留完整路径名的历史记录。

我的解决方案是使用tree-filter并从源代码仓库的临时克隆中简单地删除不需要的文件和目录,然后通过5个简单步骤从该克隆版本拉取到我的目标仓库中。

# 1. clone the source
git clone ssh://<user>@<source-repo url>
cd <source-repo>
# 2. remove the stuff we want to exclude
git filter-branch --tree-filter "rm -rf <files to exclude>" --prune-empty HEAD
# 3. move to target repo and create a merge branch (for safety)
cd <path to target-repo>
git checkout -b <merge branch>
# 4. Add the source-repo as remote 
git remote add source-repo <path to source-repo>
# 5. fetch it
git pull source-repo master
# 6. check that you got it right (better safe than sorry, right?)
gitk

这个脚本不会对您的原始仓库进行任何修改。如果映射文件中指定的目标仓库不存在,则此脚本将尝试创建它。 - eQ19
2
我认为保持目录名称不变非常重要。否则,您将在目标存储库中获得额外的重命名提交。 - ipuustin

8

git subtree 直观易懂,还能保留历史记录。

示例用法: 将 git 仓库添加为子目录:

git subtree add --prefix foo https://github.com/git/git.git master

说明:

#├── repo_bar
#│   ├── bar.txt
#└── repo_foo
#    └── foo.txt

cd repo_bar
git subtree add --prefix foo ../repo_foo master

#├── repo_bar
#│   ├── bar.txt
#│   └── foo
#│       └── foo.txt
#└── repo_foo
#    └── foo.txt

1
这是到目前为止最好的、最新的答案。 - Ebram
1
这里有另一个使用git subtree的解决方案,提供了更多细节,答案在这里:https://dev59.com/rmEh5IYBdhLWcg3wfzpE#73743153。 - maximus

7

我发现有类似的需求(尽管只是需要某个存储库中的一些文件),这个脚本非常有用:git-import

简单来说,它可以从现有的存储库中创建给定文件或目录($object)的补丁文件:

cd old_repo
git format-patch --thread -o "$temp" --root -- "$object"

然后将这些应用于新的存储库:

cd new_repo
git am "$temp"/*.patch 

详情请查看:

更新(另一位作者提供)此有用的方法可以通过以下bash函数使用。以下是一个示例用法:

gitcp <Repo1_basedir> <path_inside_repo1> <Repo2_basedir>

gitcp ()
{
    fromdir="$1";
    frompath="$2";
    to="$3";
    echo "Moving git files from "$fromdir" at "$frompath" to "$to" ..";
    tmpdir=/tmp/gittmp;
    cd "$fromdir";
    git format-patch --thread -o $tmpdir --root -- "$frompath";
    cd "$to";
    git am $tmpdir/*.patch
}

5

本回答基于git am提供有趣命令,并通过示例逐步呈现。

目标

  • 您希望将一个或多个文件从一个存储库移动到另一个存储库。
  • 您想保留它们的历史记录。
  • 但是您不关心保留标签和分支。
  • 您接受重命名文件(以及重命名目录中的文件)的有限历史记录。

过程

  1. 使用命令
    git log --pretty=email -p --reverse --full-index --binary以电子邮件格式提取历史记录。
  2. 重新组织文件树并在历史记录中更新文件名更改[可选]
  3. 使用git am应用新的历史记录。

1. 提取电子邮件格式的历史记录

示例:提取file3file4file5的历史记录。

my_repo
├── dirA
│   ├── file1
│   └── file2
├── dirB            ^
│   ├── subdir      | To be moved
│   │   ├── file3   | with history
│   │   └── file4   | 
│   └── file5       v
└── dirC
    ├── file6
    └── file7

清理临时目录 destination
export historydir=/tmp/mail/dir  # Absolute path
rm -rf "$historydir"             # Caution when cleaning

清理您的代码库源代码。
git commit ...           # Commit your working files
rm .gitignore            # Disable gitignore
git clean -n             # Simulate removal
git clean -f             # Remove untracked file
git checkout .gitignore  # Restore gitignore

将每个文件的历史记录提取为电子邮件格式

cd my_repo/dirB
find -name .git -prune -o -type d -o -exec bash -c 'mkdir -p "$historydir/${0%/*}" && git log --pretty=email -p --stat --reverse --full-index --binary -- "$0" > "$historydir/$0"' {} ';'

很遗憾,选项--follow--find-copies-harder不能与--reverse组合使用。这就是为什么在文件被重命名时(或者父目录被重命名时)历史记录会被截断。

之后:以电子邮件格式的临时历史记录

/tmp/mail/dir
    ├── subdir
    │   ├── file3
    │   └── file4
    └── file5

2. 重新组织文件树并更新文件名的历史记录[可选]

假设您想将这三个文件移动到该仓库的其他位置(可以是同一个仓库)。

my_other_repo
├── dirF
│   ├── file55
│   └── file56
├── dirB              # New tree
│   ├── dirB1         # was subdir
│   │   ├── file33    # was file3
│   │   └── file44    # was file4
│   └── dirB2         # new dir
│        └── file5    # = file5
└── dirH
    └── file77

因此,重新组织你的文件:
cd /tmp/mail/dir
mkdir     dirB
mv subdir dirB/dirB1
mv dirB/dirB1/file3 dirB/dirB1/file33
mv dirB/dirB1/file4 dirB/dirB1/file44
mkdir    dirB/dirB2
mv file5 dirB/dirB2

您的临时历史记录如下:

/tmp/mail/dir
    └── dirB
        ├── dirB1
        │   ├── file33
        │   └── file44
        └── dirB2
             └── file5

同时更改历史记录中的文件名:

cd "$historydir"
find * -type f -exec bash -c 'sed "/^diff --git a\|^--- a\|^+++ b/s:\( [ab]\)/[^ ]*:\1/$0:g" -i "$0"' {} ';'

注意: 这将重写历史记录以反映路径和文件名的更改。
      (即在新存储库中更改新位置/名称)


3. 应用新历史记录

您的其他存储库为:

my_other_repo
├── dirF
│   ├── file55
│   └── file56
└── dirH
    └── file77

应用来自临时历史文件的提交:

cd my_other_repo
find "$historydir" -type f -exec cat {} + | git am 

你的另一个代码库现在是:

my_other_repo
├── dirF
│   ├── file55
│   └── file56
├── dirB            ^
│   ├── dirB1       | New files
│   │   ├── file33  | with
│   │   └── file44  | history
│   └── dirB2       | kept
│        └── file5  v
└── dirH
    └── file77

使用git status命令以查看准备推送的提交数量 :-)
注意:由于历史记录已重写,以反映路径和文件名更改:
      (即与先前仓库中的位置/名称进行比较)
  • 无需git mv更改位置/文件名。
  • 无需git log --follow访问完整历史记录。

额外技巧:检测仓库内已重命名/移动的文件

列出已重命名的文件:

find -name .git -prune -o -exec git log --pretty=tformat:'' --numstat --follow {} ';' | grep '=>'

更多自定义选项:您可以使用选项--find-copies-harder--reverse完成命令git log。您还可以使用cut -f3-删除前两列,并使用完整模式'{.* => .*}'进行筛选。
find -name .git -prune -o -exec git log --pretty=tformat:'' --numstat --follow --find-copies-harder --reverse {} ';' | cut -f3- | grep '{.* => .*}'

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