将子目录从现有Git仓库中分离出来,创建一个独立的Git仓库

1975

我有一个 Git 仓库,其中包含许多子目录。现在我发现其中一个子目录与其他目录无关,应该将其分离到一个单独的仓库中。

在保持子目录文件历史记录的同时,我该如何做呢?

我想我可以克隆一份并移除每个克隆体中不需要的部分,但是我觉得这样会在检出旧版本时给我整个树形结构。或许这样也可以接受,但我更希望能够假装这两个仓库没有共享历史。

为了明确起见,我的目录结构如下:

XYZ/
    .git/
    XY1/
    ABC/
    XY2/

但我想用这个替代:

XYZ/
    .git/
    XY1/
    XY2/
ABC/
    .git/
    ABC/

10
现在通过 git filter-branch 进行此操作已经很容易了,请参见下面的答案。 - jeremyjjbrown
16
@jeremyjjbrown是正确的。这不再是一件难事,但在谷歌上找到正确答案却很困难,因为所有旧的答案都主导了搜索结果。 - Agnel Kurian
4
不推荐使用 git filter-branch。请参见文档中的警告 - djvg
2
请使用替代的历史记录过滤工具,如git filter-repo - Sergey Kuznetsov
26个回答

1595

简单易行™

事实证明,这是一种常见且有用的做法,Git的主宰们使其变得非常简单(在1.7.11版本中添加-2012年5月)。此外,在下面的演示中还有一个真实世界的例子

准备旧的仓库
cd git subtree split -P -b 注意: 不能包含前导或尾随字符。例如,名为 subproject 的文件夹必须传递为 subproject,而不是 ./subproject/
注意: 是您将在现有/旧仓库中创建的分支,而不是之后的新仓库。
Windows 用户注意:当您的文件夹深度大于 1 时, 必须具有 *nix 风格的文件夹分隔符 (/)。例如,名为 path1\path2\subproject 的文件夹必须传递为 path1/path2/subproject
创建新的仓库
mkdir ~/new-repo && cd ~/new-repo git init git pull 将新仓库链接到 GitHub 或其他地方
git remote add origin git@github.com:user/new-repo.git git push -u origin master 在 中进行清理,如果需要
git rm -rf 注意:这将保留仓库中的所有历史引用。如果您真正担心已提交密码或需要减小 .git 文件夹的文件大小,请参阅下面的附录。

步骤说明

这些是与上述步骤相同的步骤,但是按照我的存储库的确切步骤进行,而不是使用<meta-named-things>

这是一个我用于在Node中实现JavaScript浏览器模块的项目:

tree ~/node-browser-compat

node-browser-compat
├── ArrayBuffer
├── Audio
├── Blob
├── FormData
├── atob
├── btoa
├── location
└── navigator

我想将一个单独的文件夹btoa拆分成一个独立的Git仓库。
cd ~/node-browser-compat/
git subtree split -P btoa -b btoa-only

我现在有一个新的分支,名为btoa-only,它只包含btoa的提交记录,我想创建一个新的代码库。
mkdir ~/btoa/ && cd ~/btoa/
git init
git pull ~/node-browser-compat btoa-only

接下来,我在GitHub或Bitbucket上创建一个新的仓库,然后将其添加为origin
git remote add origin git@github.com:node-browser-compat/btoa.git
git push -u origin master

快乐的一天!
注意:如果您创建了一个带有README.md、.gitignore和LICENSE的仓库,您需要先进行拉取操作。
git pull origin master
git push origin master

最后,我想从较大的仓库中删除该文件夹。
git rm -rf btoa

清除浏览历史

默认情况下,从Git中删除文件并不会真正删除它们,只是提交了它们不再存在的事实。如果你想真正删除历史引用(比如你提交了一个密码),你需要执行以下操作:

git filter-branch --prune-empty --tree-filter 'rm -rf <name-of-folder>' HEAD

之后,您可以检查您的文件或文件夹在Git历史记录中是否完全消失:
git log -- <name-of-folder> # should show nothing

然而,你不能“推送”删除到GitHub等平台。如果你尝试这样做,会出现错误,你必须在执行git push之前执行git pull,然后你又回到了历史记录的状态。
所以,如果你想从“origin”中删除历史记录,也就是从GitHub、Bitbucket等平台中删除它,你需要删除仓库并重新推送一个修剪过的仓库副本。但是等等,还有更多!如果你真的担心删除密码或类似的东西,你需要修剪备份(见下文)。
使.git文件更小
上述删除历史记录的命令仍然会留下一堆备份文件,因为Git非常友好,帮助你避免意外破坏仓库。它最终会在几天或几个月后删除孤立的文件,但它会在那里保留一段时间,以防你意识到你意外删除了一些你不想删除的东西。
所以,如果你真的想立即清空回收站以减小仓库的克隆大小,你必须做一些非常奇怪的事情:
rm -rf .git/refs/original/ && \
git reflog expire --all && \
git gc --aggressive --prune=now

git reflog expire --all --expire-unreachable=0
git repack -A -d
git prune

说实话,我建议你不要执行这些步骤,除非你确定需要这样做,以防万一你剪切了错误的子目录,你懂的吧?备份文件在推送仓库时不会被克隆,它们只会存在于你的本地副本中。

致谢


17
git subtree 仍然是“contrib”文件夹的一部分,并不是所有发行版都默认安装。https://github.com/git/git/blob/master/contrib/subtree/ - onionjake
11
请执行以下命令以在Ubuntu 13.04上启用git subtree: @krlmlr sudo chmod +x /usr/share/doc/git/contrib/subtree/git-subtree.shsudo ln -s /usr/share/doc/git/contrib/subtree/git-subtree.sh /usr/lib/git-core/git-subtree - rui.araujo
47
如果你将密码推送到公共代码库中,应该更改密码,而不是试图从公共代码库中删除它,并希望没有人看见。 - Miles Rout
15
此解决方案不会保留历史记录。 - Cœur
18
popdpushd命令使这更加隐晦,并且更难以理解其意图... - jones77
显示剩余38条评论

1272
更新:这个过程很常见,Git 团队用新工具 git subtree 简化了它。请参见这里:将子目录分离(移动)到单独的 Git 存储库中


您希望克隆存储库,然后使用 git filter-branch 将除了您想要的子目录以外的所有东西标记为垃圾,以便在新的存储库中进行垃圾回收。

  1. To clone your local repository:

    git clone /XYZ /ABC
    

    (Note: the repository will be cloned using hard-links, but that is not a problem since the hard-linked files will not be modified in themselves - new ones will be created.)

  2. Now, let us preserve the interesting branches which we want to rewrite as well, and then remove the origin to avoid pushing there and to make sure that old commits will not be referenced by the origin:

    cd /ABC
    for i in branch1 br2 br3; do git branch -t $i origin/$i; done
    git remote rm origin
    

    or for all remote branches:

    cd /ABC
    for i in $(git branch -r | sed "s/.*origin\///"); do git branch -t $i origin/$i; done
    git remote rm origin
    
  3. Now you might want to also remove tags which have no relation with the subproject; you can also do that later, but you might need to prune your repo again. I did not do so and got a WARNING: Ref 'refs/tags/v0.1' is unchanged for all tags (since they were all unrelated to the subproject); additionally, after removing such tags more space will be reclaimed. Apparently git filter-branch should be able to rewrite other tags, but I could not verify this. If you want to remove all tags, use git tag -l | xargs git tag -d.

  4. Then use filter-branch and reset to exclude the other files, so they can be pruned. Let's also add --tag-name-filter cat --prune-empty to remove empty commits and to rewrite tags (note that this will have to strip their signature):

    git filter-branch --tag-name-filter cat --prune-empty --subdirectory-filter ABC -- --all
    

    or alternatively, to only rewrite the HEAD branch and ignore tags and other branches:

    git filter-branch --tag-name-filter cat --prune-empty --subdirectory-filter ABC HEAD
    
  5. Then delete the backup reflogs so the space can be truly reclaimed (although now the operation is destructive)

    git reset --hard
    git for-each-ref --format="%(refname)" refs/original/ | xargs -n 1 git update-ref -d
    git reflog expire --expire=now --all
    git gc --aggressive --prune=now
    

    and now you have a local git repository of the ABC sub-directory with all its history preserved.

注意:对于大多数用途,git filter-branch确实应该添加额外的参数-- --all。是的,真的是--空格--all。这需要成为命令的最后一个参数。正如Matli发现的那样,这将使项目的分支和标签包含在新存储库中。
编辑:根据下面评论中的各种建议进行了修改,以确保存储库实际上被缩小(以前并不总是这样)。

13
为什么需要使用 --no-hardlinks ?删除一个硬链接不会影响到其他文件。Git 对象也是不可变的。只有当你更改所有者/文件权限时才需要使用 --no-hardlinks - vdboor
2
如果您想重写标签以不引用旧结构,请添加“--tag-name-filter cat”。 - Malcolm Box
8
和Paul一样,我不希望在我的新仓库中有项目标签,所以我没有使用-- --all参数。在执行git filter-branch命令之前,我还运行了git remote rm origingit tag -l | xargs git tag -d命令。这将我的.git目录从60M缩小到了约300K。需要注意的是,我需要同时运行这两个命令才能获得大小减小的效果。 - saltycrane
4
这样做不会创建ABC/ABC/,而是只会创建ABC/。 - Thorbjørn Ravn Andersen
2
https://github.com/newren/git-filter-repo 是 Git 官方推荐的一种简便方法来实现此操作。 - Juan Saravia
显示剩余23条评论

140

Paul的答案创建一个包含/ABC的新存储库,但不会从/XYZ中删除/ABC。下面的命令将从/XYZ中删除/ABC:

git filter-branch --tree-filter "rm -rf ABC" --prune-empty HEAD

当然,首先在一个 'clone --no-hardlinks' 的代码库中进行测试,然后执行Paul列出的重置、垃圾回收和修剪命令。


54
运行以下命令: git filter-branch --index-filter "git rm -r -f --cached --ignore-unmatch ABC" --prune-empty HEAD 速度将会明显提升。 索引过滤器(index-filter)作用于索引,而树过滤器(tree-filter)需要为每个提交(commit)检出和暂存(stage)所有内容 - fmarc
51
在某些情况下,搞乱代码库 XYZ 的历史记录可能有些过头了...... 对于大多数人来说,只需要一个简单的“rm -rf ABC; git rm -r ABC; git commit -m '将 ABC 提取到自己的仓库中'”就足够了。请注意不要改变原话的含义,并使翻译通俗易懂。 - Evgeny
2
如果您要执行多次此命令,例如在分离两个目录后删除它们,则可能希望在该命令上使用-f(强制)选项。否则,您将收到“无法创建新备份”的错误提示。 - Brian Carlton
4
如果使用“--index-filter”方法,您可能还想将其修改为“git rm -q -r -f”,这样每次调用时就不会打印删除的每个文件的行。 - Eric Naeseth
1
我建议修改Paul的答案,只是因为Paul的答案非常详尽。 - Erik Aronesty

98

我发现为了正确地从新的代码库中删除旧的历史记录,您需要在 filter-branch 步骤之后再做一些工作。

  1. 克隆并筛选:

    git clone --no-hardlinks foo bar; cd bar
    git filter-branch --subdirectory-filter subdir/you/want
    
  2. 移除所有对旧历史的引用。“origin”用于跟踪克隆,而“original”则是filter-branch保存旧内容的地方:

  3. git remote rm origin
    git update-ref -d refs/original/refs/heads/master
    git reflog expire --expire=now --all
    
  4. 即使现在,您的历史记录可能被卡在一个packfile中,而fsck无法触及。将其撕成碎片,创建一个新的packfile并删除未使用的对象:

  5. git repack -ad
    

这个问题在 filter-branch手册中有解释


3
我认为像 git gc --aggressive --prune=now 这样的命令还是缺失的,是吗? - Albert
1
@Albert repack命令会处理这个问题,不会有任何松散的对象。 - Josh Lee
是的,git gc --aggressive --prune=now 大大减少了新存储库的大小。 - Tomek Wyderka
简单而优雅。谢谢! - Marco Pelegrini
经历了这一切之后,我仍然得到了之前的相同错误。致命错误:打包对象xxxxxx(存储在.git/objects/pack/pack-yyyyyyyy.pack中)已损坏。 - AaA

54

当使用更新版本的git(可能是2.22+)运行git filter-branch时,它会提示使用这个新工具git-filter-repo。这个工具确实为我简化了事情。

使用filter-repo进行过滤

从原始问题创建XYZ存储库的命令:

# create local clone of original repo in directory XYZ
tmp $ git clone git@github.com:user/original.git XYZ

# switch to working in XYZ
tmp $ cd XYZ

# keep subdirectories XY1 and XY2 (dropping ABC)
XYZ $ git filter-repo --path XY1 --path XY2

# note: original remote origin was dropped
# (protecting against accidental pushes overwriting original repo data)

# XYZ $ ls -1
# XY1
# XY2

# XYZ $ git log --oneline
# last commit modifying ./XY1 or ./XY2
# first commit modifying ./XY1 or ./XY2

# point at new hosted, dedicated repo
XYZ $ git remote add origin git@github.com:user/XYZ.git

# push (and track) remote master
XYZ $ git push -u origin master

假设: * 远程 XYZ 仓库在推送之前是新的且为空

过滤和移动

在我的情况下,我还想移动一些目录以获得更一致的结构。最初,我运行了简单的 filter-repo 命令,然后是 git mv dir-to-rename,但我发现使用 --path-rename 选项可以获得稍微“更好”的历史记录。现在,在新仓库中移动文件的上次修改时间显示为 去年(在 GitHub UI 中),这与原始仓库中的修改时间相匹配。

而不是...

git filter-repo --path XY1 --path XY2 --path inconsistent
git mv inconsistent XY3  # which updates last modification time

I ultimately ran...

git filter-repo --path XY1 --path XY2 --path inconsistent --path-rename inconsistent:XY3
  • 我认为Git Rev News 博客文章很好地解释了创建另一个仓库过滤工具的原因。
  • 我最初尝试在原始仓库中创建与目标仓库名称匹配的子目录,然后使用git filter-repo --subdirectory-filter dir-matching-new-repo-name进行过滤。该命令将该子目录正确转换为复制的本地仓库的根目录,但它也导致仅有三个提交的历史记录来创建子目录。(我没有意识到可以多次指定--path;从而避免在源仓库中创建子目录。)由于当我注意到我未能继承历史记录时,有人已经提交到源仓库,因此我只需在clone命令之后使用git reset commit-before-subdir-move --hard,并在filter-repo命令中添加--force以使其在略微修改的本地克隆上运行。
  • git clone ...
    git reset HEAD~7 --hard      # roll back before mistake
    git filter-repo ... --force  # tell filter-repo the alterations are expected
    
    • 由于我不知道使用 git 的扩展模式,安装时遇到了困难,但最终我克隆了 git-filter-repo 并将其符号链接到 $(git --exec-path)
    ln -s ~/github/newren/git-filter-repo/git-filter-repo $(git --exec-path)
    

    5
    目前来说,使用git-filter-repo肯定是首选的方法。它比git-filter-branch更快、更安全,并且可以防止在重写Git历史记录时遇到的许多问题。希望这个答案能得到更多关注,因为它是针对git-filter-repo的回答。 - Jeremy Caney
    实际上,我目前正在尝试使用git filter-repo让事情正常工作,但不幸的是,在运行它后,我丢失了一些文件,这些文件在一个提交中添加,包含了一个被filter-repo删除的路径。例如: Foo.cs Bar/ Bar.cs``` 所有内容都在同一个提交中添加。我想将Foo和Bar移动到单独的仓库中。所以我克隆了我的repo到与新repo名称匹配的文件夹中,并执行 ```git filter-repo -path Foo``` ,结果Foo也被删除了。我说的是一个更大的repo,对于其他每个文件,它都有效,但如果它是这样的组合则无效。 - bego
    如果文件之前被移动/重命名,这将不会自动保留移动/重命名之前的历史记录。但是,如果您在命令中包含原始路径/文件名,则该历史记录将不会被删除。例如,git filter-repo --path CurrentPathAfterRename --path OldPathBeforeRenamegit filter-repo --analyze 会生成一个文件renames.txt,可以帮助确定这些内容。或者,您可能会发现像这样的脚本有所帮助。 - Joel Leach
    将子目录的内容移动到根文件夹,并仅保留子目录中的文件及其对应的历史记录:git filter-repo --subdirectory-filter path/to/subdir - Joël Esponde

    40

    编辑:添加了Bash脚本。

    这里给出的答案对我来说只起到了部分作用。许多大文件仍然保存在缓存中。最终解决方案是在freenode上的#git频道花费数小时后得到的:

    git clone --no-hardlinks file:///SOURCE /tmp/blubb
    cd blubb
    git filter-branch --subdirectory-filter ./PATH_TO_EXTRACT  --prune-empty --tag-name-filter cat -- --all
    git clone file:///tmp/blubb/ /tmp/blooh
    cd /tmp/blooh
    git reflog expire --expire=now --all
    git repack -ad
    git gc --prune=now
    

    使用先前的解决方案,存储库大小约为100 MB。这个新解决方案将其缩小到了1.7 MB。也许对某些人有所帮助 :)


    以下bash脚本自动化了此任务:

    !/bin/bash
    
    if (( $# < 3 ))
    then
        echo "Usage:   $0 </path/to/repo/> <directory/to/extract/> <newName>"
        echo
        echo "Example: $0 /Projects/42.git first/answer/ firstAnswer"
        exit 1
    fi
    
    
    clone=/tmp/${3}Clone
    newN=/tmp/${3}
    
    git clone --no-hardlinks file://$1 ${clone}
    cd ${clone}
    
    git filter-branch --subdirectory-filter $2  --prune-empty --tag-name-filter cat -- --all
    
    git clone file://${clone} ${newN}
    cd ${newN}
    
    git reflog expire --expire=now --all
    git repack -ad
    git gc --prune=now
    

    27

    现在这已经不那么复杂了,您可以在克隆的存储库上使用git filter-branch命令来删除您不想要的子目录,然后将更改推送到新的远程仓库。

    git filter-branch --prune-empty --subdirectory-filter <YOUR_SUBDIR_TO_KEEP> master
    git push <MY_NEW_REMOTE_URL> -f .
    

    4
    这个方法非常有效。上面例子中的 YOUR_SUBDIR 是你想要保留的子目录,其他所有东西都会被删除。 - J.T. Taylor
    1
    根据您的评论进行更新。 - jeremyjjbrown
    3
    这并没有回答问题。文档中写道,“结果将包含该目录(仅该目录)作为项目根目录。”,确实如此,也就是说原始的项目结构并没有保留。 - NicBright
    2
    @NicBright,你能否详细说明一下你在问题中提到的XYZ和ABC的问题,以便我们更好地了解情况? - Adam
    @jeremyjjbrown,是否可以重复使用克隆的存储库而不是使用新存储库,即我的问题在这里 https://stackoverflow.com/questions/49269602/after-using-git-filter-branch-subdirectory-filter-how-do-i-still-use-old-repo - Qiulang

    19
    这里是对CoolAJ86"The Easy Way™" answer进行的小修改,以将多个子文件夹(比如sub1和sub2)拆分到一个新的git存储库中。

    The Easy Way™(多个子文件夹)

    1. Prepare the old repo

      pushd <big-repo>
      git filter-branch --tree-filter "mkdir <name-of-folder>; mv <sub1> <sub2> <name-of-folder>/" HEAD
      git subtree split -P <name-of-folder> -b <name-of-new-branch>
      popd
      

      Note: <name-of-folder> must NOT contain leading or trailing characters. For instance, the folder named subproject MUST be passed as subproject, NOT ./subproject/

      Note for windows users: when your folder depth is > 1, <name-of-folder> must have *nix style folder separator (/). For instance, the folder named path1\path2\subproject MUST be passed as path1/path2/subproject. Moreover don't use mvcommand but move.

      Final note: the unique and big difference with the base answer is the second line of the script "git filter-branch..."

    2. Create the new repo

      mkdir <new-repo>
      pushd <new-repo>
      
      git init
      git pull </path/to/big-repo> <name-of-new-branch>
      
    3. Link the new repo to Github or wherever

      git remote add origin <git@github.com:my-user/new-repo.git>
      git push origin -u master
      
    4. Cleanup, if desired

      popd # get out of <new-repo>
      pushd <big-repo>
      
      git rm -rf <name-of-folder>
      

      Note: This leaves all the historical references in the repository.See the Appendix in the original answer if you're actually concerned about having committed a password or you need to decreasing the file size of your .git folder.


    1
    这个方法对我有用,只需要稍作修改。因为我的 sub1sub2 文件夹在初始版本中不存在,所以我必须修改我的 --tree-filter 脚本如下:"mkdir <name-of-folder>; if [ -d sub1 ]; then mv <sub1> <name-of-folder>/; fi"。对于第二个 filter-branch 命令,我将 <sub1> 替换为 <sub2>,省略了创建 <name-of-folder>,并在 filter-branch 后包含 -f 以覆盖现有备份的警告。 - pglezen
    如果在Git的历史记录中,任何子目录发生了更改,则此方法将无法正常工作。如何解决这个问题? - nietras
    @nietras 看看rogerdpack的回答。在阅读和吸收其他答案中的所有信息后,我花了一些时间才找到它。 - Adam

    19

    1
    git-subtree现在是Git的一部分,尽管它在contrib tree中,因此不总是默认安装。我知道Homebrew git公式已安装它,但没有man页面。apenwarr因此称他的版本已过时。 - echristopherson

    13
    原问题想要把XYZ/ABC/(*files) 变成 ABC/ABC/(*files)。在我自己的代码中实现了被接受的答案后,发现它实际上会将 XYZ/ABC/(*files) 改为 ABC/(*files)。 filter-branch 的 man 页面甚至说:

    结果将包含该目录(仅限该目录)作为其项目根目录

    换句话说,它将顶层文件夹“向上”提升一个级别。这是个重要的区别,因为例如在我的历史记录中,我曾经重命名过一个顶层文件夹。通过将文件夹“向上”提升一个级别,git 在执行重命名操作的提交处丢失了连续性。

    我在 filter-branch 操作后失去了连续性

    我对这个问题的回答是复制仓库两次,然后手动删除每个副本中要保留的文件夹。man 页面也支持我的做法:

    [...] 如果一个简单的单一提交就足以解决你的问题,请避免使用 [此命令]


    1
    我喜欢那张图的风格。请问你用的是什么工具? - Slipp D. Thompson
    3
    Tower for Mac。我真的很喜欢它。它几乎值得为之而转换到Mac。 - MM.
    2
    是的,尽管在我的情况下,我的子文件夹中的“targetdir”曾经被重命名过,但git filter-branch只是打了个招呼,删除了重命名之前做出的所有提交!令人震惊的是,考虑到Git在跟踪此类事物以及单个内容块迁移方面的熟练程度! - Jay Allen
    1
    哦,还有,如果有人遇到同样的问题,这是我使用的命令。不要忘记git rm可以接受多个参数,所以没有必要为每个文件/文件夹运行它:BYEBYE="dir/subdir2 dir2 file1 dir/file2"; git filter-branch -f --index-filter "git rm -q -r -f --cached --ignore-unmatch $BYEBYE" --prune-empty -- --all - Jay Allen

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