避免提交非文本文件到 git 仓库

目录


起因

为了避免提交不必要的文件到 git 仓库,我们通常需要在 .gitignore 文件里写上大量的规则来指示 git 忽略不必要的文件。然而,.gitignore 是靠匹配文件名、文件后缀名、文件路径来忽略文件的,这就导致难免会有一些漏网之鱼。

我们很难一开始就能写出一个完美的 .gitignore 文件,更多的时候是随着项目的推进,根据实际情况修补 .gitignore。这里顺便推荐一个帮助生成 .gitignore 文件的工具 gitignore.io

但是,如果很不幸,某次 commit 真的不小心把一个二进制文件提交进了仓库,等到发现时,可能已经 push 到了远端仓库且又发生了很多 commit 了,这时候,删除工作目录的二进制文件再提交是无济于事的了,大块头的二进制文件已经成为了仓库的历史内容,顿时将仓库的尺寸撑大好几兆甚至几十兆,你正常提交几百次可能都产生不了这样的效果。且这份阴影将一直伴随着这个仓库的存在而存在,你每一次 clone 都要忍受仓库过大带来的带宽负载和无谓耗时。这时候想要弥补,就只能用 reset hard 和 push force 进行一顿骚操作,但这样做的风险成本很高,在多人合作的项目中几乎是不可能完成的。

我自认为发生这样的错误是低级的,是不能被原谅的,但是打脸的是,我自己还是会偶尔犯这样的错误。更恼人的是,被我不小心提交的往往是 golang 编译的二进制文件,编译机制决定了这样的文件往往只有一个且动不动就有几十兆大小,这对 git 仓库的拖累往往是致命的。

于是我决定想个一劳永逸的办法。

git 钩子与 file 命令

和其它版本控制系统一样,git 能在特定的重要动作发生时触发自定义脚本,称为 git 钩子

其中有个钩子叫pre-commit,在 commit 之前触发运行,如果该钩子以非零值退出,git 将放弃这次 commit。

很自然的,我开始思考可不可以写个 pre-commit 钩子来检查每次 commit 的内容,如果发现其中有二进制文件则阻止 commit。

那么下一个问题就是如何判断一个文件是不是二进制文件。

当然这里的判断肯定不是基于文件后缀名,因为它既然已经被 .gitignore 遗漏,说明它的后缀名很可能是有问题的甚至是没有后缀名,这里要给 commit 做最后一道防线,一定是基于文件实际内容做审查的,否则就形同虚设了。

一开始的想法是基于文件尺寸进行判断,超过一定大小的则认为是二进制文件,但很显然这样做很蹩脚。事实上,网上已经有一些资料教你设置钩子限制提交文件尺寸的方法,如 How to limit file size on commit,但是要注意,现在的需求是避免二进制文件被提交,而不是避免大文件被提交,即使这两个需求虽然很接近但仍不是同一个需求,二进制文件也可能不那么大,文本文件也可能尺寸爆表。。

帮助我解决这个问题的是 file 命令。是的,file 也是个命令,还是一个历史悠久的常见命令,在绝大多数 linux 发行版里你都可以直接找到它而无需安装,在 macOS 里或者 windows git bash 里也是如此。它的功能是检测一个文件的文件类型,形如:

$ file test.txt
test.txt: ASCII text
$ file test.gz
test.gz: gzip compressed data, was "test", last modified: Fri Feb  1 07:44:48 2019, from Unix, original size 3

file 命令是通过读取文件内容,在结合预置的判定规则来判断文件类型的,所以即使我起一些容易误导人的文件名,也不会影响判断结果,比如:

$ cp test.txt test-txt.gz
$ cp test.gz test-gz.txt

$ file test-txt.gz
test-txt.gz: ASCII text
$ file test-gz.txt
test-gz.txt: gzip compressed data, was "test", last modified: Fri Feb  1 07:44:48 2019, from Unix, original size 3

不过你可以看到,默认的输出结果啰里吧嗦没有任何规则可言,所以我们可以让 file 以 MIME(多用途互联网邮件扩展类型)标准输出,这样可读性要好很多:

$ file --mime-type test.txt
test.txt: text/plain
$ file --mime-type test.gz
test.gz: application/x-gzip

这里有份官方的 MIME 类型列表,我花了点时间过了一遍,基本可以确认使用 text/* 来判断文件是否是文本文件是没有问题的。但在实际实验中发现还需要额外考虑空文件的问题,空文件本质上说是没有”文件类型“这么一说的,既可以认为是文本文件也可以认为是二进制文件。但 git 有个特性,git add 时会忽略空文件夹,但不会忽略空文件,所以我们在开发过程中往往会在空文件夹里放一个空文件来强制让这个文件夹被提交,由此可见,这里应当视空文件为文本文件才是。

使用 file 检测空文件的 MIME 类型,结果是 inode/x-empty,所以最后的判断规则是:文件的 MIME 类型为 text/*inode/x-empty 则认为是文本文件。

综合上述思路,我写了一个小工具:git-text

新工具 git-text

git-text 最核心的东西其实就是一个用于做 pre-commit 钩子的脚步文件,目前它的长度没超过 20 行,即使我把它全文粘贴到这里也不会有撑文章字数之嫌:

#!/bin/bash

# Introduce: https://github.com/wolfogre/git-text
# Version:   v1.0.0

set -e

FILES=$(git status --short | grep -E "^(A|M)" | awk '{print $2}' | xargs)
if [[ -z "$FILES" ]]; then
	exit 0
fi 

WRONG_FILES=$(file --mime-type ${FILES} | grep -v -E "(text/[A-Za-z0-9.-]*|inode/x-empty)$" | cat)

if [[ -n "${WRONG_FILES}" ]]; then
	echo "DELETE NON-TEXT FILES OR USE 'git commit -n':"
	echo -e "${WRONG_FILES}"
	exit 1
fi

echo "ALL FILES ARE TEXT:"
file --mime-type ${FILES}

脚本内容的含义我就不再解释了,上文我已经将它的原理阐尽,无非是一些脑洞和小技巧。

但让人蛋疼的是,一个 git 仓库的钩子并不是这个仓库的一部分,换句话说,我只能给一个本地仓库设置钩子,钩子本身是不会被推送到远端仓库的,自然也不会被其他人拉取到,这就意味着,每次我 clone 或 init 一个仓库,就需要在特定位置创建一个钩子文件,写入上述的脚步内容,还得设置下文件的可执行权限,这未免也太麻烦了。

目前我能想到的解决这个问题的办法是使用 git 别名,设置一个全局的新命令来简化给仓库安装钩子的步骤:

git config --global alias.text '!f() { set -ex ; hookfile=$(git rev-parse --show-toplevel)/.git/hooks/pre-commit ; curl -sSL https://raw.githubusercontent.com/wolfogre/git-text/master/pre-commit -o $hookfile ; chmod +x $hookfile ; }; f'

这里设置了一个新命令叫 git text,当执行它时会下载 GitHub 上的钩子文件到当前仓库的指定位置,再设置文件具有可执行权限,完成给一个仓库安装钩子的工作。

试试!

首先创建一个本地仓库方便测试:

$ mkdir test-repo
$ cd test-repo/
$ git init
Initialized empty Git repository in /root/test-repo/.git/

执行 git text 为这个仓库安装钩子:

$ git text
++ git rev-parse --show-toplevel
+ hookfile=/root/test-repo/.git/hooks/pre-commit
+ curl -sSL https://raw.githubusercontent.com/wolfogre/git-text/master/pre-commit -o /root/test-repo/.git/hooks/pre-commit
+ chmod +x /root/test-repo/.git/hooks/pre-commit

测试提交一些文本文件,可以看到提交是成功了的:

$ touch test-empty-file
$ echo ok > test-text-file
$ git add --all
$ git commit -m "test commit"
ALL FILES ARE TEXT:
test-empty-file: inode/x-empty
test-text-file:  text/plain
[master (root-commit) f17008d] test commit
 2 files changed, 1 insertion(+)
 create mode 100644 test-empty-file
 create mode 100644 test-text-file

测试提交非文本文件,可以看到提交提交被终止,并提示类型异常的文件:

$ gzip test-text-file
$ git add --all
$ git commit -m "test commit"
DELETE NON-TEXT FILES OR USE 'git commit -n':
test-text-file.gz: application/x-gzip

如果我非要提交这个文件,比如需要提交一些图片等资源文件,可以在提交时加上 -n 参数来绕过钩子:

$ git commit -n -m "force commit non-text"
[master 7c01515] force commit non-text
 2 files changed, 64 deletions(-)
 delete mode 100644 test-text-file
 create mode 100644 test-text-file.gz

最后

关于这个工具更多的内容,可查看项目的 GitHub 页

目前我对这个工具的版本定位于“实验阶段”,因为它虽然满足了我日常的需求,但还没有经过更长时间的考验和更广泛的测试,所以如何你在使用过程中发现有什么问题,莫要恼火,提个 Issue 且让我瞧瞧便是。

后记 1

在使用过程中,发现仓库如果使用了 git-submodule,是会跟踪一个空目录的,这导致 git-text 误以为提交了非文本文件,而拒绝 commit。

为了兼容这种情况,我已经修改了钩子代码,放行 MIME 为 inode/directory 的文件(夹)。

评论加载中……

若长时间无法加载,请刷新页面重试,或直接访问