A Subversion Pre-Commit Hook

2006-08-09, , , Comments

Subversion’s hook scripts provide a powerful and flexible way to associate actions with repository events. For example, the pre-commit hook allows you to check — and possibly abort — a transaction before it actually gets committed. This entry describes how to install and test a simple Python hook script to prohibit tabs from C++ files.

Creating and Installing a Hook Script

Your Subversion repository already has some template hook scripts. For example, the pre-commit template is in PATH_TO_REPOS/hooks/pre-commit.tmpl. These templates contain instructions on what the hook script does and what parameters it can expect.

Here, then, is the most direct route to creating and activating a pre-commit hook:

su - svn                      # As user svn
cd PATH_TO_REPOS/hooks        # Change to the hooks directory
cp pre-commit.tmpl pre-commit # Create a pre-commit script
emacs pre-commit              # Edit to taste
chmod u+x pre-commit          # It needs to be executable

And that’s it.

You haven’t actually tested your new hook script so it probably doesn’t do what you meant it to. Either you can sit back and wait for users to complain or you can think of a way to test your hook before you install it.

System Testing a Hook Script

I’ve used a couple of strategies:

  1. Set up a throw away temporary repository and use this as a sandbox in which to develop your hook script.
  2. Find a way to test your hook script on the live repository before you actually install it.

In the case of a pre-commit hook the second strategy is quite attractive. Pre-commit hooks are typically used to enforce checks on a transaction before it gets committed and becomes a new repository revision. They are often introduced when a faulty revision gets into the repository and someone says: “Couldn’t Subversion have stopped this from happening?”

With a little ingenuity you can test how the pre-commit hook would have responded to such a faulty transaction which, in future, we would like to prohibit.

Svnlook

Svnlook is the Subversion administrator’s friend. It can be used on the Subversion server to examine the repository without changing it. A hook script typically uses svnlook to examine a repository event and take appropriate action. Thus, for example, to see what files were changed by revision 1234, we run:

svnlook changed PATH_TO_REPOS --revision 1234

To find what files might be changed by transaction 1234-1 (if nothing goes wrong) the command is similar:

svnlook changed PATH_TO_REPOS --transaction 1234-1

Examining transactions using svnlook is tricky since transactions are transient. When a transaction becomes a revision you can no longer look at it.

The Pre-commit Hook

The pre-commit hook gives you an opportunity to catch the transaction just before it becomes a revision. Subversion passes this hook two parameters:

  1. the path to the root of the repository
  2. the transaction identifier

The pre-commit can fail the transaction by printing an informative message to standard error and returning non-zero. A return code of zero allows the transaction to complete successfully.

For testing we can add an extra switch, --revision, to our test pre-commit hook. This switch is to indicate that the second parameter is in fact a revision number. Now we can system-test our hook script on exisiting repository revisions, and confirm that it does indeed return non-zero for the bad ones and zero for the good ones.

test-pre-commit PATH_TO_REPOS --revision 1234

An Example Pre-commit Hook

Here, then, is a pre-commit hook to ban the TAB character from C++ source files. Test it using the --revision option. Command line help is available using --help.

It uses two different flavours of svnlook to examine the repository:

  1. svnlook changed — to find which files were changed by a transaction/revision
  2. svnlook cat — to find the contents of a file in a transaction/revision
pre-commit
#!/bin/env python
" Example Subversion pre-commit hook. "

def command_output(cmd):
  " Capture a command's standard output. "
  import subprocess
  return subprocess.Popen(
      cmd.split(), stdout=subprocess.PIPE).communicate()[0]

def files_changed(look_cmd):
  """ List the files added or updated by this transaction.

"svnlook changed" gives output like:
  U   trunk/file1.cpp
  A   trunk/file2.cpp
  """
  def filename(line):
      return line[4:]
  def added_or_updated(line):
      return line and line[0] in ("A", "U")
  return [
      filename(line)
      for line in command_output(look_cmd % "changed").split("\n")
      if added_or_updated(line)]

def file_contents(filename, look_cmd):
  " Return a file's contents for this transaction. "
  return command_output(
     "%s %s" % (look_cmd % "cat", filename))

def contains_tabs(filename, look_cmd):
  " Return True if this version of the file contains tabs. "
  return "\t" in file_contents(filename, look_cmd)

def check_cpp_files_for_tabs(look_cmd):
  " Check C++ files in this transaction are tab-free. "
  def is_cpp_file(fname):
      import os
      return os.path.splitext(fname)[1] in ".cpp .cxx .h".split()
  cpp_files_with_tabs = [
      ff for ff in files_changed(look_cmd)
      if is_cpp_file(ff) and contains_tabs(ff, look_cmd)]
  if len(cpp_files_with_tabs) > 0:
      sys.stderr.write("The following files contain tabs:\n%s\n"
                       % "\n".join(cpp_files_with_tabs))
  return len(cpp_files_with_tabs)

def main():
  usage = """usage: %prog REPOS TXN

Run pre-commit options on a repository transaction."""
  from optparse import OptionParser
  parser = OptionParser(usage=usage)
  parser.add_option("-r", "--revision",
                    help="Test mode. TXN actually refers to a revision.",
                    action="store_true", default=False)
  errors = 0
  try:
      (opts, (repos, txn_or_rvn)) = parser.parse_args()
      look_opt = ("--transaction", "--revision")[opts.revision]
      look_cmd = "svnlook %s %s %s %s" % (
          "%s", repos, look_opt, txn_or_rvn)
      errors += check_cpp_files_for_tabs(look_cmd)
  except:
      parser.print_help()
      errors += 1
  return errors

if __name__ == "__main__":
  import sys
  sys.exit(main())

Feedback