【问题标题】:How to filter history based on gitignore?如何根据 gitignore 过滤历史记录?
【发布时间】:2017-09-13 19:00:12
【问题描述】:

为了明确这个问题,我不是在询问如何从历史记录中删除单个文件,例如这个问题:Completely remove file from all Git repository commit history。我也不是在询问来自 gitignore 的 untracking 文件,就像在这个问题中一样:Ignore files that have already been committed to a Git repository

我说的是“更新 .gitignore 文件,然后从历史记录中删除与列表匹配的所有内容”,或多或少类似于这个问题:Ignore files that have already been committed to a Git repository。但是,不幸的是,该问题的答案不适用于此目的,因此我在这里尝试详细说明该问题,并希望找到一个不涉及人类查看整个源树以手动执行过滤器分支的好答案在每个匹配的文件上。

这里我提供一个测试脚本,目前正在执行Ignore files that have already been committed to a Git repository答案中的过程。它将在 PWD 下删除并创建一个文件夹root,所以在运行它之前要小心。我将在代码之后描述我的目标。

#!/bin/bash -e

TESTROOT=${PWD}
GREEN="\e[32m"
RESET="\e[39m"

rm -rf root
mkdir -v root
pushd root

mkdir -v repo
pushd repo
git init

touch a b c x 
mkdir -v main
touch main/{a,x,y,z}

# Initial commit
git add .
git commit -m "Initial Commit"
echo -e "${GREEN}Contents of first commit${RESET}"
git ls-files | tee ../00-Initial.txt

# Add another commit just for demo
touch d e f y z main/{b,c}
## Make some other changes
echo "Test" | tee a | tee b | tee c | tee x | tee main/a > main/x
git add .
git commit -m "Some edits"

echo -e "${GREEN}Contents of second commit${RESET}"
git ls-files | tee ../01-Changed.txt

# Now I want to ignore all 'a' and 'b', and all 'main/x', but not 'main/b'
## Checkout the root commit
git checkout -b temp $(git rev-list HEAD | tail -1)
## Add .gitignores
echo "a" >> .gitignore
echo "b" >> .gitignore
echo "x" >> main/.gitignore
echo "!b" >> main/.gitignore
git add .
git commit --amend -m "Initial Commit (2)"
## --v Not sure if it is correct
git rebase --onto temp master
git checkout master
## --v Now, why should I delete this branch?
git branch -D temp
echo -e "${GREEN}Contents after rebase${RESET}"
git ls-files | tee ../02-Rebased.txt

# Supposingly, rewrite history
git filter-branch --tree-filter 'git clean -f -X' -- --all
echo -e "${GREEN}Contents after filter-branch${RESET}"
git ls-files | tee ../03-Rewritten.txt

echo "History of 'a'"
git log -p a

popd # repo

popd # root

此代码创建一个存储库、添加一些文件、进行一些编辑并执行清理过程。此外,还会生成一些日志文件。 理想情况下,我希望 abmain/x 从历史中消失,而 main/b 保留。然而,现在什么都没有从历史中删除。应该修改什么来实现这个目标?

如果这可以在多个分支上完成,则可以加分。但现在,将其保留在单个 master 分支中。

【问题讨论】:

    标签: git gitignore rebase


    【解决方案1】:

    实现你想要的结果有点棘手。最简单的方法是使用git filter-branch--tree-filter,会非常慢。 编辑:我已经修改了您的示例脚本来执行此操作;看到这个答案的结尾。

    首先,让我们注意一个约束:您可以永远更改任何现有的提交。您所能做的就是使 new 提交看起来很像旧的提交,但“新的和改进的”。然后,您指示 Git 停止查看旧提交,而只查看新提交。这就是我们将在这里做的。 (然后,如果需要,您可以强制 Git真正忘记旧的提交。最简单的方法是重新克隆克隆。)

    现在,要重新提交可从一个或多个分支和/或标记名称访问的每个提交,保留除我们明确告诉它更改的所有内容之外的所有内容,1 我们可以使用@987654323 @。 filter-branch 命令有一系列令人眼花缭乱的过滤选项,其中大部分是为了让它更快,因为复制每个提交非常慢。如果存储库中只有几百个提交,每个提交有几十个或数百个文件,那还不错;但是如果有大约 100k 个提交,每个提交大约 100k 个文件,那么需要检查和重新提交一万个文件(10,000,000,000 个文件)。这需要一段时间。

    不幸的是,没有简单方便的方法来加快速度。加快速度的最佳方法是使用--index-filter,但没有内置的索引过滤器命令可以满足您的需求。最容易使用的过滤器是--tree-filter,它也是最慢的过滤器。您可能想尝试编写自己的索引过滤器,可以使用 shell 脚本,也可以使用您喜欢的其他语言(无论哪种方式,您仍然需要调用 git update-index)。


    1已签名的注释标签无法完整保留,因此其签名将被剥离。已签名的提交可能会导致其签名无效(如果提交哈希更改,这取决于它是否必须:记住提交的哈希 ID 是提交内容的校验和,因此如果文件集发生更改,校验和也会更改;但如果父提交的校验和发生变化,则此提交的校验和也会发生变化。


    使用--tree-filter

    当您将git filter-branch--tree-filter 一起使用时,过滤器分支代码的作用是将每次提交提取到一个临时目录中。这个临时目录没有.git 目录,也不是你运行git filter-branch 的地方(它实际上位于.git 目录的子目录中,除非你使用-d 选项将Git 重定向到内存文件系统,这是加快速度的好主意)。

    将整个提交解压到这个临时目录后,Git 运行你的树过滤器。一旦你的树过滤器完成,Git 会将那个临时目录中的 所有内容 打包到新的提交中。无论你离开那里,都在里面。你添加到那里的任何东西,都会被添加。无论你在那里修改什么,都会被修改。无论您从那里删除什么,都不再在新的提交中。

    请注意,此临时目录中的 .gitignore 文件对将提交的内容没有影响(但 .gitignore 文件本身提交,因为临时目录中的任何内容都将成为新的复制提交)。因此,如果您想确定某个已知路径的文件提交,只需rm -f known/path/to/file.ext。如果该文件在临时目录中,那么它现在已经不存在了。如果没有,什么都不会发生,一切都很好。

    因此,一个可行的树形过滤器应该是:

    rm -f $(cat /tmp/files-to-remove)
    

    (假设文件名中没有空格问题;使用xargs ... | rm -f 来避免空格问题,使用您喜欢的xargs 输入编码;-z 样式编码是理想的,因为路径名中禁止使用\0) .

    将其转换为索引过滤器

    使用索引过滤器可以让 Git 跳过提取和检查阶段。如果你有一个正确形式的固定“删除”列表,它会很容易使用。

    假设您在/tmp/files-to-remove 中具有适合xargs -0 的格式的文件名。然后,您的索引过滤器可能会完整读取:

    xargs -0 /tmp/files-to-remove | git rm --cached -f --ignore-unmatch
    

    这与上面的rm -f 基本相同,但在 Git 用于每个待复制的提交的临时索引内工作。 (将-q 添加到git rm --cached 以使其安静。)

    在树过滤器中应用.gitignore 文件

    您的示例脚本尝试使用--tree-filter 在重新定位到具有所需项目的初始提交后:

    git filter-branch --tree-filter 'git clean -f -X' -- --all
    

    虽然有一个初始错误(git rebase 是错误的):

    -git rebase --onto temp master
    +git rebase --onto temp temp master
    

    解决了这个问题,事情仍然无法正常工作,原因是git clean -f -X 只删除了实际上被忽略的文件。任何已经在索引中的文件实际上都不会被忽略。

    诀窍是清空索引。然而,这太多了:git clean 然后永远不会下降到子目录中——所以诀窍分为两部分:清空索引,然后用非忽略文件重新填充它。现在git clean -f -X 将删除剩余的文件:

    -git filter-branch --tree-filter 'git clean -f -X' -- --all
    +git filter-branch --tree-filter 'git rm --cached -qrf . && git add . && git clean -fqX' -- --all
    

    (我在这里添加了几个“安静”标志)。

    为了避免在安装初始 .gitignore 文件时首先需要 rebase,假设您在每次提交中都有一组您想要的 .gitignore 文件(然后我们将在树过滤器中使用它们) )。只需将它们放在临时树中即可:

    mkdir /tmp/ignores-to-add
    cp .gitignore /tmp/ignores-to-add
    mkdir /tmp/ignores-to-add/main
    cp main/.gitignore /tmp/ignores-to-add
    

    (我将留下一个脚本来查找和复制.gitignore 文件给你,没有它似乎有点烦人)。然后,对于--tree-filter,使用:

    cp -R /tmp/ignores-to-add . &&
        git rm --cached -qrf . &&
        git add . &&
        git clean -fqX
    

    第一步,cp -R(实际上可以在git add . 之前的任何地方完成),安装正确的.gitignore 文件。由于我们对每次提交都执行此操作,因此在运行 filter-branch 之前我们永远不需要 rebase。

    第二个从索引中删除所有内容。 (稍微快一点的方法就是rm $GIT_INDEX_FILE,但不能保证它会永远有效。)

    第三个重新添加.,即临时树中的所有内容。由于.gitignore 文件已就位,我们只添加未忽略的文件。

    最后一步,git clean -qfX,删除被忽略的工作树文件,以便filter-branch 不会将它们放回原处。

    【讨论】:

    • 我承认我不太了解 Git 的内部结构。这是否意味着我仍然需要手动删除每个文件?
    • 使用树过滤器,您可以“手动”(但自动地,每次提交一次)删除文件,是的。使用索引过滤器,您可以自动从每个索引中删除每个文件,绕过从索引中提取和重建索引步骤。 filter-branch 真正做的就是重复您的过滤器,在分支中的每个提交上运行它(当然,按照正确的顺序,从最早的提交到最新的提交)。
    • 我知道它会为每次提交运行。我的意思是,使用新添加的 gitignore 规则过滤所有提交上的所有文件是否没有简单(或没有自定义脚本)的方法?
    • 确实,没有简单的方法来应用.gitignore 规则集。您可以让 Git 为您执行此操作,但这会非常慢,并且会堆积在已经非常慢的过滤器分支之上,这可能是个坏主意。 (这也很棘手:你需要一个临时工作树和临时索引来实现它,并且你已经在使用临时工作树和临时索引,所以如果你尝试它会有很多洗牌。)跨度>
    • 定期做可能不切实际,但在从其他VC系统切换到git同时保持历史记录时很有用。
    【解决方案2】:

    在 Windows 上,这个序列对我不起作用

    cp -R /tmp/ignores-to-add . &&
    git rm --cached -qrf . &&
    git add . &&
    git clean -fqX
    

    但以下工作。

    使用现有的 .gitignore 更新每个提交:

    git filter-branch --index-filter '
      git ls-files -i --exclude-from=.gitignore | xargs git rm --cached -q 
    ' -- --all
    

    在每个提交和过滤文件中更新 .gitignore:

    cp ../.gitignore /d/tmp-gitignore
    git filter-branch --index-filter '
      cp /d/tmp-gitignore ./.gitignore
      git add .gitignore
      git ls-files -i --exclude-from=.gitignore | xargs git rm --cached -q 
    ' -- --all
    rm /d/tmp-gitignore
    

    如果您有特殊情况,请使用grep -v,例如文件empty 保留空目录:

    git ls-files -i --exclude-from=.gitignore | grep -vE "empty$" | xargs git rm --cached -q
    

    【讨论】:

    • 在每次提交中都不存在 .gitignore 的干净存储库上使用 git filter-branch --index-filter ' git ls-files -i --exclude-from=.gitignore | xargs git rm --cached -q ' -- --all 时,我收到以下错误: 1-fatal:无法使用 .gitignore 作为排除文件 2-rm:无法删除“.git-rewrite”:目录不为空
    • 我也无法让 filter-branch 从临时位置添加 .gitignore,大概是因为它只对没有 tmp-gitignore 文件的现有提交进行操作 - tmp-gitignore需要在回购之外?
    • @goofology,是的,使用git filter-branch --index-filter ' git ls-files -i --exclude-from=.gitignore | xargs git rm --cached -q ' -- --all 假设 .gitignore 存在于每个提交中。是的,tmp-gitignore 应该在外面,我的例子是cp ../.gitignore /d/tmp-gitignore
    • 小心 - 有时过滤器分支内cp 的相对路径不起作用,不知道为什么。这就是为什么使用固定路径/d/tmp-gitignore 的原因。
    • 我执行了 git rebase -i --root 并将我的 gitignore 提交移至根目录。现在在执行上述命令时,我收到:致命:不能使用 .gitignore 作为排除文件。 :/。这可能是因为我的 gitignore 中有异常(如 *.ext \ !*special.ext)?
    【解决方案3】:

    这种方法使 git 完全忘记忽略的文件(过去/现在/未来),但从工作目录中删除任何内容(甚至从远程重新拉出时)。

    此方法需要在所有有文件的提交中使用/.git/info/exclude(首选) 预先存在的 .gitignore被忽略/忘记。 1

    所有强制执行 git 的方法都会忽略事后的行为,从而有效地重写历史记录,因此对于在此过程之后可能被拉取的任何公共/共享/协作存储库都有significant ramifications2

    一般建议:从干净的 repo 开始 - 所有内容都已提交,工作目录或索引中没有任何待处理的内容,并进行备份

    此外,this answer 的 cmets/revision historythis questionand revision history)可能有用/启发性。

    #commit up-to-date .gitignore (if not already existing)
    #this command must be run on each branch
    
    git add .gitignore
    git commit -m "Create .gitignore"
    
    #apply standard git ignore behavior only to current index, not working directory (--cached)
    #if this command returns nothing, ensure /.git/info/exclude AND/OR .gitignore exist
    #this command must be run on each branch
    
    git ls-files -z --ignored --exclude-standard | xargs -0 git rm --cached
    
    #Commit to prevent working directory data loss!
    #this commit will be automatically deleted by the --prune-empty flag in the following command
    #this command must be run on each branch
    
    git commit -m "ignored index"
    
    #Apply standard git ignore behavior RETROACTIVELY to all commits from all branches (--all)
    #This step WILL delete ignored files from working directory UNLESS they have been dereferenced from the index by the commit above
    #This step will also delete any "empty" commits.  If deliberate "empty" commits should be kept, remove --prune-empty and instead run git reset HEAD^ immediately after this command
    
    git filter-branch --tree-filter 'git ls-files -z --ignored --exclude-standard | xargs -0 git rm -f --ignore-unmatch' --prune-empty --tag-name-filter cat -- --all
    
    #List all still-existing files that are now ignored properly
    #if this command returns nothing, it's time to restore from backup and start over
    #this command must be run on each branch
    
    git ls-files --other --ignored --exclude-standard
    

    最后,关注this GitHub guide 的其余部分(从第 6 步开始)其中包括有关以下命令的重要警告/信息

    git push origin --force --all
    git push origin --force --tags
    git for-each-ref --format="delete %(refname)" refs/original | git update-ref --stdin
    git reflog expire --expire=now --all
    git gc --prune=now
    

    从现在修改的远程仓库中提取的其他开发人员应该进行备份,然后:

    #fetch modified remote
    
    git fetch --all
    
    #"Pull" changes WITHOUT deleting newly-ignored files from working directory
    #This will overwrite local tracked files with remote - ensure any local modifications are backed-up/stashed
    #Switching branches after this procedure WILL LOOSE all newly-gitignored files in working directory because they are no longer tracked when switching branches
    
    git reset FETCH_HEAD
    

    脚注

    1 因为/.git/info/exclude 可以使用上述说明应用于所有历史提交,也许是有关将.gitignore 文件放入历史提交的详细信息需要它超出了这个答案的范围。我想要一个正确的.gitignore 在根提交中,就好像这是我做的第一件事一样。其他人可能不在乎,因为/.git/info/exclude 可以完成相同的事情,无论.gitignore 存在于提交历史中的哪个位置,并且显然重写历史是一个非常敏感的主题,即使知道@ 987654327@.

    FWIW,潜在的方法可能包括 git rebasegit filter-branch,它们将 external .gitignore 复制到每个提交中,例如 this question 的答案

    2 通过提交独立的git rm --cached 命令的结果来强制执行 git 事后忽略行为可能会导致新忽略的文件 删除 将来从强制推送的遥控器。以下git filter-branch 命令中的--prune-empty 标志通过自动删除先前的“删除所有忽略的文件”仅索引提交来避免此问题。重写 git 历史也会更改提交哈希,这将在未来从公共/共享/协作存储库中提取时 wreak havoc。在对这样的回购执行此操作之前,请充分理解ramificationsThis GitHub guide 指定以下内容:

    告诉你的合作者rebase不要合并他们从你旧的(受污染的)存储库历史创建的任何分支。一次合并提交可能会重新引入部分或全部您刚刚费力清除的受污染历史。

    影响远程仓库的替代解决方案是git update-index --assume-unchanged </path/file>git update-index --skip-worktree <file>,可以在here 找到示例。

    【讨论】:

      猜你喜欢
      • 2019-12-15
      • 1970-01-01
      • 2012-12-11
      • 2020-10-30
      • 2021-04-20
      • 2012-07-25
      • 2020-03-17
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多