git-filter-branch
重写分支
概要
'git filter-branch' [--setup <command>] [--subdirectory-filter <directory>]
[--env-filter <command>] [--tree-filter <command>]
[--index-filter <command>] [--parent-filter <command>]
[--msg-filter <command>] [--commit-filter <command>]
[--tag-name-filter <command>] [--prune-empty]
[--original <namespace>] [-d <directory>] [-f | --force]
[--state-branch <branch>] [--] [<rev-list-options>...]警告
'git filter-branch' 有大量陷阱,可能导致对预期历史重写的非明显篡改(并且由于其糟糕的性能,可能没有时间调查此类问题)。这些安全和性能问题无法向后兼容修复,因此不建议使用。请使用替代的历史过滤工具,如 git filter-repo。如果您仍需要使用 'git filter-branch',请仔细阅读安全性(和性能)部分以了解 filter-branch 的隐患,然后尽可能警惕地避免那里列出的危害。
描述
允许您通过重写 <rev-list-options> 中提到的分支来重写 Git 修订历史,对每个修订应用自定义过滤器。这些过滤器可以修改每个树(例如删除文件或对所有文件运行 perl 重写)或每个提交的信息。否则,所有信息(包括原始提交时间或合并信息)将被保留。
该命令只会重写命令行中提到的正引用(例如,如果您传递 'a..b',只会重写 'b')。如果您不指定过滤器,提交将在没有任何更改的情况下重新提交,这通常没有效果。尽管如此,这将来可能对补偿某些 Git bug 或类似情况有用,因此允许此类用法。
注意:此命令遵守 .git/info/grafts 文件和 refs/replace/ 命名空间中的引用。如果您定义了任何移植或替换引用,运行此命令将使它们永久化。
警告!重写的历史将对所有对象具有不同的对象名称,并且不会与原始分支收敛。您将无法轻松地将重写的分支推送到原始分支之上。如果您不知道全部影响,请勿使用此命令,如果简单的单个提交就足以修复您的问题,请避免使用它。(有关重写已发布历史的更多信息,请参阅 git-rebase(1) 中的"从上游变基恢复"部分。)
始终验证重写版本是否正确:原始引用(如果与重写的不同)将存储在命名空间 'refs/original/' 中。
请注意,由于此操作非常消耗 I/O,使用 -d 选项将临时目录重定向到磁盘外(例如在 tmpfs 上)可能是个好主意。据报道加速非常明显。
过滤器
过滤器按下面列出的顺序应用。<command> 参数始终在 shell 上下文中使用 'eval' 命令评估(提交过滤器因技术原因而例外)。在此之前,$GIT_COMMIT 环境变量将被设置为包含正在重写的提交的 ID。此外,GIT_AUTHOR_NAME、GIT_AUTHOR_EMAIL、GIT_AUTHOR_DATE、GIT_COMMITTER_NAME、GIT_COMMITTER_EMAIL 和 GIT_COMMITTER_DATE 从当前提交中获取并导出到环境中,以便影响过滤器运行后 git-commit-tree(1) 创建的替换提交的作者和提交者身份。
如果 <command> 的任何评估返回非零退出状态,整个操作将被中止。
有一个可用的 'map' 函数,它接受"原始 sha1 id"参数,如果提交已被重写则输出"重写的 sha1 id",否则输出"原始 sha1 id";如果您的提交过滤器发出多个提交,'map' 函数可以在单独的行上返回多个 id。
选项
--setup <command>这不是为每个提交执行的真正过滤器,而是循环之前的一次性设置。因此尚未定义特定于提交的变量。此处定义的函数或变量可在后续过滤步骤中使用或修改,但提交过滤器除外(因技术原因)。--subdirectory-filter <directory>仅查看涉及给定子目录的历史。结果将包含该目录(且仅该目录)作为其项目根目录。隐含重映射到祖先。--env-filter <command>如果您只需要修改将执行提交的环境,可以使用此过滤器。具体来说,您可能想要重写作者/提交者名称/电子邮件/时间环境变量(有关详细信息,请参阅 git-commit-tree(1))。--tree-filter <command>这是用于重写树及其内容的过滤器。参数在 shell 中评估,工作目录设置为检出树的根目录。然后按原样使用新树(新文件自动添加,消失的文件自动删除——.gitignore 文件或任何其他忽略规则没有任何效果!)。--index-filter <command>这是用于重写索引的过滤器。它类似于树过滤器,但不检出树,这使其更快。经常与git rm --cached --ignore-unmatch ...一起使用,请参阅下面的示例。有关棘手情况,请参阅 git-update-index(1)。--parent-filter <command>这是用于重写提交的父列表的过滤器。它将在 stdin 上接收父字符串,并应在 stdout 上输出新的父字符串。父字符串的格式如 git-commit-tree(1) 中所述:初始提交为空,普通提交为 "-p parent",合并提交为 "-p parent1 -p parent2 -p parent3 ..."。--msg-filter <command>这是用于重写提交消息的过滤器。参数在 shell 中评估,原始提交消息在标准输入上;其标准输出用作新的提交消息。--commit-filter <command>这是用于执行提交的过滤器。如果指定了此过滤器,它将代替 'git commit-tree' 命令被调用,参数形式为 "<TREE_ID> [(-p <PARENT_COMMIT_ID>)...]",日志消息在 stdin 上。期望在 stdout 上输出提交 id。 作为特殊扩展,提交过滤器可以发出多个提交 id;在这种情况下,原始提交的重写子提交将把它们全部作为父提交。 您可以在此过滤器中使用 'map' 便利函数,以及其他便利函数。例如,调用 'skip_commit "$@"' 将省略当前提交(但不是其更改!如果您想要那样,请使用 'git rebase')。 如果您不希望保留只有一个父提交且不对树进行任何更改的提交,也可以使用git_commit_non_empty_tree "$@"代替git commit-tree "$@"。--tag-name-filter <command>这是用于重写标签名称的过滤器。传入后,它将为指向重写对象(或指向重写对象的标签对象)的每个标签引用被调用。原始标签名称通过标准输入传递,新的标签名称在标准输出上。 原始标签不会被删除,但可以被覆盖;使用 "--tag-name-filter cat" 简单更新标签。在这种情况下,请非常小心并确保备份了旧标签,以防转换出错。 支持近乎正确的标签对象重写。如果标签附有消息,将创建具有相同消息、作者和时间戳的新标签对象。如果标签附有签名,签名将被剥离。根据定义不可能保留签名。这"近乎"正确的原因是,理想情况下,如果标签未更改(指向相同的对象、具有相同的名称等),它应该保留任何签名。但事实并非如此,签名将始终被移除,买家请注意。也不支持更改作者或时间戳(或标签消息)。指向其他标签的标签将被重写为指向底层提交。--prune-empty某些过滤器将生成空提交,使树保持不变。此选项指示 git-filter-branch 删除此类提交,如果它们恰好有一个或零个未修剪的父提交;因此合并提交将保持完整。此选项不能与--commit-filter一起使用,但可以通过在提交过滤器中使用提供的git_commit_non_empty_tree函数来实现相同的效果。--original <namespace>使用此选项设置存储原始提交的命名空间。默认值为 'refs/original'。-d <directory>使用此选项设置用于重写的临时目录的路径。应用树过滤器时,命令需要临时将树检出到某个目录,对于大型项目这可能消耗大量空间。默认情况下它在.git-rewrite/目录中执行此操作,但您可以通过此参数覆盖该选择。-f,--force'git filter-branch' 拒绝在存在现有临时目录或已有以 'refs/original/' 开头的引用时启动,除非强制执行。--state-branch <branch>此选项将导致从命名分支加载旧对象到新对象的映射,并在退出时作为新提交保存到该分支,从而实现大型树的增量处理。如果 '<branch>' 不存在,将创建它。<rev-list options>...'git rev-list' 的参数。这些选项包含的所有正引用都会被重写。您也可以指定--all等选项,但必须使用--将它们与 'git filter-branch' 选项分开。隐含重映射到祖先。
重映射到祖先
通过使用 git-rev-list(1) 参数(例如路径限制器),您可以限制被重写的修订集。但是,命令行上的正引用是有区别的:我们不允许它们被此类限制器排除。为此目的,它们被重写为指向未被排除的最近祖先。
退出状态
成功时,退出状态为 0。如果过滤器找不到任何要重写的提交,退出状态为 2。在任何其他错误时,退出状态可以是任何其他非零值。
示例
假设您想从所有提交中删除包含机密信息或侵犯版权的文件:
git filter-branch --tree-filter 'rm filename' HEAD但是,如果某些提交的树中不存在该文件,简单的 rm filename 将对该树和提交失败。因此您可能想改用 rm -f filename 作为脚本。
使用 --index-filter 与 'git rm' 可以产生显著更快的版本。与使用 rm filename 一样,如果文件在提交的树中不存在,git rm --cached filename 将失败。如果您想"完全忘记"一个文件,它何时进入历史并不重要,因此我们还添加了 --ignore-unmatch:
git filter-branch --index-filter 'git rm --cached --ignore-unmatch filename' HEAD现在,您将获得保存在 HEAD 中的重写历史。
要将仓库重写为看起来 foodir/ 一直是其项目根目录,并丢弃所有其他历史:
git filter-branch --subdirectory-filter foodir -- --all因此,您可以将库子目录变成自己的仓库。注意将 'filter-branch' 选项与修订选项分开的 --,以及重写所有分支和标签的 --all。
要将某个提交(通常在另一个历史的顶端)设置为当前初始提交的父提交,以便将另一个历史粘贴在当前历史之后:
git filter-branch --parent-filter 'sed "s/^\$/-p <graft-id>/"' HEAD(如果父字符串为空——在处理初始提交时会发生——添加 graftcommit 作为父提交)。请注意,这假设具有单个根的历史(即没有发生没有共同祖先的合并)。如果不是这种情况,请使用:
git filter-branch --parent-filter \
'test $GIT_COMMIT = <commit-id> && echo "-p <graft-id>" || cat' HEAD或者更简单:
git replace --graft $commit-id $graft-id
git filter-branch $graft-id..HEAD要从历史中删除由 "Darl McBribe" 编写的提交:
git filter-branch --commit-filter '
if [ "$GIT_AUTHOR_NAME" = "Darl McBribe" ];
then
skip_commit "$@";
else
git commit-tree "$@";
fi' HEAD函数 'skip_commit' 定义如下:
skip_commit()
{
shift;
while [ -n "$1" ];
do
shift;
map "$1";
shift;
done;
}shift 魔法首先丢弃树 ID,然后丢弃 -p 参数。请注意,这会正确处理合并!如果 Darl 提交了 P1 和 P2 之间的合并,它将被正确传播,合并的所有子提交将成为以 P1,P2 作为父提交的合并提交,而不是原来的合并提交。
注意:由提交引入且未被后续提交还原的更改仍将存在于重写的历史中。如果您想将_更改_与提交一起丢弃,应使用 'git rebase' 的交互模式。
您可以使用 --msg-filter 重写提交日志消息。例如,'git svn' 创建的仓库中的 'git svn-id' 字符串可以这样删除:
git filter-branch --msg-filter '
sed -e "/^git-svn-id:/d"
'如果您需要向最后 10 个提交(都不是合并)添加 'Acked-by' 行,请使用此命令:
git filter-branch --msg-filter '
cat &&
echo "Acked-by: Bugs Bunny <bunny@bugzilla.org>"
' HEAD~10..HEAD--env-filter 选项可用于修改提交者和/或作者身份。例如,如果您发现由于配置错误的 user.email 导致提交具有错误的身份,可以在发布项目之前进行更正,如下所示:
git filter-branch --env-filter '
if test "$GIT_AUTHOR_EMAIL" = "root@localhost"
then
GIT_AUTHOR_EMAIL=john@example.com
fi
if test "$GIT_COMMITTER_EMAIL" = "root@localhost"
then
GIT_COMMITTER_EMAIL=john@example.com
fi
' -- --all要将重写限制为历史的某一部分,请在新分支名称之外指定修订范围。新分支名称将指向 'git rev-list' 将打印的此范围的最高修订。
考虑此历史:
D--E--F--G--H
/ /
A--B-----C要仅重写提交 D,E,F,G,H,但保留 A、B 和 C 不变,请使用:
git filter-branch ... C..H要重写提交 E,F,G,H,请使用以下之一:
git filter-branch ... C..H --not D
git filter-branch ... D..H --not C要将整个树移入子目录或从中移出:
git filter-branch --index-filter \
'git ls-files -s | sed "s-\t\"*-&newsubdir/-" |
GIT_INDEX_FILE=$GIT_INDEX_FILE.new \
git update-index --index-info &&
mv "$GIT_INDEX_FILE.new" "$GIT_INDEX_FILE"' HEAD缩小仓库清单
git-filter-branch 可用于摆脱文件子集,通常使用 --index-filter 和 --subdirectory-filter 的某种组合。人们期望结果仓库比原始仓库更小,但您需要更多步骤才能实际使其更小,因为 Git 会尽力不丢失您的对象,直到您告诉它。首先确保:
如果 blob 在其生命周期中被移动过,您确实删除了文件名的所有变体。
git log --name-only --follow --all -- filename可以帮助您找到重命名。您确实过滤了所有引用:调用 git-filter-branch 时使用
--tag-name-filter cat -- --all。
然后有两种方法可以获得更小的仓库。更安全的方法是克隆,它保持原始仓库不变。
- 使用
git clone file:///path/to/repo克隆它。克隆不会有被删除的对象。参见 git-clone(1)。(请注意,使用纯路径克隆只是硬链接所有内容!)
如果您确实不想克隆它,无论出于何种原因,请改为检查以下几点(按此顺序)。这是一种非常具有破坏性的方法,因此请备份或回到克隆它。您已被警告。
删除 git-filter-branch 备份的原始引用:执行
git for-each-ref --format="%(refname)" refs/original/ | xargs -n 1 git update-ref -d。使用
git reflog expire --expire=now --all过期所有 reflog。使用
git gc --prune=now垃圾回收所有未引用的对象(或者如果您的 git-gc 不够新以支持--prune参数,请改用git repack -ad; git prune)。
性能
git-filter-branch 的性能极其缓慢;其设计使得向后兼容的实现永远无法快速:
在编辑文件时,git-filter-branch 按设计检出原始仓库中存在的每个提交。如果您的仓库有
10^5个文件和10^5个提交,但每个提交只修改五个文件,那么 git-filter-branch 将使您进行10^10次修改,尽管最多只有5*10^5个唯一的 blob。如果您试图作弊并尝试使 git-filter-branch 仅处理提交中修改的文件,那么会发生两件事:
- 当用户只是尝试重命名文件时,您会遇到删除问题(因为尝试删除不存在的文件看起来像无操作;当重命名通过任意用户提供的 shell 发生时,跨文件重命名重新映射删除需要一些诡计)。
- 即使您成功完成了重命名的映射删除诡计,您仍然在技术上违反了向后兼容性,因为用户可以以依赖提交拓扑而不是仅基于文件内容或名称进行过滤的方式过滤文件(尽管在实际中尚未观察到这种情况)。
即使您不需要编辑文件而只想重命名或删除某些文件,从而可以避免检出每个文件(即您可以使用 --index-filter),您仍然为过滤器传递 shell 片段。这意味着对于每个提交,您必须有一个准备好的 git 仓库来运行这些过滤器。这是一个重要的设置。
此外,git-filter-branch 每个提交创建或更新几个额外文件。其中一些是为了支持 git-filter-branch 提供的便利函数(如 map()),而另一些是为了跟踪内部状态(但也可以被用户过滤器访问;git-filter-branch 的一个回归测试就是这样做的)。这本质上相当于使用文件系统作为 git-filter-branch 和用户提供的过滤器之间的 IPC 机制。磁盘往往是缓慢的 IPC 机制,编写这些文件也有效地代表了我们在每个提交时遇到的单独进程之间的强制同步点。
用户提供的 shell 命令可能涉及命令管道,导致每个提交创建许多进程。创建和运行另一个进程在操作系统之间所需的时间差异很大,但在任何平台上,与调用函数相比都非常慢。
git-filter-branch 本身是用 shell 编写的,这有点慢。这是可以向后兼容修复的唯一性能问题,但与上述 git-filter-branch 设计固有的问题相比,工具本身的语言是一个相对较小的问题。 附注:不幸的是,人们往往专注于用 shell 编写这一方面,并定期询问是否可以用另一种语言重写 git-filter-branch 以修复性能问题。这不仅忽略了设计中更大的固有问题,而且帮助也比您预期的要少:如果 git-filter-branch 本身不是 shell,那么便利函数(map()、skip_commit() 等)和
--setup参数就不能在程序开始时执行一次,而是需要添加到每个用户过滤器之前(因此每个提交都会重新执行)。
git filter-repo 工具是 git-filter-branch 的替代方案,不存在这些性能问题或安全问题(如下所述)。对于依赖 git-filter-branch 的现有工具,'git filter-repo' 还提供 filter-lamely,一个直接的 git-filter-branch 替代品(有一些注意事项)。虽然 filter-lamely 存在与 git-filter-branch 相同的所有安全问题,但它至少稍微改善了性能问题。
安全性
git-filter-branch 充满了陷阱,导致各种方式容易损坏仓库或最终得到比开始时更糟的混乱:
某人可能有一组"经过工作和测试的过滤器",他们将其记录或提供给同事,然后同事在不同的操作系统上运行它们,在那里相同的命令未经工作/测试(git-filter-branch 手册页中的一些示例也受此影响)。BSD 与 GNU 用户空间的差异真的很伤人。如果幸运,会喷出错误消息。但同样可能的是,命令要么不执行请求的过滤,要么通过进行一些不需要的更改而静默损坏。不需要的更改可能只影响少数提交,因此不一定明显。(问题不一定明显这一事实意味着它们可能在重写的历史使用相当长时间后才被注意到,此时很难证明另一个标志日进行另一次重写是合理的。)
带空格的文件名经常被 shell 片段处理不当,因为它们会给 shell 管道带来问题。不是每个人都熟悉 find -print0、xargs -0、git-ls-files -z 等。即使熟悉这些的人也可能认为这些标志无关紧要,因为其他人在做过滤的人加入项目之前很久就重命名了仓库中的任何此类文件。而且,即使熟悉处理带空格参数的人也可能不会这样做,只是因为他们没有考虑所有可能出错的心态。
非 ASCII 文件名可能被静默删除,尽管位于所需目录中。仅保留所需路径通常使用
git ls-files | grep -v ^WANTED_DIR/ | xargs git rm等管道完成。ls-files 仅在需要时才引用文件名,因此人们可能没有注意到其中一个文件与正则表达式不匹配(至少不会直到为时已晚)。是的,知道 core.quotePath 的人可以避免这种情况(除非他们有其他特殊字符如 \t、\n 或 "),使用 ls-files -z 与 grep 以外的内容的人也可以避免这种情况,但这并不意味着他们会这样做。类似地,移动文件时,可能会发现具有非 ASCII 或特殊字符的文件名最终位于不同的目录中,该目录包含双引号字符。(这在技术上与上面的引用问题相同,但也许是一种有趣的不同表现方式。)
太容易意外混淆新旧历史。使用任何工具仍然可能,但 git-filter-branch 几乎邀请这样做。如果幸运,唯一的缺点是用户不知道如何缩小仓库并删除旧东西而感到沮丧。如果不幸,他们合并了新旧历史,最终得到每个提交的多个"副本",其中一些有不需要的或敏感的文件,而另一些没有。这以多种不同方式出现:
- 默认只进行部分历史重写('--all' 不是默认值,很少有示例显示它)
- 没有自动运行后清理
- --tag-name-filter(用于重命名标签时不删除旧标签,只是添加具有新名称的新标签)
- 提供的教育信息很少,告知用户重写的后果以及如何避免混淆新旧历史。例如,此手册页讨论用户需要理解他们需要将所有分支的更改变基到新历史之上(或删除并重新克隆),但这只是需要考虑的多个问题之一。有关更多详细信息,请参阅 git filter-repo 手册页的"讨论"部分。
带注释的标签可能意外转换为轻量级标签,由于两个问题之一:
- 某人可以进行历史重写,意识到搞砸了,从 refs/original/ 中的备份恢复,然后重做他们的 git-filter-branch 命令。(refs/original/ 中的备份不是真正的备份;它首先解引用标签。)
- 在 <rev-list-options> 中使用 --tags 或 --all 运行 git-filter-branch。为了保留带注释的标签为带注释,您必须使用 --tag-name-filter(并且不得在之前搞砸的重写中从 refs/original/ 恢复)。
指定编码的任何提交消息将被重写损坏;git-filter-branch 忽略编码,获取原始字节,并将其馈送到 commit-tree 而不告诉它正确的编码。(无论是否使用 --msg-filter 都会发生这种情况。)
提交消息(即使都是 UTF-8)默认会因未更新而损坏——提交消息中对其他提交哈希的任何引用现在将引用不再存在的提交。
没有帮助用户找到应删除的不需要的垃圾的工具,这意味着他们更有可能进行不完整或部分的清理,有时会导致困惑和人们浪费时间尝试理解。(例如,人们往往只查找要删除的大文件而不是大目录或扩展名,一旦他们这样做,那么稍后使用新仓库浏览历史的人会注意到构建工件目录有一些文件而没有其他文件,或者依赖缓存(node_modules 或类似物)不可能起作用,因为它缺少一些文件。)
如果未指定 --prune-empty,则过滤过程可能会创建大量令人困惑的空提交。
如果指定了 --prune-empty,则过滤操作之前有意放置的空提交也会被修剪,而不仅仅是因过滤规则变空的提交。
如果指定了 --prune-empty,有时空提交会被遗漏并留在原处(一个相当罕见的 bug,但它确实发生...)
一个小问题是,目标是更新仓库中所有名称和电子邮件的用户可能会被引导到 --env-filter,它只会更新作者和提交者,而遗漏标记者。
如果用户提供了将多个标签映射到同名的 --tag-name-filter,不会提供警告或错误;git-filter-branch 只是以某种未记录的预定义顺序覆盖每个标签,最终只留下一个标签。(git-filter-branch 的回归测试需要这种令人惊讶的行为。)
此外,git-filter-branch 的糟糕性能经常导致安全问题:
除非您只是做一些微不足道的修改(如删除几个文件),否则想出正确的 shell 片段来做您想要的过滤有时很困难。不幸的是,人们经常通过尝试来了解片段是对还是错,但对错可能取决于特殊情况(文件名中的空格、非 ASCII 文件名、奇怪的作者姓名或电子邮件、无效时区、移植或替换对象的存在等),这意味着他们可能需要等待很长时间,遇到错误,然后重新开始。git-filter-branch 的性能如此糟糕,以至于这个循环很痛苦,减少了仔细重新检查的时间(更不用说它对做重写的人的耐心的影响了,即使他们确实有更多时间可用)。这个问题特别复杂,因为来自损坏过滤器的错误可能很长时间不会显示和/或在输出的海洋中丢失。更糟糕的是,损坏的过滤器通常只导致静默的不正确重写。
最重要的是,即使用户最终找到了有效的命令,他们自然想分享它们。但他们可能不知道他们的仓库没有其他人有的某些特殊情况。因此,当拥有不同仓库的其他人运行相同的命令时,他们会遇到上述问题。或者,用户只是运行确实为特殊情况审查过的命令,但在不同的操作系统上运行不起作用,如上所述。
Git
git(1) 套件的一部分
