Git预提交钩子:如何在使用-a标志提交时获取添加/修改的文件

3
当我使用 git commit -a 提交我的工作时,pre-commit hook 中的 "git diff --diff-filter=ACM --name-only --cached" 无法获取将被 git 添加的文件。那么这种情况的正确解决方案是什么呢?
1个回答

6
问题在于git commit -a本身。最好不要使用-a选项。分别添加文件,然后运行git commit。如果您想修复钩子,请继续阅读。
提交的真正工作方式
编写Git钩子的人(至少是其中一些钩子)需要知道Git从索引中构建提交这一事实。但是,声称Git从索引中构建提交是有点谎言,或者至少是不完整的。Git从一个索引构建提交。
如果您运行git commit而没有使用三个特定选项之一,则只有一个索引。该索引是唯一的索引,因此任何假设Git正在使用该索引的人都会得到他们期望的行为,并且完全幼稚的预提交挂钩表现良好:有时甚至不必意识到Git将提交索引中的内容,而不是用户的工作树中的内容。但是,有三个提交选项会更改此行为:
  • git commit -a:这个命令与git add -u && git commit很像,但是为了确保如果提交被拒绝(被pre-commit hook拒绝或者被用户中止)时git add -u不会生效,Git必须创建一个临时索引。

  • git commit --include paths:这个命令与git commit -a类似,但是添加的文件不是由git add -u找到的,而是指定的路径。

  • git commit --only paths:这是最糟糕的情况。需要注意的是,没有--include--only参数的git commit paths命令与使用--only参数的命令效果相同。对于这种特殊情况,Git必须创建不只一个,而是两个临时索引文件。

所有这些都源于索引的基本思想。Git的索引始终保存着您的1下一个提交建议,也就是说,索引中包含了应该被包括在下一个提交中的文件集合。当您运行不带选项的git commit命令时,您正在请求Git提交下一个建议的提交。所以,索引中有正确的东西。
但是当您运行git commit -agit commit --include(简称-i)或git commit --only(简称-o)命令时,您正在说:对当前建议的下一个提交进行一些更改,然后尝试提交。如果此操作成功,那么新的索引——添加了额外更改的那个——应该是更新后的索引。但是,如果此操作失败,Git会希望将索引恢复到原来的状态,没有做任何更改。
为了实现这一点,Git 保持原始索引文件不变,并创建一个或两个新的索引文件。2如果您使用的是 git commit -agit commit -i,我们需要一个额外的索引:Git 将主索引复制到临时索引,然后使用 git add 或内部等效项来更新临时索引。此临时索引命名为 index.lock,该文件用于在运行此 git commit 命令时防止运行其他 git commit 命令,因此即使是没有选项的普通 git commit 命令也会创建一个 index.lock 文件:只是对于普通提交,index.lock 文件内容将与 index 文件内容匹配。
所以,对于git commitgit commit -agit commit -i,可以只使用index.lock文件作为“the”索引,并从中获取正确的内容。当然,如果你想在预提交钩子中实现这个功能,你需要先确定Git是否首先使用标准索引:如脚注2所示,添加的工作树使用不同的标准索引,因此它具有不同的标准index.lock

1这并不完全准确,因为有时索引会被扩展以容纳不是“阶段零”的条目。这种情况发生在有冲突的合并期间。 (这里的“合并”还包括樱桃拣选和还原:任何调用Git的内部合并引擎的操作。)然而,在此扩展操作期间,索引仍然保存建议的下一个提交。只是索引中有一些部分不能被提交,需要解决这些问题才能进行提交。解决冲突的条目会删除非零阶段的条目,用单个已解决的零阶段条目替换它们,或者如果文件根本不应该被提交,则将它们删除。

索引内容可以通过 git ls-files --stage 命令查看:其中包含完整的路径名,例如 src/somefile.ext,一个模式——对于普通文件是 100644100755 中的一个;另外两个模式保留用于符号链接和 gitlink——以及哈希 ID。还有一个阶段编号,必须为零才能提交索引。任何阶段 1、2 或 3 的条目都表示存在合并冲突,冲突的文件可通过读取该插槽来获取:请参见 git checkout-index 命令

2索引文件通常是普通文件:.git/index是常见的文件。在通过git worktree add创建的次要工作树中,常规文件位于.git顶级目录下的子目录中。但是,您可以使用环境变量GIT_INDEX_FILE覆盖此索引,以使用自己的临时索引。各种Git shell脚本都使用这种技术。例如,当git stash是一个shell脚本时,它就是这样做的。当然,git commit也使用相同的概念来创建其他额外的临时索引文件。


git commit --only 是最难的情况

对于 git commit --only,两个索引文件是不够的。我们需要 三个 这样的文件。原因如下:函数 git commit --only 的作用是:

  1. 当前提交 (HEAD) 读入临时索引。
  2. 更新该临时索引以添加指定的文件。
  3. 尝试将此索引转换为新提交。

步骤 3 有一个成功案例和一个失败案例。失败案例比较简单,所以我们先看它:

  • 在失败时,Git 应该返回到当前提议的下一次提交作为提议的下一次提交。这意味着我们需要保留现有的索引。

  • 然而,在 成功 的情况下,Git 应该提出一个新的建议下一次提交。这个新的建议下一次提交应该由 当前建议的下一次提交 (即当前索引) 更新为指定文件的 git add 更新 组成。

为了准备步骤3的成功,因此,步骤1和2应该改为以下方式:
  1. 准备两个临时索引文件:通过将HEAD复制到索引文件中设置Index A,并通过复制现有索引文件设置Index B。
  2. 通过git add命名文件更新Index A,并通过git add命名文件更新Index B。
现在简化了步骤3:
  1. 使用Index A进行提交。如果成功,则用Index B替换标准索引。如果失败,则删除两个临时索引文件。

Git如何使用锁定文件

Git有一堆代码路径,想要对单个文件进行某种原子更改3这就是这个index.lock东西的来源。在POSIX系统上,没有特别好的方法可以锁定一个文件以进行特定的事务,但我们可以以各种方式近似它。

一种简单的方法是:使用原子性的文件创建(在open系统调用中使用O_CREAT|O_EXCL),以确保只有一个进程可以创建以.lock结尾的文件。例如,如果我们想锁定名为index的文件,我们会原子地创建一个名为index.lock的文件。如果创建成功,我们现在拥有了锁,并且可以将现有的index文件复制到新的index.lock文件中,对文件进行必要的更改并将其写出。
现在我们可以:
  • 使用rename系统调用原子性地更新索引文件并释放锁文件:rename("index.lock", "index")将完全用当前的index.lock文件替换旧的index文件并成功,在此过程中删除index.lock文件,否则会失败并保持indexindex.lock不变。(如果失败,我们将终止事务;请参见下文。)

  • 或者,我们可以通过简单地删除锁文件(unlink("index.lock"))来有意地释放文件上的锁,并终止我们的事务。现有的index文件保持不变。

请注意这种技术如何无缝地完成git commitgit commit -a/git commit -i。这两个操作之间的关键区别完全受控于我们放置在index.lock中的内容。对于普通的git commitindexindex.lock都包含相同的内容。而对于git commit -agit commit -iindex包含旧的内容,而index.lock则包含新的更新内容。
我们可以创建锁定文件,如果需要,更新它,尝试提交,然后通过重命名完成事务,或者通过取消链接锁定文件来回滚事务。这一切都非常简单和容易。4

困难的情况在于git commit -o: --only选项需要两个临时索引文件。我们保留index,用一组内容(因为它是我们想要在重命名操作中放置的内容)创建index.lock,并为提交过程创建第三个索引,即索引A。我们将HEAD读入索引A,同时更新索引文件A和B,尝试使用索引A进行提交,删除索引A,然后使用索引B完成交易,或者像以前一样回滚。这种方法不太直观,但明显它是有效的。


我在这里链接至维基百科关于数据库中原子性的页面,因为这就是Git试图实现的概念:原子事务。真正的数据库软件可能会对Git有所裨益;它所做的事情有点粗糙。然而,真正的数据库软件很难且速度较慢。Git在这里尝试了一种既想要蛋糕又想吃蛋糕的方式。它大部分都成功了:这里确实存在真正的权衡,Git在大多数情况下处理得非常好。但是,在某些情况下,它们正在出现问题,并且在这里的工作仍在进行中。
“容易”在这里意味着只有几十行C代码。如果Git是用高级语言编写的,那么它确实相对容易。

编写一个 pre-commit 钩子来处理所有这些情况

在这里,你会遇到麻烦。对于 git commit --only 这种情况,将被提交的内容位于 Index A 中。但是,你可以知道的两个文件的路径是 Original Index(如果设置了 $GIT_INDEX_FILE,或者是 .git/index 或相应的工作树索引),以及 Index B(与之前相同的文件加上 .lock 后缀)。

你可以确定是否存在至少两个不同的索引文件。如果是这种情况,我们正在执行 git commit -agit commit -igit commit -o。那么,你就知道无法可靠地处理它,可以让你的 pre-commit 钩子中止并告诉用户不要这样做。

由于没有文档记录,因此没有官方方法来处理此问题,但一些现有的 pre-commit 钩子使用了这种技术:

if [ $GIT_INDEX_FILE != ".git/index" ]; then
    echo "Error: non-default index file is being used (GIT_INDEX_FILE is set)." >&2
    ...
    exit 1
fi

这会有一个讨厌的副作用,即拒绝从添加的工作树提交。为了解决这个问题,如果您的Git足够新,可以使用git rev-parse --git-path来替换任何硬编码的.git/index字符串:
git rev-parse --git-path index

正如您所观察到的,有些Git版本在不必要时不会创建index.lock文件。这就是依赖未记录行为的问题:它可能在您目前安装的Git版本中运行,但在升级到新版本的Git后会出现问题。

非常感谢您的出色回答!!!这真的帮了我很多。因此,总之,我们无法处理-a/-i/-o情况,但可以检测并中止它,强制用户根据一些提示信息使用“git add”命令? - Sienna
我再次阅读了您的答案,发现有一个小问题。您说:“对于普通的git提交,索引和索引锁包含相同的内容。”但是为什么在这种情况下需要额外的副本似乎是不必要的。我在pre-commit脚本中使用“ls -a .git”进行测试,发现在普通的git提交中没有index.lock文件。 - Sienna
@Sienna:很有趣。肯定在某个时候有一个。无需创建一个(正如您所指出的),因此我认为内部代码已经有所更改。这是依赖未记录行为的一般问题:它可能会在将来发生变化。 - torek

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接