【问题标题】:Why do pre-commit hooks leave files "half staged"?为什么预提交挂钩会使文件“半分阶段”?
【发布时间】:2019-10-24 18:12:10
【问题描述】:

我正在尝试设置一个用于格式化代码的预提交挂钩,它将格式化文件并在提交中包含更改。有几个脚本说他们这样做,但我尝试过的那些有同样的问题:他们让文件“半分阶段”。

参见例如this script。它在修改文件后正确添加文件,并说它应该在 Windows 上工作。当钩子为其他人工作时,钩子对我不起作用,这一事实让我相信我的环境出了问题。

当钩子修改带有多余换行符的文件时会发生这种情况:

$ git status -s
A  src/hello.c
$ git commit src/hello.c

Add 'Hello World!'
# Please enter the commit message for your changes. Lines starting
# with '#' will be ignored, and an empty message aborts the commit.
#
# On branch master
# Changes to be committed:
#   new file:   src/hello.c
#
# Changes not staged for commit:
#   modified:   src/hello.c
#
$ git status
On branch master
Changes to be committed:
  (use "git reset HEAD <file>..." to unstage)

        modified:   src/hello.c

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git checkout -- <file>..." to discard changes in working directory)

        modified:   src/hello.c
$ git diff
warning: LF will be replaced by CRLF in src/hello.c.
The file will have its original line endings in your working directory
diff --git a/src/hello.c b/src/hello.c
index 5e4b595..768d31a 100644
--- a/src/hello.c
+++ b/src/hello.c
@@ -1,6 +1,5 @@
 #include <stdio.h>

-
 int main() {
   printf("Hello, World!");
   return 0;

$ git diff --staged
diff --git a/src/hello.c b/src/hello.c
index 768d31a..5e4b595 100644
--- a/src/hello.c
+++ b/src/hello.c
@@ -1,5 +1,6 @@
 #include <stdio.h>

+
 int main() {
   printf("Hello, World!");
   return 0;

我本来希望钩子留下一个干净的索引。相反,它使文件暂存而不修改,但也使文件本身被修改。为什么会发生这种行为,我怎样才能让它停止?

【问题讨论】:

    标签: windows git pre-commit-hook


    【解决方案1】:

    警告:这个答案有点长,但那是因为它实际上是关于这种预提交钩子的所有陷阱。有好几种,在复杂的情况下会变得复杂。


    您没有直接显示钩子,但您确实有一个指向包含该钩子的the link to the GitHub repository 的链接;这是more direct link to the hook itself)。我将引用钩子中的几行。

    钩子做了一些相当粗鲁的假设,因为当你运行git commit时,每个文件至少有三个我喜欢称之为“活动副本”,而这个钩子不是复杂到足以注意到它们之间的差异。

    三份文件,有时内容不同

    三个副本是:

    • 当前或HEAD 提交中的已提交副本。这个文件实际上是无法更改的——它一直被冻结——但它很重要,因为它是我们用于比较的基础。

    • 索引副本。此文件可以更改。这就是您要提交的内容:如果您的 pre-commit 和 commit-message 挂钩允许提交并且一切正常,则索引中的文件副本就是将要提交的副本。因此,您可以将索引(Git 也称为 暂存区)视为提议的下一次提交

      前两个文件(冻结的HEAD 副本和索引副本)采用特殊的、仅限 Git 的压缩格式。虽然可以更改索引副本,但始终通过替换它来完成,通常使用git add 覆盖它。 git add 命令将文件压缩为仅 Git 格式,并将压缩副本(从技术上讲,是对压缩副本的引用)放入索引中。

    • 工作树副本。此文件是您可以查看和操作的普通文件。

    现在,您正在使用 Git 的 LF/CRLF 翻译,如下所示:

    warning: LF will be replaced by CRLF in src/hello.c
    

    当 Git 将文件从工作树复制到索引时(即在git add 期间)或将文件从索引复制到工作树时(例如在git checkout 期间)时,会发生实际的转换。 extract-to-work-tree 步骤将仅 LF 行结尾更改为 CRLF 行结尾; add-to-index 步骤将 CRLF 行尾更改为 LF-only 行尾。 (你可以控制它并稍微改变它,但这是通常的方案。)

    git statusgit add,以及现有的钩子

    我们现在进入脚本,看几行:

    for line in $(git status -s)
    

    (从技术上讲这应该是git status --porcelain,但目前他们几乎都在做同样的事情:主要危险是--short 输出可能被着色,这会破坏下一个位)

      if [[ $line == A* || $line == M* ]]
    

    现在是时候考虑git status 打印的内容了。 The documentation 说,关于短格式:

    ...每条路径的状态显示为这些形式之一

       XY PATH
       XY ORIG_PATH -> PATH
    

    ORIG_PATH 是重命名/复制的内容的来源。 ORIG_PATH 仅在条目被重命名或复制时显示。 XY 是 两个字母的状态码。 [片段] X 显示索引的状态,Y显示工作树的状态。

    (除此之外:已复制目前不是git status 的可能状态。git status 调用的内部差异引擎可以设置它,但要这样做,调用者必须启用它,而git status 则没有。如果git status 获得了启用复制检测的新命令行标志或配置条目,您可以获得C status-es,但目前还不能。)

    这里的关键是第一个字母,也就是脚本在这里测试的,是基于索引的状态。也就是说,它是比较HEAD 提交与索引的结果的摘要——与建议的提交。如果一个文件在索引中是新的(不会出现在HEAD 提交中),则该文件将是Added,或者如果它同时在索引HEAD 提交中,则为Modified ,但索引副本不同于 HEAD 提交。

    这里要实现的是,无论 index 副本是否匹配 head 副本,work-tree 副本都是第三个完全归档。它可能与其他两个副本中的一个或两个完全不同! 没关系,事实上,如果您使用git add -p 选择性地仅暂存工作树文件的部分,这是故意的。在我们继续耕耘时请牢记这一点。

    现在让我们回到预提交挂钩脚本:

        if [[ $line == *.c || $line == *.cc || $line == *.h || $line == *.cpp ]]
        then
          # format the file
          clang-format -i -style=file $(pwd)/${line:3}
    
           # and then add the file (so that any formatting changes get committed)
          git add $(pwd)/${line:3}
        fi
    

    如果行尾的文件名——AM 状态文件只是一个文件名;只有R 状态文件有两个名称;但是该脚本在不检查R 状态文件时出错,因为文件可以重命名修改—以.c.cc 等结尾,运行clang-format

    clang-format输入work-tree 文件。输入几乎可以肯定应该是文件的索引副本,但它不是。因此脚本假定索引和工作树副本匹配。

    运行clang-format 后,脚本然后运行git add 将(更新的)工作树文件复制回索引中。如果我们想正确地做到这一点,我们需要格式化索引副本,然后添加格式化的索引副本,这非常棘手。这可能是为什么脚本有点懒惰,但这绝对值得一提。

    clang-format 编写的工作树文件可能只有 LF 行结尾(请参阅https://reviews.llvm.org/D19031)。这符合警告的文字:

    警告:src/hello.c 中的 LF 将被替换为 CRLF。

    这告诉您当前的工作树副本src/hello.c 仅具有 LF 行尾。 Git 被告知,当 Git 从索引复制回工作树时,Git 应该将 LF-only 结尾更改为 CRLF 结尾。

    三份以上

    现在事情变得复杂了。我上面提到过每个文件至少三个副本,然后描述了这三个副本所在的位置。有HEAD 提交、索引和工作树。这个描述的一个缺陷是短语索引,因为Git有时会使用一个临时索引。 一些 git commit 命令就是这种情况,但不是所有的。

    git commit 的完整故事是它总是从 an 索引构建您的新提交,但不一定从 索引构建。有一个“the”索引——工作树附带的一个特殊的、可区分的索引。1 然后还有一些额外的索引文件,一些 Git 命令为各种目的而创建——例如,git stash创建一个临时索引来保存工作树,git filter-branch 在运行时会创建许多临时索引文件。不过,我们对git commit 感兴趣,git commit 有时会创建一两个自己的临时索引文件。

    如果你运行git commit——根本没有额外的参数——git commit 只使用 索引文件。那是你提议的提交,它已经包含了所有文件。如果您的预提交挂钩运行 git add,它会将新文件复制到 索引中,替换索引中的旧文件,并最终 git commit 使用新文件写出新提交.如果新文件来自工作树,则大部分情况都匹配,除了 CRLF 行结尾。

    但是,如果您运行 git commit --onlygit commit --include,甚至只是运行 git commit -a,Git 就会发生变化。例如,如果您运行git commit file1.cc,则意味着 git commit --only file1.cc,除非您添加--include,在这种情况下,它意味着git commit --include file1.cc

    要执行这些操作(实际上包括纯 git commit),Git 至少会创建一个临时索引文件,尽管对于纯 git commit,这会尽可能晚地发生。一个临时索引文件被命名为index.lock(嗯,.git/index.lock,取决于您的.git 目录在哪里)。这个临时索引将是新提交的文件的真正来源。提交完成后,如果全部成功,Git 通过将 .git/index.lock 重命名为 .git/index 来释放锁。

    我们可以通过一个虚拟的.git/hooks/pre-commit 看到这些操作,它只打印环境变量的名称$GIT_INDEX_FILE,然后退出并阻止提交失败:

    $ cat .git/hooks/pre-commit
    $ git commit
    $GIT_INDEX_FILE is .git/index
    $ git commit -a
    $GIT_INDEX_FILE is [path]/git/.git/index.lock
    $ git commit --only cache.h
    $GIT_INDEX_FILE is [path]/.git/next-index-53061.lock
    $ git commit --include cache.h
    $GIT_INDEX_FILE is [path]/.git/index.lock
    

    所以:

    • 普通的git commit 使用常规索引文件。如果您的钩子运行git add,您将替换索引中的文件。当 Git 开始创建锁定文件 index.lock 时,它 index 创建它,当 git commit 完成时(假设成功),你对索引的更改,由你的钩子,将生效。

    • git commit -agit commit --include 的工作方式类似。锁定较早,但git add 应该更新到位的index.lock,当git commit 完成时,您的主索引应该有更新。 (我没有对此进行测试,但似乎很明显。)

    • 但是git commit --only 会创建一个临时索引 (next-index-53061.lock) 以及锁定主索引和 git add--only 文件锁定到主锁定索引。提交完成后,新提交的文件将是临时索引中的文件,包括您更新的任何内容;但是 main 索引将来自index.lock,这是更新了特定文件的旧索引。 他们得到更新将控制该索引中的实际内容。


    1当您使用git worktree add 创建额外的工作树时,额外的工作树会获得自己的单数索引,因此 索引是与 the 工作树配对:添加的工作树是具有单独索引的单独工作树。添加的工作树也有自己的HEAD,这使得事情在 Windows 上特别复杂,但我们不需要去那里。


    结论

    这些是提交钩子中需要注意的陷阱。所有这一切的含义是,除非你想深入了解 Git 本身的内部结构——例如检查 $GIT_INDEX_FILE 的名称,和/或将内容添加到多个索引文件中——否则通常是一个坏主意钩住修改正在进行的提交。相反,检查正在进行的提交通常更明智。如果提交是好的,让它继续。如果没有,请提醒用户运行所需的任何内容,并让提交失败。

    可以修改正在进行的提交;你只需要注意这些奇怪的情况。

    【讨论】:

      【解决方案2】:

      一种解决方法是像这样使用post-commit 挂钩:

      #!/bin/sh
      for line in $(git diff-tree --no-commit-id --name-only -r HEAD)
      do
          git add $(pwd)/${line}
      done
      

      一个问题是,如果提交不包含任何更改,因为 pre-commit 钩子会删除它们并提交,post-commit 钩子不会找到受影响的文件,并且索引会保持半分阶段的外观。不过,这可能是 pre-commit 钩子应该确保不会发生的事情。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2017-11-02
        • 2011-10-27
        • 2019-06-02
        • 1970-01-01
        • 1970-01-01
        • 2012-08-23
        相关资源
        最近更新 更多