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
- git version 2.48.0.rc0
-
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. - 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