GIT拆分存储库目录并保留“移动/重命名”历史记录

20

假设你有一个存储库:

myCode/megaProject/moduleA
myCode/megaProject/moduleB
随着时间的推移(几个月),您重新组织了项目。重构代码,使模块独立。MegaProject目录中的文件被移动到它们自己的目录中。强调“移动”-这些文件的历史记录得到保留。
myCode/megaProject
myCode/moduleA
myCode/moduleB

现在您希望将这些模块移至它们自己的GIT存储库,仅留下包含megaProject的原始存储库。

myCode/megaProject
newRepoA/moduleA
newRepoB/moduleB
filter-branch 命令可以实现此功能,但是它不能跟踪文件移动到目标目录之外的历史记录。 因此,历史记录始于文件移动到新目录时,而不是文件在旧 megaProject 目录中存在时的历史记录。

如何基于目标目录拆分 GIT 历史记录,并跟踪该路径之外的历史记录 - 仅留下与这些文件相关的提交历史,而没有其他内容?

许多其他 SO 答案侧重于通常拆分存储库的情况,但没有提到拆分和跟踪移动历史记录。

6个回答

9
这是基于 @rksawyer 的脚本版本,但它使用 git-filter-repo。我发现它比 git-filter-branch 更容易使用,并且速度要快得多(现在被 Git 推荐作为替代方案)。
# This script should run in the same folder as the project folder is.
# This script uses git-filter-repo (https://github.com/newren/git-filter-repo).
# The list of files and folders that you want to keep should be named <your_repo_folder_name>_KEEP.txt. I should contain a line end in the last line, otherwise the last file/folder will be skipped.
# The result will be the folder called <your_repo_folder_name>_REWRITE_CLONE. Your original repo won't be changed.
# Tags are not preserved, see line below to preserve tags.
# Running subsequent times will backup the last run in <your_repo_folder_name>_REWRITE_CLONE_BKP.

# Define here the name of the folder containing the repo: 
GIT_REPO="git-test-orig"

clone="$GIT_REPO"_REWRITE_CLONE
temp=/tmp/git_rewrite_temp
rm -Rf "$clone"_BKP
mv "$clone" "$clone"_BKP
rm -Rf "$temp"
mkdir "$temp"
git clone "$GIT_REPO" "$clone"
cd "$clone"
git remote remove origin
open .
open "$temp"

# Comment line below to preserve tags
git tag | xargs git tag -d

echo 'Start logging file history...'
echo "# git log results:\n" > "$temp"/log.txt

while read p
do
    shopt -s dotglob
    find "$p" -type f > "$temp"/temp
    while read f
    do
        echo "## " "$f" >> "$temp"/log.txt
        # print every file and follow to get any previous renames
        # Then remove blank lines.  Then remove every other line to end up with the list of filenames       
        git log --pretty=format:'%H' --name-only --follow -- "$f" | awk 'NF > 0' | awk 'NR%2==0' | tee -a "$temp"/log.txt
        
        echo "\n\n" >> "$temp"/log.txt
    done < "$temp"/temp
done < ../"$GIT_REPO"_KEEP.txt > "$temp"/PRESERVE

mv "$temp"/PRESERVE "$temp"/PRESERVE_full
awk '!a[$0]++' "$temp"/PRESERVE_full > "$temp"/PRESERVE

sort -o "$temp"/PRESERVE "$temp"/PRESERVE

echo 'Starting filter-branch --------------------------'
git filter-repo --paths-from-file "$temp"/PRESERVE --force --replace-refs delete-no-add
echo 'Finished filter-branch --------------------------'

它将git log的结果记录到/tmp/git_rewrite_temp/log.txt文件中,如果您不需要log.txt并且想要更快地运行,可以删除这些行。


1
使用一个很棒的工具的绝佳示例!在经历了一整天的filter-branch问题,运行了40分钟却没有效果后,这个工具在大约5秒钟内正确地解决了它。 - Tobb
我有一些混乱的旧提交,所以最终我在filter-repo命令中添加了“--prune-empty always”。 - Tobb
自动设置将修剪重写存储库时最终为空的所有提交。在我的情况下,我猜我有实际上是空的提交。它们似乎来自于 git 之前的存储库(svn),可能由于 svn 是 svn 或迁移到 git 时出现某些原因而最终为空。无论如何,没有理由保留这些提交,它们应该从原始存储库中删除。 - Tobb
1
我对 git-filter-repo 还比较新,但是通过阅读文档,git filter-repo --analyze 不应该能够提供有关重命名的信息吗? - Leo
我发现你的Shell脚本版本与我实现的有些不同,让我感到不太舒服,所以我用Python写了一个版本,它的行为更类似于裸的git-filter-repo,有--help,并且有一堆安全保护。我不确定在这种情况下将其作为自己的答案最合适的方式是什么。(它是一个Gist,但在我看来也太长了,不能在此处进行代码块处理。) - ssokolow
我会将其添加为答案。如果它是一种改进,那么对社区来说更好,因此应该得到更多的关注。虽然我知道我的脚本运行良好,但我的shell技能很差,所以代码很丑陋。 - Roberto

4
在克隆的存储库中运行git filter-branch --subdirectory-filter将删除所有不影响该子目录中内容的提交,包括那些影响移动前文件的提交。相反,您需要使用--index-filter标志和脚本删除您不感兴趣的所有文件,并使用--prune-empty标志忽略影响其他内容的任何提交。Kevin Deldycke的博客文章提供了一个很好的示例。
git filter-branch --prune-empty --tree-filter 'find ./ -maxdepth 1 -not -path "./e107*" -and -not -path "./wordpress-e107*" -and -not -path "./.git" -and -not -path "./" -print -exec rm -rf "{}" \;' -- --all

这个命令会逐个检出每个提交,从工作目录中删除所有无关的文件,并且如果与上一次提交有任何更改,则将其检入(在处理历史记录时进行重写)。您需要调整该命令以删除除/moduleA/megaProject/moduleA和您想要保留的特定文件之外的所有文件。/megaProject

出于某种原因,它对我不起作用,它删除了 .git/refs/heads,破坏了我的仓库。有趣的是,并非所有 .git 内部的文件都被删除。您知道为什么会发生这种情况吗?此外,我无法看到这个解决方案如何保留移动/重命名。 - Roberto

2

我知道没有简单的方法来完成这个任务,但是它是可以实现的。

filter-branch的问题在于它通过在每个修订版本上应用自定义过滤器来工作。

应用自定义过滤器到每个修订版本上

如果你能创建一个不会删除文件的过滤器,那么它们就会在目录之间被跟踪。当然,对于任何不是微不足道的存储库来说,这很可能是非常困难的。

首先,我们假设这是一个微不足道的存储库。你从未重命名过文件,并且你从未在两个模块中使用相同的文件名。你只需要获取你的模块中文件的列表 find megaProject/moduleA -type f -printf "%f\n" > preserve,然后使用这些文件名和目录运行你的过滤器:

preserve.sh

cmd="find . -type f ! -name d1"
while read f; do
  cmd="$cmd ! -name $f"
done < /path/to/myCode/preserve
for i in $($cmd)
do
  rm $i
done

git filter-branch --prune-empty --tree-filter '/path/to/myCode/preserve.sh' HEAD

当然,重命名是使这个过程变得困难的原因之一。git filter-branch 的一个好处是它提供了 $GIT_COMMIT 环境变量。你可以使用一些高级技巧,例如:

for f in megaProject/moduleA
do
 git log --pretty=format:'%H' --name-only --follow -- $f |  awk '{ if($0 != ""){ printf $0 ":"; next; } print; }'
done > preserve

为了构建一个包含提交历史的文件名,可以用它来代替简单的“preserve”文件,在这个简单的例子中。但是,你需要自己跟踪每个提交时应该存在哪些文件。实际上,编写代码并不难,但我还没有看到有人这样做过。

如果进行了打磨,那看起来很酷,但是如果直接应用就无法正常工作。 - Vasaka

1

接着上面的答案,首先通过使用 git log --follow 遍历保留在目录中的所有文件,以获取之前移动/重命名的旧路径/名称。然后使用 filter-branch 遍历每个修订版本,删除步骤1中创建的列表中没有的任何文件。

#!/bin/bash
DIRNAME=dirD

# Catch all files including hidden files
shopt -s dotglob
for f in $DIRNAME/*
do
# print every file and follow to get any previous renames
# Then remove blank lines.  Then remove every other line to end up with the list of filenames
 git log --pretty=format:'%H' --name-only --follow -- $f | awk 'NF > 0' | awk 'NR%2==0'
done > /tmp/PRESERVE

sort -o /tmp/PRESERVE /tmp/PRESERVE
cat /tmp/PRESERVE

然后创建一个脚本(preserve.sh),filter-branch将为每个修订版本调用该脚本。
#!/bin/bash
DIRNAME=dirD

# Delete everything that's not in the PRESERVE list
echo 'delete this files:'
cmd=`find . -type f -not -path './.git/*' -not -path './$DIRNAME/*'`
echo $cmd > /tmp/ALL


# Convert to one filename per line and remove the lead ./
cat /tmp/ALL | awk '{NF++;while(NF-->1)print $NF}' | cut -c3- > /tmp/ALL2
sort -o /tmp/ALL2 /tmp/ALL2

#echo 'before:'
#cat /tmp/ALL2

comm -23 /tmp/ALL2 /tmp/PRESERVE > /tmp/DELETE_THESE
echo 'delete these:'
cat /tmp/DELETE_THESE
#exit 0

while read f; do
  rm $f
done < /tmp/DELETE_THESE

现在使用filter-branch,如果在修订版中删除了所有文件,则修剪该提交及其消息。
 git filter-branch --prune-empty --tree-filter '/FULL_PATH/preserve.sh' master

这个很好用!我只需要改动一些东西就可以让它适应包含空格的路径。 - Roberto
@Roberto 你好,不知道你是否还有修复空格问题的版本? - Stals
@Stals 你好。在使用变量时,需要加上引号,比如"$DIRNAME"。我已经将我的答案发布为新答案了。 - Roberto

0
这是我根据@Roberto发布的脚本编写的版本,适用于Linux/WSL。如果您没有指定“myrepo_KEEP.txt”,它将基于当前文件结构创建一个。传入要操作的存储库:

prune.sh MyRepo

# This script should run one level up from the git repo folder (i.e. the  containing folder)
# This script uses git-filter-repo (github.com/newren/git-filter-repo).
# The result will be the folder called <your_repo_folder_name>_REWRITE_CLONE. Your original repo won't be changed.
# Tags are not preserved, see line below to preserve tags.
# Running subsequent times will backup the last run in <your_repo_folder_name>_REWRITE_CLONE_BKP.
# Optionally, list the files and folders that you want to keep the KEEP_FILE (<your_repo_folder_name>_KEEP.txt) 
## It should contain a line end in the last line, otherwise the last file/folder will be skipped.
## If this file is missing it will be created by this script with all current folders listed. 

echo "Prune git repo"

# User needs to pass in the repo name
GIT_REPO=$1

if [ -z $GIT_REPO ]; then
    echo "Pass in the directory to prune"
else
    KEEP_FILE="${GIT_REPO}"_KEEP.txt

    # Build up a list of current directories in the repo, if one hasn't been supplied
    if [ ! -f "${KEEP_FILE}" ]; then
        echo "Keeping all current files in repo (generating keep file)"
        cd $GIT_REPO
        find . -type d -not -path '*/\.*' > "../${KEEP_FILE}"
        cd ..
    fi

    echo "Pruning $GIT_REPO"

    clone="${GIT_REPO}_REWRITE_CLONE"
    
    # Shift backup
    bkp="${clone}_BKP"
    temp=/tmp/git_rewrite_temp
    echo $clone
    rm -Rf "$bkp"
    mv "$clone" "$bkp"
    
    # Setup temp
    rm -Rf "$temp"
    mkdir "$temp"   
    
    # Clone
    echo "Cloning repo...from $GIT_REPO to $clone"
    if git clone "$GIT_REPO" "$clone"; then
        cd "$clone"
        git remote remove origin

        # Comment line below to preserve tags
        git tag | xargs git tag -d

        echo 'Start logging file history...'
        echo "# git log results:\n" > "$temp"/log.txt

        # Follow the renames
        while read p
        do
            shopt -s dotglob
            find "$p" -type f > "$temp"/temp
            while read f
            do
                echo "## " "$f" >> "$temp"/log.txt
                # print every file and follow to get any previous renames
                # Then remove blank lines.  Then remove every other line to end up with the list of filenames       
                git log --pretty=format:'%H' --name-only --follow -- "$f" | awk 'NF > 0' | awk 'NR%2==0' | tee -a "$temp"/log.txt

                echo "\n\n" >> "$temp"/log.txt
            done < "$temp"/temp
        done < ../"${KEEP_FILE}" > "$temp"/PRESERVE

        mv "$temp"/PRESERVE "$temp"/PRESERVE_full
        awk '!a[$0]++' "$temp"/PRESERVE_full > "$temp"/PRESERVE

        sort -o "$temp"/PRESERVE "$temp"/PRESERVE

        echo 'Starting filter-branch --------------------------'
        git filter-repo --paths-from-file "$temp"/PRESERVE --force --replace-refs delete-no-add
        echo 'Finished filter-branch --------------------------'
        cd ..
    fi
fi

感谢 @rksawyer 和 @Roberto。


-2
我们把自己陷入了一个更糟糕的境地,有数十个项目分布在数十个分支上,每个项目都依赖于1-4个其他项目,总共有56k次提交。filter-branch 花费了长达24小时才能将单个目录拆分出来。
最终,我使用 .NET 编写了一个工具,利用 libgit2sharp 和原始文件系统访问来拆分每个项目中任意数量的目录,并仅保留新存储库中每个项目的相关提交/分支/标签。它不会修改源存储库,而是输出 N 个其他存储库,其中只包含配置的路径/引用。
欢迎查看并尝试使用此工具,进行修改等。https://github.com/CurseStaff/GitSplit

链接的仓库不存在或不是公共的。 - ChrisW
听起来不错,能看到它会很好吧?如果你想让这个答案得到赞同,你需要发布一些有用的细节,而不仅仅是发布一个超链接。顺便说一句。 - noelicus

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