I recently set up a Subversion pre-commit hook at work to check for the presence of an issue reference in the commit message. It was tricky enough to figure out that I thought I’d post the end result here. Many thanks to this post for flattening the learning curve for me. It’s an old article, but I was able to reuse a good chunk of the code it presents. And the ability to call it with --revision is really helpful for testing.
The tricky bit was dealing with our server’s slightly odd project layout. We have several repos that contain what amount to ‘sub-repos’, i.e., multiple directories that contain the conventional branches, tags and trunk sub-folders. We only wanted to enable the commit check on a subset of these ‘sub-repos’, so some futzing was required to do this filtering. I also made the script as generic as possible so that new types of checks can be added in the future. (The commented-out check_py_files_contain_no_tabs() call is an artifact of my proving to myself that I could, in fact, run a ‘battery’ of checks, if desired.)
First, here’s the base pre-commit script that calls the main Python script:
#!/bin/bash
REPOS="$1"
TXN="$2"
SVNLOOK=/usr/local/bin/svnlook
/usr/local/bin/python /repo/my-project/hooks/pre-commit.py "$REPOS" "$TXN" > /dev/null || exit 1
exit 0
This calls the following:
#!/usr/bin/env python
# Folders in this set will be subject to commit sanity-checks.
# Update as needed...
COMMIT_CHECK_FOLDERS = frozenset([
'SubProject-A/branches',
'SubProject-A/tags',
'SubProject-A/trunk',
'SubProject-B/branches',
'SubProject-B/tags',
'SubProject-B/trunk',
])
def capture(cmd):
"""Capture a command's standard output."""
import subprocess
return subprocess.Popen(
cmd.split(), stdout=subprocess.PIPE
).communicate()[0]
def check_log_msg_contains_issue(look_cmd):
"""Check whether the commit message references an issue
Returns 0 if the commit message contains an issue reference
(e.g., '[NL-1234]'), otherwise returns 1
"""
import re
msg = capture(look_cmd.format("log"))
pat = r'\[[A-Z]{2,8}-\d{1,6}\]'
if re.search(pat, msg) is None:
sys.stderr.write(
'----------------------------------------'
'----------------------------------------\n'
'Please include an issue number in your commit message. (If no '
'issue is relevant,\njust include "[NL-0000]" somewhere in the '
'message...)\n'
)
return 1
else:
return 0
def files_updated(look_cmd):
""" List the files touched by the current transaction.
The `svnlook changed` can be pretty esoteric, looking something like:
A + moved_dir
M + moved_dir/README
D stuff/fish.c
A stuff/loot/bloo.h
C stuff/loot/lump.c
C stuff/loot/glub.c
R xyz.c
U trunk/file1.cpp
A trunk/file2.cpp
The file status info is guaranteed to be only 4 characters wide, though,
so we can get the file list just by snipping off the first four
characters...
"""
def filename(line):
return line[4:]
return [
filename(f)
for f in capture(look_cmd.format("changed")).split("\n")
if f # 0:
sys.stderr.write(
'----------------------------------------'
'----------------------------------------\n'
"Please remove TABs from these files before committing:"
"\n {0}\n".format(
"\n ".join(py_files_with_tabs)
)
)
return len(py_files_with_tabs)
def main():
retval = 0
usage = """
%prog <SVN_REPO_PATH> <TRANSACTION_ID>
Run pre-commit options on a repository transaction.
"""
from optparse import OptionParser
from os.path import dirname
parser = OptionParser(usage=usage)
parser.add_option(
"-r", "--revision",
help="Test mode [TRANSACTION_ID actually refers to a revision]",
action="store_true",
default=False
)
try:
(opts, args) = parser.parse_args()
if len(args) != 2:
parser.print_help()
sys.exit(1)
# The `look_cmd` var below is a string-formatting template containing
# a '{0}' placeholder for whichever svnlook sub-command needs to be
# invoked. It eventually winds up expanding to things like:
#
# svnlook cat <repos_path> --transaction
#
# or:
#
# svn log <repos_path> --revision
#
# etc, etc...
#
repos, txnum_or_revnum = args
if opts.revision:
look_opt = "--revision"
else:
look_opt = "--transaction"
look_cmd = "/usr/local/bin/svnlook {0}" + " {0} {1} {2}".format(
repos, look_opt, txnum_or_revnum
)
# Run our commit checks if *any* of the files touched by this commit
# reside in (sub-)folders listed in COMMIT_CHECK_FOLDERS
if commit_touches_checked_folders(look_cmd):
retval += check_log_msg_contains_issue(look_cmd)
#retval += check_py_files_contain_no_tabs(look_cmd)
except Exception as e:
import traceback
sys.stderr.write("Unhandled exception in pre-commit script: ")
traceback.print_exc()
retval += 1
return retval
if __name__ == "__main__":
import sys
sys.exit(main())
All in all, I’m pretty happy with the result. Adding the catchall except clause that prints a traceback turned out to be a nice debugging aid. (And I needed one, too!)