如何基于gitignore过滤历史记录?

How to filter history based on gitignore?

要澄清这个问题,我不是在问如何从历史记录中删除单个文件,例如这个问题:Completely remove file from all Git repository commit history. I am also not asking about untracking files from gitignore, like in this question: Ignore files that have already been committed to a Git repository

我说的是"updating a .gitignore file, and subsequently removing everything matching the list from history",或多或少像这个问题: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 保留 。然而,现在没有什么可以从历史中删除。应该修改什么来实现这个目标?

如果这可以在多个分支上完成,则可加分。但现在,将它保留在一个主分支上。

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

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

现在,重新提交可从一个或多个分支 and/or 标记名称访问的每个提交,保留除我们明确告诉它更改的内容之外的所有内容,1 我们可以使用 git filter-branch。 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)

(假设文件名中没有白色 space 问题;使用 xargs ... | rm -f 避免白色 space 问题,使用您喜欢的 xargs 输入编码;-z样式编码是理想的,因为 [=35=] 在路径名中被禁止)。

将其转换为索引过滤器

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

假设您在 /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

(我在这里添加了几个"quiet"标志)。

为了避免首先需要变基来安装初始 .gitignore 文件,假设您在每次提交时都有一个 .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.

之前变基

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

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

最后一步,git clean -qfX,删除被忽略的工作树文件,这样 filter-branch 就不会 把它们放回去。

在 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

This method makes git completely forget ignored files (past/present/future), but does not delete anything from working directory (even when re-pulled from remote).

This method requires usage of /.git/info/exclude (preferred) OR a pre-existing .gitignore in all the commits that have files to be ignored/forgotten. 1

All methods of enforcing git ignore behavior after-the-fact effectively re-write history and thus have significant ramifications for any public/shared/collaborative repos that might be pulled after this process. 2

General advice: start with a clean repo - everything committed, nothing pending in working directory or index, and make a backup!

Also, the comments/revision history of (and revision history of this question) may be useful/enlightening.

#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 步开始),其中包括关于以下命令的重要warnings/information

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 文件的详细信息 into 需要它的历史提交超出了这个答案的范围。我希望在根提交中有一个合适的 .gitignore,就好像这是我做的第一件事一样。其他人可能不在乎,因为 /.git/info/exclude 可以完成相同的事情,无论 .gitignore 存在于提交历史中的什么位置,显然重写历史是一个 非常 敏感的主题,即使知道 ramifications.

FWIW,可能的方法可能包括 git rebasegit filter-branchexternal .gitignore 复制到每个提交中,例如

2 通过提交独立 git rm --cached 命令的结果强制执行 git 事后忽略行为可能会导致新忽略的文件 删除 将来会从强制推送的遥控器中拉出。以下 git filter-branch 命令中的 --prune-empty 标志通过自动删除先前的“删除所有忽略的文件”仅索引提交来避免此问题。重写 git 历史也会更改提交哈希,这将 对 public/shared/collaborative 回购的未来拉动造成严重破坏 。在对这样的 repo 执行此操作之前,请充分理解 ramifications此 GitHub 指南 指定以下内容:

Tell your collaborators to rebase, not merge, any branches they created off of your old (tainted) repository history. One merge commit could reintroduce some or all of the tainted history that you just went to the trouble of purging.

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