简体中文 ▾ 主题 ▾ 最新版本 ▾ git-filter-branch 最后更新于 2.44.0

名称

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 错误等情况有用,因此这种用法是被允许的。

注意:此命令尊重 .git/info/grafts 文件和 refs/replace/ 命名空间中的引用。如果你定义了任何嫁接或替换引用,运行此命令将使它们变为永久性的。

警告!重写后的历史中所有对象的名字(Object Name)都会改变,且无法与原始分支合并。你将无法轻易地在原始分支之上推送和分发重写后的分支。如果你不了解其全面影响,请不要使用此命令;即使知道影响,如果一个简单的单次提交就能解决问题,也请避免使用它。(有关重写已发布历史的更多信息,请参阅 git-rebase[1] 中的“从上游变基中恢复”章节。)

务必验证重写版本是否正确:原始引用(如果与重写后的引用不同)将存储在 refs/original/ 命名空间中。

请注意,由于此操作的 I/O 开销非常大,使用 -d 选项将临时目录重定向到磁盘之外(例如 tmpfs 上)可能是一个好主意。据报道,提速效果非常明显。

过滤器

过滤器按如下顺序列出并应用。<command> 参数始终使用 eval 命令在 shell 环境中求值(由于技术原因,提交过滤器除外)。在此之前,环境变量 $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>

仅查看涉及指定子目录的历史。结果将包含该目录(且仅包含该目录)作为其项目根目录。暗示了 重映射至祖先 (Remap to ancestor)

--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>

这是用于重写提交父节点列表的过滤器。它将在标准输入中接收父节点字符串,并应在标准输出中输出新的父节点字符串。父节点字符串的格式详见 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>)…​]",日志消息作为标准输入。标准输出应返回提交 ID。

作为一种特殊扩展,提交过滤器可以输出多个提交 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 的选项分隔开。暗示了 重映射至祖先 (Remap to ancestor)

重映射至祖先 (Remap to ancestor)

通过使用 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 清除所有引用日志。

  • 使用 git gc --prune=now 对所有未引用的对象进行垃圾回收(或者如果你的 git-gc 版本太旧不支持 --prune 的参数,请改用 git repack -ad; git prune)。

性能

git-filter-branch 的性能极其缓慢;它的设计使得向后兼容的实现永远无法达到快速。

  • 在编辑文件时,git-filter-branch 的设计是检出原仓库中的每一个提交。如果你的仓库有 10^5 个文件和 10^5 个提交,但每个提交只修改了 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 手册页中的“讨论”部分。

  • 由于以下两个问题之一,附注标签(Annotated tags)可能会被意外转换为轻量标签:

    • 某人可能执行了历史重写,意识到搞砸了,从 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,这只会更新作者和提交者,漏掉打标签者(taggers)。

  • 如果用户提供的 --tag-name-filter 将多个标签映射到同一个名称,不会提供警告或错误;git-filter-branch 只是以某种未记录的预定义顺序覆盖每个标签,最终只剩下一个标签。(git-filter-branch 的一个回归测试要求这种令人惊讶的行为。)

此外,git-filter-branch 糟糕的性能经常导致安全性问题:

  • 除非你只是进行删除几个文件之类的琐碎修改,否则构思出正确的 shell 片段来执行所需的过滤有时是很困难的。不幸的是,人们通常通过尝试来学习片段是对是错,但对错可能因特殊情况而异(文件名中的空格、非 ASCII 文件名、古怪的作者姓名或邮箱、无效的时区、嫁接或替换对象等),这意味着他们可能需要等待很长时间,遇到错误,然后重新开始。git-filter-branch 的性能如此之差,以至于这种循环非常痛苦,减少了仔细复核的时间(更不用说这会对执行重写的人的耐心造成什么影响,即使他们技术上确实有更多可用时间)。由于损坏的过滤器产生的错误可能很长时间都不会显示,或者淹没在海量输出中,这个问题会进一步加剧。更糟糕的是,损坏的过滤器往往只会导致静默的错误重写。

  • 最糟糕的是,即使当用户终于找到可用的命令时,他们自然想要分享。但他们可能没有意识到自己的仓库没有别人仓库中的某些特殊情况。因此,当拥有不同仓库的其他人运行相同的命令时,他们就会受到上述问题的打击。或者,用户运行的命令确实针对特殊情况进行了审查,但他们在不同的操作系统上运行,如上所述,该命令在那里无法工作。

GIT

Git[1] 套件的一部分