Branch Freezing with Mercurial

I recently found the need to venture into the somewhat shady realm of Mercurial hooks. (If you are not familiar, Mercurial is a git-like Version Control System written in Python.) Within my development environment, after making releases, we construct named branches of the release, to be kept pristine in perpetuity. This allows us, at any time in the future, to reconstruct a particular release identical to what has been released to the public, or to sub-branch from that point for any subsequent patch releases that might be necessary.

While Mercurial has a rather powerful branch management toolset (which is one of the reasons we switched to it from Subversion), one thing it does appear to lack is the ability to actually “freeze” a named branch. By “freeze”, I mean actively disallow any further modifications. Up to this point on my project (and even under Subversion), “freezing” a branch amounted to not much more than a mutual understanding and agreement that no further modifications were to be made. However, I recently ran into a situation where a released branch was being modified by a developer because they did not (for some reason) realize that it had been released, and had been modifying it for some months before it was discovered. Although it was an accident, and I could probably have walked back through and somehow undone the damage, I decided to harness my fury and put in place a mechanism that would actually prevent it in the future.

Why Not Just Close A Branch?

Mercurial does provide a mechanism to “close” a named branch.   I explored this possibility before going for the throat. However, one of the major flaws I discovered with this approach is that any push to a “closed” branch automatically re-opens it and applies the modification. This is really unacceptable to my intent, and I realized that I was left with no other choice but to put a hook in place.

An Uncommon Task

Of course, I went in search of an existing piece of work that would accomplish what I wanted. Surely, I thought, this would be a common thing, and there would be numerous examples from which I could choose. Well…such was not the case. A considerable search for a Mercurial hook that would do exactly what I wanted proved to simply not exist.

Picking pieces from other existing hooks, and scanning through the Mercurial hook documentation (such as it is), I was able to put together a fairly simple Mercurial hook that will actually “freeze” a named branch.

So, in case you find yourself in the same place, here’s how I solved the problem…

Update The Config

First, you’ll want to open your repository’s .hg/hgrc file. In my case, this is on a centralized server where Mercurial is being served via Apache. In this location, any and all interactions with the repository can be monitored by the hook.

If your hgrc file does not already have a “hooks” section, you’ll want to add one, along with an entry for a pretxnchangegroup hook:

[hooks]
pretxnchangegroup.frozen_branches = python:frozen_branches.frozen_branches.hook

This creates an “in-process” hook, meaning the hook will run within the Mercurial space instead of running as a stand-alone process (each type requires a different interface). You will want to place the file (we’ll call this one “frozen_branches.py”) that contains your hook function into one of the folders in Python’s sys.path list. You can see all the paths Python will search by running this Python command:

python -c "import sys ; print sys.path"

Once you’ve selected a location from Python’s search paths, create a folder called “frozen_branches.” It is within this directory that you will deposit your hook files.

Hook Settings

Within the same hgrc file where you specified the hook itself, you’ll want to create a section for your hook’s data. This section will contain a single setting that lists all the named branches that are to be considered “frozen” by the hook. It might look something like this:

[frozen_branches]
freeze_list = 10.0, 10.next, 11.0, 11.0.sp1, 11.0.sp2

The Hook

Your hook needs to function as a Python package. This means that, along with your main Python file, you will need to create an empty “__init__.py” file in the same folder to flag it as a package. The existence of this file seemed to be necessary for the hook to successfully load into the Mercurial environment. YMMV.

The frozen_branches.hook begins with a default function signature that most hooks require:

def hook(ui, repo, **kwargs):

I’m not going to pretend to have deep arcane knowledge of these arguments. Based on other hook examples I could find, I know that ui will be used for various I/O functions, and repo represents all the information we will require for the pending changes to the repository.

Next, we need to get our settings from the hgrc. We use ui to access this information:

frozen_list = ui.configlist('frozen_branches', 'freeze_list')
if frozen_list is None:
# no frozen branches listed; allow all changes
return False

Note that returning False from the hook indicates that the action should be allowed, while returning True tells Mercurial that the action should be rejected.

To ensure proper usage, we need to check the hook type to make sure it is not being used for anything other than the pretxnchangegroup hook type. Using it with another type may have unpredictable results. We extract this information from the kwargs parameter:

hooktype = kwargs['hooktype']
if hooktype != 'pretxnchangegroup':
ui.warn('frozen_branches: Only "pretxnchangegroup" hooks are supported by this script\n')
return True

There are other (potentially valuable) keyword values contained in the kwargs list; we use a few — like ‘hooktype’ and ‘node’ — in this script.

We now need to examine each incoming changeset that is about to be applied to the repository, and if there are any that target a designated “frozen” branch, the entire action will be rejected with a message to the user:

ctx = repo[kwargs['node']]
start = ctx.rev()
end = len(repo)

for rev in xrange(start, end):
node = repo[rev]
branch = node.branch()
if branch in frozen_list:
ui.warn(“abort: %d:%s includes modifications to a frozen branch ‘%s’!\n” % (rev, node.hex()[:12], branch))
# reject the entire changegroup
return True

Note the formatting in the message:

(rev, node.hex()[:12], ...

These values will produce a changeset identifier which should match what the user will see if they do an “hg log” or “hg outgoing”. This should make it easier to identify the offending entry.

Finally, if everything is acceptable (i.e., the loop finished without hitting its embedded return), we let Mercurial know that the entire changegroup can be applied to the repository:

# allow the changegroup
return False

No More Nasty Surprises

Once in place, any attempt to modify a “frozen” branch will result in an abort on the client end, with an explanatory error message:

$ hg push
pushing to https://...
searching for changes
remote: adding changesets
remote: adding manifests
remote: adding file changes
remote: added 1 changesets with 1 changes to 1 files
remote: abort: 26218:de8955e0b9f4 includes modifications to a frozen branch '11.0.sp2'!
remote: transaction abort!
remote: rollback completed

$ hg outgoing
comparing with https://…
searching for changes
changeset: 26218:de8955e0b9f4
branch: 11.0.sp2
tag: tip

And the day will never come again when you find a nasty surprise waiting in a released — and supposedly frozen — branch. 🙂

Advertisements