Git
简体中文 ▾ Topics ▾ Latest version ▾ git-filter-branch last updated in 2.44.0

名称

git-filter-branch - 重写分支

概述

git filter-branch [--setup <命令>] [--subdirectory-filter <目录>]
	[--env-filter <命令>] [--tree-filter <命令>]
	[--index-filter <命令>] [--parent-filter <命令>]
	[--msg-filter <命令>] [--commit-filter <命令>]
	[--tag-name-filter <命令>] [--prune-empty]
	[--original <命名空间>] [-d <目录>] [-f | --force]
	[--state-branch <分支>] [--] [<rev-list options>…​]

WARNING

git filter-branch 存在大量隐患,可能会对预期的历史重写产生不明显的误差(而且由于其性能糟糕,你几乎没有时间去研究这些问题)。 这些安全和性能问题无法向后兼容修复,因此不建议使用。 请使用其他历史过滤工具,如 git filter-repo。 如果您仍然需要使用 git filter-branch,请仔细阅读 SAFETY(和 PERFORMANCE)以了解 filter-branch 的隐患,然后尽可能合理地避免其中列出的危险。

描述

让你可以通过重写 <rev-list 选项> 中提到的分支来重写 Git 修订历史,并对每个修订应用自定义过滤器。 这些过滤器可以修改每棵树(例如删除文件或在所有文件上运行 perl 重写)或每个提交的信息。 否则,所有信息(包括原始提交时间或合并信息)都将被保留。

该命令只会重写命令行中提到的 positive refs(例如,如果您传递 a..b,则只会重写 b)。 如果不指定过滤器,提交将在不做任何改动的情况下重新提交,这通常不会产生任何影响。 不过,这在将来补偿某些 Git bug 或类似问题时可能会有用,因此允许使用这种方法。

注意: 此命令尊重 .git/info/grafts 文件 和 refs/replace/ 命名空间中的引用。 如果您定义了任何移植物或替换引用,运行此命令 将使其永久生效。

警告!重写后的历史记录中,所有对象的名称都会不同,并且不会与原始分支趋同。 对象的名称也会不同,并且不会与原始分支一致。 您将无法 在原始分支的基础上推送和分发重写分支。 原始分支。 如果不了解该命令的全部影响,请不要使用它。 如果简单的单次提交就能解决您的问题,请尽量避免使用该命令。 就能解决问题的话,请避免使用该命令。 (请参阅 git-rebase[1] 中的 "RECOVERING FROM UPSTREAM" 重写已发布历史)。

请务必验证重写版本是否正确:原始参考文件(如果与重写版本不同)将存储在命名空间 refs/original/ 中。

需要注意的是,由于该操作的 I/O 成本很高,因此最好使用 -d 选项将临时目录重定向到磁盘外,例如在 tmpfs 上。 据报道,速度提升非常明显。

Filters

过滤器的应用顺序如下。 <命令> 参数总是在 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] 创建的替换提交的作者和提交者身份。

如果对 <命令> 的任何评估返回非零的退出状态,则整个操作将被终止。

我们提供了一个 map 函数,它接收 原始 sha1 id 参数,如果提交已被改写,则输出 改写后的 sha1 id,否则输出 原始 sha1 id

选项

--setup <命令>

这不是为每次提交执行的真正过滤器,而是在循环之前的一次性设置。因此还没有定义特定于提交的变量。 由于技术原因,此处定义的函数或变量可以在除提交过滤器之外的后续过滤器步骤中使用或修改。

--subdirectory-filter <directory>

只查看涉及给定子目录的历史记录。 结果将包含作为项目根目录的该目录(且仅包含该目录)。隐含 重映射到祖先

--env-filter <命令>

如果只需要修改提交的环境,可以使用这个过滤器。 具体来说,你可能想重写作者/提交者姓名/电子邮件/时间等环境变量(详见 git-commit-tree[1])。

--tree-filter <command>

这是重写树及其内容的过滤器。 参数在 shell 中评估,工作目录设置为签出树的根目录。 新的签出树将按原样使用(自动添加新文件,自动删除已消失的文件—​无论是 .gitignore 文件还是任何其他忽略规则都不会 产生任何影响!)。

--index-filter <命令>

这是用于重写索引的过滤器。 它与树过滤器类似,但不检查树,因此速度更快。 经常与 `git rm --cached --ignore-unmatch …​ ` 一起使用,参见下面的示例。 如需了解更多信息,请参阅 git-update-index[1]

--parent-filter <命令>

这是重写提交父级列表的过滤器。 它通过 stdin 接收父级字符串,并在标准输出流输出新的父级字符串。 父级字符串的格式如 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;在这种情况下,原始提交的重写子代将以所有这些提交为父代。

您可以在此过滤器中使用 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>

该选项将导致在启动时从指定分支加载新旧对象的映射,并在退出时作为新提交保存到该分支,从而实现大树的增量。如果 <分支> 不存在,它将被创建。

<版本列表选项>…​

Arguments for git rev-list. All positive refs included by these options are rewritten. You may also specify options such as --all, but you must use -- to separate them from the git filter-branch options. Implies 重映射到祖先.

重映射到祖先

通过使用 git-rev-list[1] 参数,例如路径限制器,可以限制被改写的版本集。不过,命令行上的正向引用是有区别的:我们不会让它们被此类限制器排除在外。为此,它们会被改写为指向未被排除的最近的祖先。

退出状态码

成功时,退出状态为 0。 如果过滤器找不到要重写的提交,则退出状态为 2。 如果出现其他错误,退出状态可能是任何其他非零值。

实例

假设您想从所有提交中删除一个文件(包含机密信息或侵犯版权):

git filter-branch --tree-filter 'rm filename' HEAD

但是,如果某个提交的树中没有该文件,那么简单的 rm filename 就会在该树和提交中失败。 因此,你可能需要使用 rm -f filename 作为脚本。

--index-filtergit 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 = <提交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;
}

移位魔法会首先删除树 id,然后删除 -p 参数。 请注意,这样可以正确处理合并!如果 Darl (达尔)提交了 P1 和 P2 之间的合并,它将被正确传播,合并的所有子提交都将成为以 P1,P2 为父提交的合并提交,而不是合并提交。

注意 这些提交所引入的变更,如果没有被后续提交所还原 的改动仍将保留在重写分支中。如果你想 要把_changes_连同提交一起扔掉,应该使用 交互式的 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="%(引用名称)" 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 代替)。

PERFORMANCE

The performance of git-filter-branch is glacially slow; its design makes it impossible for a backward-compatible implementation to ever be fast:

  • 在编辑文件时,git-filter-branch 会检查原始仓库中的每个提交。 如果你的仓库有 10^5 个文件和 10^5 次提交,但每次提交只修改了五个文件,那么 git-filter-branch 会让你做 10^10 次修改,尽管(最多)只有 5*10^5 个唯一的 blob。

  • 如果你试图作弊,让 git-filter-branch 只对提交中修改过的文件起作用,那么会发生两种情况

    • 当用户只是试图重命名文件时,就会遇到删除问题(因为试图删除不存在的文件看起来是不可能的;当重命名通过任意用户提供的 shell 进行时,需要一些技巧来重新映射文件重命名时的删除)

    • 即使你成功地使用了 map-deletes-for-renames 的诡计,从技术上讲,你仍然违反了向后兼容性,因为用户可以根据提交的拓扑结构来过滤文件,而不是仅仅根据文件内容或名称来过滤(尽管在实际中还没有观察到这种情况)。

  • 即使您不需要编辑文件,而只想重命名或删除某些文件,从而可以避免检查每个文件(即您可以使用 --index-filter ),您仍然要为过滤器传递 shell 片段。 这意味着每次提交时,您都必须准备一个可以运行这些过滤器的 git repo。 这可是个大工程。

  • 此外,git-filter-branch 还会在每次提交时创建或更新几个额外的文件。 其中一些用于支持 git-filter-branch 提供的便利函数(如 map()),另一些则用于跟踪内部状态(但也可能被用户过滤器访问;git-filter-branch 的一个回归测试就是这么做的)。 这基本上相当于把文件系统用作 git-filter-branch 和用户提供的过滤器之间的 IPC 机制。 磁盘往往是一种缓慢的 IPC 机制,而写入这些文件实际上也代表了我们在每次提交时都要在不同进程间强制同步的点。

  • 用户提供的 shell 命令很可能涉及命令流水线,导致每次提交都要创建许多进程。 在不同的操作系统上,创建和运行另一个进程所需的时间差别很大,但在任何平台上,相对于调用一个函数而言,创建和运行另一个进程都是非常缓慢的。

  • git-filter-branch itself is written in shell, which is kind of slow. This is the one performance issue that could be backward-compatibly fixed, but compared to the above problems that are intrinsic to the design of git-filter-branch, the language of the tool itself is a relatively minor issue.

    • 题外话:不幸的是,人们往往会把注意力集中在用 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 存在同样的安全问题,但它至少在性能上稍有改善。

SAFETY

git-filter-branch 漏洞百出,有各种方法可以轻易破坏仓库,或者最后弄得一团糟,比一开始更糟:

  • 有些人可能有一套 “经过测试的有效过滤器”,他们将其记录下来或提供给同事,但同事在不同的操作系统上运行这些过滤器时,相同的命令却无法正常工作或经过测试(git-filter-branch manpage 中的一些示例也受此影响)。 BSD 与 GNU 的用户态差异确实会让人头疼。 如果幸运的话,会出现错误信息。 但同样有可能的是,这些命令要么没有完成所要求的过滤,要么因为做了一些不必要的改动而无声无息地损坏了程序。 这些不必要的改动可能只影响到几个提交,所以也不一定很明显。 (问题不一定很明显这一事实意味着,在重写的历史被使用一段时间后,这些问题才有可能被发现。)

  • 带有空格的文件名通常会被 shell 片段错误处理,因为它们会给 shell 管道带来问题。 并非每个人都熟悉 find -print0、xargs -0、git-ls-files -z 等。 即使是熟悉这些的人,也可能会认为这些标记无关紧要,因为早在进行过滤的人加入项目之前,就有人重命名了他们的 repo 中的任何此类文件。 即使是熟悉处理带空格参数的人,也可能不会这么做,因为他们没有考虑到所有可能出错的地方。

  • Non-ascii filenames can be silently removed despite being in a desired directory. Keeping only wanted paths is often done using pipelines like git ls-files | grep -v ^WANTED_DIR/ | xargs git rm. ls-files will only quote filenames if needed, so folks may not notice that one of the files didn’t match the regex (at least not until it’s much too late). Yes, someone who knows about core.quotePath can avoid this (unless they have other special characters like \t, \n, or "), and people who use ls-files -z with something other than grep can avoid this, but that doesn’t mean they will.

  • 同样,在移动文件时,我们会发现带有非字符串或特殊字符的文件名最终会出现在不同的目录中,其中包括一个双引号字符。 (从技术上讲,这与上面的引号问题是一样的,但也许是一个有趣的不同方式,它可以并已经表现为一个问题)

  • 不小心混淆新旧历史太容易了。 任何工具都有可能发生这种情况,但 git-filter-branch 几乎是在自找麻烦。 如果幸运的话,唯一的坏处就是用户会因为不知道如何缩小他们的仓库并移除旧的东西而感到沮丧。 如果运气不好,他们就会合并新旧历史,最终每个提交都会有多个 “副本”,其中一些有不需要的文件或敏感文件,另一些则没有。 这种情况有多种不同的方式:

    • 默认情况下只进行部分历史重写(--all 不是默认值,而且很少有例子显示它)

    • 没有运行后的自动清理功能

    • the fact that --tag-name-filter (when used to rename tags) doesn’t remove the old tags but just adds new ones with the new name

    • 事实上,几乎没有提供任何教育信息,让用户了解重写的后果,以及如何避免新旧历史混合。 例如,该手册页面讨论了用户需要了解如何将所有分支的改动重置到新历史之上(或删除并重新克隆),但这只是需要考虑的多个问题之一。 更多详情,请参阅 git filter-repo 手册页面的 “讨论” 部分。

  • 注释标签可能会意外转换为轻量级标签,原因有两个:

    • 有人可能会重写历史,意识到自己搞砸了,从 refs/original/ 中的备份恢复,然后重做 git-filter-branch 命令。 (refs/original/ 中的备份并不是真正的备份;它首先会取消引用标签)

    • 在运行 git-filter-branch 时,在 <rev-list 选项> 中使用 --tags 或 --all 选项。 为了将注释标签保留为注释标签,必须使用 --tag-name-filter(而且必须不是在之前失败的重写中从 refs/original/ 恢复的)。

  • 任何指定编码的提交信息都会因为重写而损坏;git-filter-branch 会忽略编码,获取原始字节,并将其输入 commit-tree,而不会告诉它正确的编码。 (无论是否使用了 --msg-filter,都会发生这种情况)

  • 默认情况下,提交信息(即使它们都是 UTF-8)会因未更新而损坏 --any 提交信息中对其他提交哈希值的引用现在会指向不再存在的提交。

  • 没有任何工具可以帮助用户找到他们应该删除的不需要的垃圾,这意味着他们更有可能进行不完整或部分的清理,有时会造成混乱,让人浪费时间去理解。 (例如,人们倾向于只寻找要删除的大文件,而不是大目录或扩展名,一旦他们这样做了,那么使用新仓库的人在查看历史记录时就会发现构建工件目录中有一些文件但没有其他文件,或者依赖关系缓存(node_modules 或类似缓存)因为缺少了一些文件而无法正常运行)

  • 如果不指定—​prune-empty,过滤过程就会产生大量混乱的空提交

  • 如果指定了—​prune-empty,那么过滤操作前有意放置的空提交也会被剪枝,而不只是剪枝因过滤规则而变为空的提交。

  • 如果指定—​prune-empty,有时会漏掉一些空提交,但还是会保留下来(这是一个有点罕见的错误,但还是会发生…​…​)

  • 这只是个小问题,但如果用户的目标是更新版本库中的所有姓名和电子邮件,则可能会使用 --env-filter,它只会更新作者和提交者,而不会更新标记者。

  • 如果用户提供了一个 --tag-name-filter 过滤器,将多个标签映射到同一个名字上,git-filter-branch 不会给出任何警告或错误信息,而只会按照某种未被记录的预定义顺序覆盖每个标签,导致最后只有一个标签。 (git-filter-branch 的回归测试需要这种令人惊讶的行为)

此外,git-filter-branch 的性能不佳往往会导致安全问题:

  • 除非你只是做一些微不足道的修改,比如删除几个文件,否则有时很难找到正确的 shell 代码段来完成你想要的过滤。 不幸的是,人们往往通过尝试来判断代码段的正确与否,但正确与否会因特殊情况(文件名中的空格、非字符串文件名、有趣的作者姓名或电子邮件、无效的时区、存在嫁接或替换对象等)而有所不同,这意味着他们可能需要等待很长时间,遇到错误,然后重新启动。 git-filter-branch 的性能如此糟糕,以至于这种循环非常痛苦,减少了仔细重新检查的时间(更不用说对改写者耐心的影响了,即使他们在技术上有更多的时间)。 由于过滤器损坏导致的错误可能在很长时间内都不会显示出来,并且/或者在大量输出中丢失,因此这个问题变得更加复杂。 更糟糕的是,破损的过滤器往往只会导致无声的错误重写。

  • 更糟糕的是,即使用户最终找到了可用的命令,他们自然也想分享这些命令。 但他们可能没有意识到,自己的软件源并不具备某些特殊情况,而别人的软件源却具备。 因此,当其他人使用不同的版本库运行相同的命令时,他们就会遇到上述问题。 或者,用户运行的命令确实经过了特殊情况审核,但他们在不同的操作系统上运行时却无法正常工作,如上所述。

GIT

属于 git[1] 文档

scroll-to-top