在Git pre-commit 钩子中,暂时移除所有不打算提交的更改

In Git pre-commit hook, temporarily remove all changes that are not about to be commited

我希望我的 pre-commit 挂钩能够编译程序并 运行 在允许执行提交之前进行所有自动测试。 问题是通常我的工作副本在我提交时并不干净。它们不是我不想提交的暂存文件,甚至不是未跟踪的文件。有时我什至明确指定只提交几个与当前暂存的文件无关的文件。

当然我只想编译和测试将要提交的更改,忽略其他更改。 将有 3 个步骤:

  1. 删除所有不会提交的更改。
  2. 运行 测试。
  3. 将所有更改恢复到第一步之前的状态。

第一步可以通过运行宁git stash push --include-untracked --keep-index实现。隐藏条目也有助于第三步。 但是,当我提交未暂存的文件的显式列表时,我不知道该怎么做。

(第二步不是问题的一部分。)

理论上可以使用命令 git stash pop --index 完成第 3 步,但如果暂存某些文件然后更改更多而没有再次暂存它,则此命令似乎容易发生冲突。

此脚本创建一个存储库,其中包含一些文件和涵盖各种特殊情况的更改:

#!/usr/bin/env sh

set -e -x

git init test-repo
cd test-repo
git config user.email "you@example.com"
git config user.name "Your Name"

echo foo >old-file-unchanged
echo foo >old-file-changed-staged
echo foo >old-file-changed-unstaged
echo foo >old-file-changed-both
git add .
git commit -m 'previous commit'

echo bar >old-file-changed-staged
echo bar >old-file-changed-both
echo bar >new-file-staged
echo bar >new-file-both
git add .
echo baz >old-file-changed-unstaged
echo baz >old-file-changed-both
echo baz >new-file-both
echo baz >untracked-file

您实际上非常接近正确的解决方案。

(在这个回答中,我将使用“缓存”一词而不是“阶段”,因为后者与“存储”太相似了。)

事实上,即使您要提交未缓存的文件,使用存储 的技巧仍然有效。这是因为 Git 在 运行ning 挂钩期间更改缓存,因此它始终包含正确的文件。您可以通过将命令 git status 添加到 pre-commit 挂钩来检查它。

所以你可以使用git stash push --include-untracked --keep-index

恢复存储时的冲突问题也很容易解决。您已经在存储中备份了所有更改,因此没有丢失任何东西的风险。只需删除所有当前更改并将隐藏应用到干净的平板上。

这可以分两步完成。 命令 git reset --hard 将删除所有跟踪的文件。 命令 git clean -d --force 将删除所有未跟踪的文件。

在那之后你可以 运行 git stash pop --index 而没有任何冲突的风险。


一个简单的钩子看起来像这样:

#!/bin/sh

set -e

git stash push --include-untracked --keep-index --quiet --message='Backed up state for the pre-commit hook (if you can see it, something went wrong)'

#TODO Tests go here

git reset --hard --quiet
git clean -d --force --quiet
git stash pop --index --quiet

exit $tests_result

我们来分解一下。

set -e 确保脚本在出现错误时立即停止,因此不会造成任何进一步的损害。 带有所有更改备份的隐藏条目在开始时完成,因此如果出现错误,您可以手动控制并恢复所有内容。

git stash push --include-untracked --keep-index --quiet --message='...' 有两个目的。它创建所有当前更改的备份,并从工作目录中删除所有非暂存更改。 标志 --include-untracked 确保未跟踪的文件也被备份和删除。 标志 --keep-index 取消从工作目录中删除缓存的更改(但它们仍包含在存储中)。

#TODO Tests go here 是你测试的地方。 确保您没有在此处退出脚本。在执行此操作之前,您仍然需要恢复隐藏的更改。 不要以错误代码退出,而是将其值设置为变量 tests_result.

git reset --hard --quiet 从工作目录中删除所有跟踪的更改。 标志 --hard 确保缓存中没有任何内容,所有文件都被删除。

git clean -d --force --quiet 从工作目录中删除所有未跟踪的文件。 标志 -d 告诉 Git 递归删除目录。 标志 --force 告诉 Git 你知道你在做什么,它确实应该删除所有这些文件。

git stash pop --index --quiet 恢复保存在最新存储中的所有更改并将其删除。 标志 --index 告诉它确保它没有混淆哪些文件被缓存,哪些没有。


这种方法的缺点

这种方法只是半健壮的,对于简单的用例应该足够了。 然而,它们是相当多的极端情况,在实际使用中可能会破坏某些东西。

git stash push 拒绝处理仅使用标志 --intent-to-add 添加的文件。 我不确定为什么会这样,而且我还没有找到解决它的方法。 您可以通过添加不带标志的文件或至少将其添加为空文件并仅保留未缓存的内容来绕过该问题。

Git 只跟踪文件,不跟踪目录。但是,命令 git clean 可以删除目录。结果,脚本将删除空目录(除非它们被忽略)。

自上次提交后添加到 .gitignore 的文件将被删除。我认为这是一个功能,但如果你想阻止它,你可以通过颠倒 git resetgit clean 的顺序。 请注意,仅当 .gitignore 包含在当前提交中时才有效。

如果没有更改,

git stash push 不会创建新的存储,但它仍然是 returns 0。要处理没有更改的提交,例如使用 --amend 更改消息,您需要添加一些代码来检查 stash 是否真的被创建并且只有在它被创建时才弹出它。

Git stash 似乎删除了有关当前合并的信息,因此在合并提交上使用此代码会破坏它。 为防止这种情况,您需要备份文件 .git/MERGE_* 并在弹出存储后恢复它们。


稳健的解决方案

我已经设法解决了这个方法的大部分问题(使代码在这个过程中变得更长)。

唯一剩下的问题是删除空目录和忽略的文件(如上所述)。我不认为这些问题严重到需要花时间试图绕过它们。 (不过这是可行的。)

#!/bin/sh

backup_dir='./pre-commit-hook-backup'
if [ -e "$backup_dir" ]
then
    printf '"%s" already exists!\n' "$backup_dir" 1>&2
    exit 1
fi

intent_to_add_list_file="$backup_dir/intent-to-add"
remove_intent_to_add() {
    git diff --name-only --diff-filter=A | tr '\n' '[=11=]' >"$intent_to_add_list_file"
    xargs -0 -r -- git reset --quiet -- <"$intent_to_add_list_file"
}
readd_intent_to_add() {
    xargs -0 -r -- git add --intent-to-add --force -- <"$intent_to_add_list_file"
}

backup_merge_info() {
    echo 'If you can see this, tests in the `pre-commit` hook went wrong. You need to fix this manually.' >"$backup_dir/README"
    find ./.git -name 'MERGE_*' -exec cp {} "$backup_dir" \;
}
restore_merge_info() {
    find "$backup_dir" -name 'MERGE_*' -exec mv {} ./.git \;
}

create_stash() {
    git stash push --include-untracked --keep-index --quiet --message='Backed up state for the pre-commit hook (if you can see it, something went wrong)'
}
restore_stash() {
    git reset --hard --quiet
    git clean -d --force --quiet
    git stash pop --index --quiet
}

run_tests() (
    set +e
    printf 'TODO: Put your tests here.\n' 1>&2
    echo $?
)

set -e
mkdir "$backup_dir"
remove_intent_to_add
backup_merge_info
create_stash
tests_result=$(run_tests)
restore_stash
restore_merge_info
readd_intent_to_add
rm -r "$backup_dir"
exit "$tests_result"