The `pre-commit` hook can stage new changes during its execution, but `pre-merge-commit` cannot. Why is that?

I have a repository with a version (semantic versioning) hardcoded in one of its files. Each time a developer adds a change (let’s call it a type 1 change), I need to review those changes and increment the minor version component. If a developer adds a type 2 change, I increment the patch version component.

I have set up a convention for developers: if they are adding a type 1 change, they must create a branch and name it A/ID. If they are adding a type 2 change, they must create a branch and name it bugfix/A/ID.

Additionally, I wrote some Git hook (commit-msg, since it will be called in both git commit and git merge) script that, based on the branch being merged into the default branch, automatically increment the related version components. Below is the Git hook script I wrote:

#!/usr/bin/env python3
import subprocess
import sys
import pathlib
import re


def main():
    default, base = "main", "package/__init__.py"

    head = pathlib.Path(".git/MERGE_HEAD")
    if not head.exists():
        exit(0)

    with head.open() as f:
        commit = f.read().strip()

    command = ["git", "name-rev", "--name-only", commit]
    process = subprocess.run(command, capture_output=True, encoding="UTF-8")
    if process.returncode != 0:
        print(
            f"Failing to perform `commit-msg` commit operations. The `{' '.join(command)}` command and its logs may be helpful.",
            " ".join(process.args),
            process.stderr,
            sep="n",
            file=sys.stderr,
        )
        exit(process.returncode)

    merge = process.stdout.strip()

    command = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
    process = subprocess.run(command, capture_output=True, encoding="UTF-8")
    if process.returncode != 0:
        print(
            f"Failing to perform `commit-msg` commit operations. The `{' '.join(command)}` command and its logs may be helpful.",
            " ".join(process.args),
            process.stderr,
            sep="n",
            file=sys.stderr,
        )
        exit(process.returncode)

    current = process.stdout.strip()

    if current != default or (
        not merge.startswith("A/") and not merge.startswith("bugfix/A/")
    ):
        exit(0)

    semver = re.compile(
        r"(?P<major>0|[1-9]d*).(?P<minor>0|[1-9]d*).(?P<patch>0|[1-9]d*)(?:-(?P<prerelease>(?:0|[1-9]d*|d*[a-zA-Z-][0-9a-zA-Z-]*)(?:.(?:0|[1-9]d*|d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:+(?P<buildmetadata>[0-9a-zA-Z-]+(?:.[0-9a-zA-Z-]+)*))?"
    )
    version = {"major": 0, "minor": 0, "patch": 0}

    with open(base, mode="r", encoding="UTF-8") as f:
        line, match = f.readline(), None
        while line:
            if line.startswith("__version__"):
                match = re.match(r'__version__.*"(?P<version>.*?)"', line)
                break

            line = f.readline()

    if match == None:
        print(
            "Failing to perform `commit-msg` commit operations. The Semantic Versioning regular expression cannot find the detection version in the Python module!",
            file=sys.stderr,
        )
        exit(1)

    match = semver.match(match.group("version"))
    if match == None:
        print(
            "Failing to perform `commit-msg` commit operations. The detection version format in this repository does not match the Semantic Versioning format.",
            file=sys.stderr,
        )
        exit(1)

    version["major"], version["minor"], version["patch"] = (
        int(match.group("major")),
        int(match.group("minor")),
        int(match.group("patch")),
    )

    if merge.startswith("A/"):
        version["minor"] += 1

    elif merge.startswith("bugfix/A/"):
        version["patch"] += 1

    with open(base, mode="r", encoding="UTF-8") as f:
        module = f.read()

    with open(base, mode="w", encoding="UTF-8") as f:
        module = re.sub(
            r'__version__ = "(?P<version>.*?)"',
            f'__version__ = "{version["major"]}.{version["minor"]}.{version["patch"]}-rc"',
            module,
        )
        f.write(module)

    command = ["git", "add", base]
    process = subprocess.run(command, capture_output=True, encoding="UTF-8")
    if process.returncode != 0:
        print(
            f'Failing to perform `commit-msg` commit operations. The `{" ".join(command)}` command and its logs may be helpful.',
            " ".join(process.args),
            process.stderr,
            sep="n",
            file=sys.stderr,
        )
        exit(process.returncode)

    print("Upgraded.")


if __name__ == "__main__":
    main()

The script works as expected, with one exception: at the end of the script, you can see I’m using git add <FILE> to include the changes made earlier to the version hardcoded in the Python module.

This command works as expected and adds the changes to the Git index. However, after the hook finishes and the merge commit message appears, when I accept the message, the changes are not included in the merge commit and are left in the index alone!

What am I doing wrong? I’m online and active, answering questions as they appear.

The solution to the former problem that I described comes first, but suggestions are welcome on every aspect of the problem, including changes to the overall approach, Python code, Git hook name, and philosophy.

I can rephrase the question more clearly in following sentence: The pre-commit hook can stage new changes during its execution, but pre-merge-commit cannot. Why is that?

The capabilities of the commit-msg hook is documented as being able to “edit the
message file in place”, to “normalize the message”, and to “refuse the commit
after inspecting the message file”. [1]

That’s all. And I see no reason to read through the negative space here; the
absence of a negation of a capability does not let us infer that it supports
that capability.

There has been plenty of confusion on the related pre-commit hook, which
after all has a more general/generic name; why will it sometimes act in a weird
way for my rewrite-commit/add to the staging area-kinda script? Well the
documentation says that pre-commit hook can abort a commit by exiting with a
non-zero value.

It doesn’t say anything about “fixing” or “amending” commits—it is made for
validation, not amendments. [2]

So it is perhaps unfortunate that tool and script writers get no feedback on how
they are making unwarranted assumptions. Instead they might get the worst of both
worlds: it works enough of the time to look like a feature (pre-commit) but when
it does fail it seems buggy and wrong. But what can you do here? Your hook
scripts can do whatever they want. I don’t think Git can reasonably check that
all the side-effects [3] that your script does and give appropriate and
relevant feedback. But for the narrow problem of changes to the index/staging
area in particular: it was deemed too expensive. [2]

Notes

  1. git version 2.48.0.rc0
  2. FYI, we tried not to do the extra re-reading, because pre-commit
    hook was designed to be a mechanism to allow users to validate, but
    not correct, what gets committed. As the system originally was
    designed, users who correctly use Git would not be modifying the
    index. Because it is an error to modify the index in the hook, (1)
    re-reading the index just in case the user commits such a mistake is
    waste of resources, and (2) checking the index to make sure it did
    not change before and after invoking the hook, again, is also waste
    of resources.

    It may have been a mistake that we re-read the index in some case,
    which adds to the confusion, but not others.

  3. All the filesystem and other system state changes that a script does. Really
    everything that a script does beyond returning an exit code.

2

Ah. Okay, I see. Yeah, this is counterintuitive at best, I’d call it bugged, and I don’t see any explanation of it in the docs.

The very short form is it appears only pre-commit can modify the commit, if you want a hook-modified merge you’re going to have to add --no-commit and use a following git commit to do the automatic changes, to run the pre-commit hook.

commit-msg runs too late to affect the committed snapshot, index changes are accepted but they don’t go in to the commit itself… but they do show up in the printed summary of that commit. Yuck.

pre-merge-commit likewise cannot affect the commit, but it isn’t invoked at all (pre-commit is eventually run instead) for merges that require manual resolution. However, if you ask for a summary of the merge it at least doesn’t include the hook’s attempted changes.

pre-commit can affect the commit, at least in the cases I tested.

Test case for commit-msg:

sh -x <<EOD
git init --template= `mktemp -d`; cd $_
mkdir -p .git/hooks
echo 'echo hithere >>file; git add file' >.git/hooks/commit-msg
chmod +x -R .git/hooks

echo >file; git add .; git commit -m'this will show two insertions'
cat file;                : and there they are
git show :file           #
git show @:file;         : ... but it only committed one
EOD

So if the commit-msg hook tries to affect the commit, it will fail, which kinda makes sense, but the commit summary won’t reflect the failure, at least in this case, which is just wrong.

Test case for pre-commit with ordinary commits:

sh -x <<EOD
git init --template= `mktemp -d`; cd $_
mkdir -p .git/hooks
echo 'echo hithere >>file; git add file' >.git/hooks/pre-commit
chmod +x -R .git/hooks

echo >file; git add .; git commit -m'this will show two insertions'
cat file;                : and there they are
git show :file           #
git show @:file;         : ... and it committed both
EOD

and that works fine.

Test case for pre-merge-commit with a trivially-resolved merge

sh -x <<EOD
git init --template= `mktemp -d`; cd $_
mkdir -p .git/hooks
echo 'echo hithere >>file; git add file' >.git/hooks/pre-merge-commit
chmod +x -R .git/hooks

echo line1 >file; git add .; git commit -m-
git checkout -b mergeme
echo line2 >>file; git add .; git commit -m-
git checkout -
echo line2 >>file; sleep 1; git commit -am-
git merge --stat -;             : this will show only one insertion
cat file;                : but the work tree and index have both
git show :file           
git show @:file;         : ... tho the hook insertion is not committed
EOD

Test case for pre-merge-commit with an autoresolved merge:

sh -x <<EOD
git init --template= `mktemp -d`; cd $_
mkdir -p .git/hooks
echo 'echo hithere >>file; git add file' >.git/hooks/pre-merge-commit
chmod +x -R .git/hooks

echo line1 >file; git add .; git commit -m-
git checkout -b mergeme
(seq 3; echo line1) >file; git commit -am-
git checkout -
seq 3 >>file; git commit -am-
git merge --stat -;             : this will not show the hook insertion 
cat file;                : and though the work tree and index have it
git show :file           
git show @:file;         : ... it really was not committed
EOD

3

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật