遍历正在推送的文件的git命令是什么?

4
我正在尝试用Python实现一个pre-push git hook,以在将文件推送到远程repo之前对其进行验证。

我之前编写了一个pre-commit git hook,在将文件提交到本地repo之前进行验证并获取提交中的文件列表,我运行了git diff-index --cached --name-status HEAD

对于pre-push脚本,我可以运行什么git命令来遍历即将推送的所有提交,然后遍历单个提交中的所有文件,以便我可以对它们进行验证?

到目前为止,我正在使用以下命令:git diff --name-status @{u}..

编辑:我认为还需要注意的是,相同的文件可能会跨多个提交进行修改-因此最好不要多次验证同一文件。

最终解决方案:

以下是我最终使用的代码,感谢@Vampire和@Torek的答案...

#!/usr/bin/env python

# read the args provided by git from stdin that are in the following format...
# <local ref> SP <local sha1> SP <remote ref> SP <remote sha1> LF
# the line above represents a branch being pushed
# Note: multiple branches may be pushed at once

lines = sys.stdin.read().splitlines()

for line in lines:
    local_ref, local_sha1, remote_ref, remote_sha1 = line.split()

    if remote_sha1 == "0000000000000000000000000000000000000000":
        print_error("Local branch '%s' cannot be found on the remote repo - push only the branch without any commits first!" % local_ref)
        sys.exit(1)

    # get changed files
    changed_files = subprocess.check_output(["git", "diff", "--name-status", local_sha1, remote_sha1], universal_newlines=True)

    # get the non deleted files while getting rid of M\t or A\t (etc) characters from the diff output
    non_deleted_files = [ f[2:] for f in changed_files.split("\n") if f and not f.startswith("D") ]

    # validation here...
    if validation_failed:
        sys.exit(1) # terminate the push

sys.exit(0)

在进行“重写历史”(例如,在git rebase之后)时,当您进行强制推送时,该解决方案在某些情况下(不一定全部)会失败。它还没有检查任何中间提交,也没有检查第一个引用以外的任何其他更新的引用,如果您推送多个引用(例如,使用git push --all或将push.default设置为matchinggit push)。但是,如果这就是您想要的,那么它应该足够了。(好吧,它需要一些Python3的工作,但这也没关系:您可以继续使用Python 2.7。) - torek
@torek 好无聊啊...所以如果我提交了3次,然后推送,它只会在最后一次推送时运行pre-push... - Ogen
这就是为什么我建议(1)读取所有的stdin,然后(2)在每个更新上使用git rev-list(这确实需要偶尔的git fetch来填充未知的哈希ID - 这可能可以在pre-push钩子内完成,但我从未尝试过),并查看git diff-tree结果。 - torek
@torek 我以为 sys.stdin.read() 确实会读取所有标准输入。 - Ogen
它确实会读取所有内容,但接下来你必须实际使用它。 (我的措辞不太好,因为sys.stdin.read()确实读取了所有内容。 :-) ) - torek
显示剩余3条评论
2个回答

4

问题1:提交

获取提交列表通常不是很困难,因为您只需要运行git rev-list。但是在这里有一些边缘情况。正如 githooks文档所说:

“push”操作的信息通过标准输入提供,格式如下所示: SP SP SP LF
例如,如果运行命令“git push origin master:foreign”,则hook将接收以下类似的行:
refs/heads/master 67890 refs/heads/foreign 12345
其中将提供完整的40个字符的SHA-1。如果外部引用尚不存在,则将为40个0。如果要删除引用,则会将提供为“(delete)”,并且将为40个0。如果本地提交是由其他无法扩展的名称指定(例如HEAD〜或SHA-1),则将按原样提供。因此,您必须读取每个stdin行并将其解析为其组件,然后决定:
  • 这是一个分支更新吗?(即,远程引用是否具有refs/heads/*的形式,作为通配符匹配?)如果不是,您是否仍要检查任何提交?
  • 正在创建或销毁引用吗?如果是,您应该怎么做?
  • 您是否拥有由外部哈希指定的对象?(如果没有,并且推送成功-它可能会失败-这将删除一些提交对象,但您无法确定哪些。此外,您无法正确列出将传输的本地提交:您知道您要求他们将其名称设置为什么,但您不知道您和他们共享的提交,因为您无法遍历其历史记录。)

假设您已经确定了这些答案——我们称之为“否”,“跳过”和“本地拒绝不可分析的推送”——我们继续列出提交,这只是输出:

git rev-list remotehash..localhash

你可能会用以下方式实现:
proc = subprocess.Popen(['git', 'rev-list',
    '{}..{}'.format(remotehash, localhash)], stdout=subprocess.PIPE)
text = proc.stdout.read()
if proc.wait():
    raise ... # some appropriate error, as Git failed here
if not isinstance(text, str):   # i.e., if python3
    text = text.decode('utf-8') # convert bytes to str
lines = text.split('\n')
# now work with each commit hash

请注意,如果远程或本地哈希值为全零,或者远程哈希值对应的对象在您的本地存储库中不存在(您可以使用“git rev-parse --verify --quiet”进行检查并检查返回状态,或者在此处使用失败作为无法检查提交的指示,尽管创建新分支时有其他选项),则git rev-list调用将失败(退出并显示非零状态)。
请注意,必须对要更新的每个引用运行上述git rev-list。同样的提交或某些相同的提交可能会发送到不同的引用。例如:
git push origin HEAD:br1 HEAD:br2 HEAD~3:br3

我希望您能远程更新三个分支br1br3,将br1br2设置为与HEAD相同的提交,并将br3设置为比HEAD早三步的提交。我们不知道哪些提交是真正的新提交-另一端的预接收挂钩可以弄清楚,但我们不能-但如果远程的br1br2都从HEAD~3更新到HEAD,并且远程的br3正在从HEAD~2向后更新到HEAD~3,则最多只能有HEAD~1HEAD的提交是新的。是否要检查HEAD~2,因为它现在很可能会出现在其他存储库中的br1br2上(即使它已经在那里的br3上),这也取决于您。

问题2:文件

现在你面临更困难的问题。你在编辑中提到:

编辑:我认为同一文件可能会在将要推送的多个提交中被修改 - 因此最好不要重复验证同一文件。

每个要发送的提交都有存储库的完整快照。也就是说,每个提交都有每个文件。我不知道你打算运行什么验证,但你是正确的:如果你要发送总共六个提交,那么所有六个提交中的大部分文件很可能是相同的,只有少数文件被修改。然而,在提交1234567(相对于父提交)中可能会修改文件foo.py,然后在提交fedcba9中再次修改,你可能应该检查两个版本

此外,当提交是一个合并提交时,它至少有两个不同的父级。如果一个文件与任一父级不同,那么你应该检查它吗?还是只有在它与两个父级都不同时才检查它,这表明它包含了来自合并的“两侧”的更改?如果它只有来自“一侧”的更改,那么该文件可能已经被“预检查”过了,因为发生在另一侧提交的检查,因此它可能不需要重新检查(尽管这当然取决于检查的类型)。
(对于八爪鱼合并,即具有多个父级的合并,这个问题变得更加难以思考。)

相对而言,很容易看出哪些文件在提交中与其父级或祖先有所改变:只需使用适当的选项(特别是-r以递归到提交的子树)运行git diff-tree即可。默认输出格式非常适合机器解析,但您可能希望添加-z以便直接在Python中处理。如果您一次只处理一个提交(您可能也想这样做),则可能还需要--no-commit-id,以便无需读取并跳过提交头。

是否启用重命名检测以及阈值设置取决于您。再次取决于您要验证文件的具体操作,最好关闭重命名检测:这样,您将“看到”重命名的文件作为旧路径的删除和新路径的添加。

特定提交的git diff-tree -r --no-commit-id输出如下:

:000000 100644 0000000000000000000000000000000000000000 b0b4c36f9780eaa600232fec1adee9e6ba23efe5 A  Documentation/RelNotes/2.13.0.txt
:100755 100755 6a208e92bf30c849028268b5fca54b902f671bbd 817d1cf7ef2a2a99ab11e5a88a27dfea673fec79 M  GIT-VERSION-GEN
:120000 120000 d09c3d51093ac9e4da65e8a127b17ac9023520b5 125bf78f3b9ed2f1444e1873ed02cce9f0f4c5b8 M  RelNotes
哈希 ID 是旧和新的 blob 哈希值;字母代码和路径名如所述。 您可以使用git cat-file -p命令检索新哈希 ID 的文件内容。 如果您的 Git 足够新,您甚至可以添加--textconv --filters--path=<path>(或者使用文件的路径和提交 ID,而不是--path=...来命名要提取的对象的哈希),以应用任何基于.gitattributes的过滤和行尾转换。 或者如果过滤器不重要,您可以直接使用存储在仓库中的对象形式。
根据您所检查的内容,您可能需要将整个提交提取到临时工作树中进行检查。 (例如,静态分析器可能需要执行任何import)。 在这种情况下,您可以直接运行git checkout,使用GIT_INDEX_FILE环境变量(像往常一样通过subprocess传递)指定一个临时索引文件,以便不会干扰主索引。 使用--work-tree=或通过GIT_WORK_TREE环境变量指定替代工作树。 无论如何,git diff-tree都会告诉您哪些文件已被修改,因此应进行检查。 (您可以使用shutil.rmtree在测试完成后处理临时工作树。)
如果您要检查合并提交,请特别注意合并所做的组合差异的描述,因为它们需要稍微不同的处理方式(或使用-m拆分合并)。

编辑:一些代码以展示我的意思

这里有一段代码,可以获取所有输入并显示每个提交被添加到每个外部分支。请注意,如果只删除提交,则添加提交的列表将为空。这也只是经过轻微测试,不打算做到健壮、可维护、良好的风格等,只是一个最小的例子。

import re, subprocess, sys

lines = sys.stdin.read().splitlines()
for line in lines:
    localref, localhash, foreignref, foreignhash = line.split()
    if not foreignref.startswith('refs/heads/'):
        print('skip {}'.format(foreignref))
        continue
    if re.match('0+$', localhash):
        print('deleting {}, do nothing'.format(foreignref))
        continue
    if re.match('0+$', foreignhash):
        print('creating {}, too hard for now'.format(foreignref))
        continue
    proc = subprocess.Popen(['git', 'rev-parse', '--quiet', '--verify',
            foreignhash],
        stdout=subprocess.PIPE)
    _ = proc.stdout.read()
    status = proc.wait()
    if status:
        print('we do not have {} for {}, try '
            'git fetch'.format(foreignhash, foreignref))
        # can try to run git fetch here ourselves, but for now:
        continue
    print('sending these commits for {}:'.format(foreignref))
    subprocess.call(['git', 'rev-list', '{}..{}'.format(localhash, foreignhash)])

我更新了问题,包括我目前正在使用的代码。 - Ogen
我更新了我的钩子,以处理在推送中包含多个分支,通过对每个分支运行我的代码验证来完成。我正在检查远程哈希是否仅为零 - 在这种情况下,我只是终止推送并告诉用户推送该分支。然而,这是不可接受的,因为现在我无法推送新的分支...我的钩子会失败。只有在远程哈希不仅为零的情况下,我才能将提交推送到现有分支。 - Ogen
可能值得尝试在我建议的地方进行git fetch(然后查看是否获取了缺失的对象),对于我们创建分支的情况,将本地分支末端的所有文件视为“添加”,通过与空树的哈希进行比较(单击此链接以获取详细信息)。由于您不会检查任何中间提交,因此这可能是一个合理的方法。 - torek

1

使用@{u}..有一定的帮助作用,因为它将对比HEAD的上游与HEAD,如果已经定义了上游。但这并不一定与推送的内容有任何关系,因为您可以推送任何分支或实际上任何提交(commit-ish),而不管当前检出的是什么,并将其推送到任何远程分支,而不受上游设置的限制。

根据githooks的文档,您将获得远程名称和位置作为脚本的参数,并在stdin中获得每个推送的“thing”(指提交、分支等)的一行,其中包括本地和远程引用以及本地和远程sha。因此,您需要遍历stdin并将推送的远程sha与推送的本地sha进行比较,以获取不同的文件。


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