A server-side hook rejects every commit until the message carries the right project token
Symptom
I had one line to change in a deployment script that lives in a shared release repository. The edit took ten seconds. Getting it into the repository took an hour, because the server refused the commit:
svn: E165001: Commit blocked by pre-commit hook (exit code 1) with output:
<the hook's own message here>
The exact wording of the second part is whatever the repository’s administrator wrote into the hook; mine
said, in effect, that the commit message did not begin with a valid project identifier. Nothing landed:
Subversion had already opened a transaction, the hook returned non-zero, the transaction was rolled back,
and the working copy was untouched. svn status looked exactly as it did before I pressed enter.
I did what everyone does. I reworded the message. Then I made it longer, then shorter, then I put the ticket number in front, then I removed everything but the ticket number. Every attempt failed identically and in the same amount of time — which, in hindsight, was the whole diagnosis sitting in plain sight.
What it is not
A commit that fails on a server invites three wrong theories, and each of them costs time.
It is not authentication. Bad or missing credentials fail earlier and differently: you get an
authorization or authentication error, not a hook failure, and the error does not contain someone else’s
prose. E165001 is specifically “a hook script ran and exited non-zero”. The server already knew who I
was and was willing to let me write; it was the content of the commit it objected to.
It is not a path permission problem. Path-based authorization refuses you before a transaction is built, and says so.
It is not a local repository or working-copy problem. There is nothing to clean up, no lock to break,
no svn cleanup to run. The transaction never became a revision. Re-checking out the tree — the reflex
when Subversion misbehaves — changes nothing, because the rule being enforced is not in your checkout at
all.
The confirmation takes one command. I committed a trivial change to a different path in the same repository, with a message in exactly the style that had just been rejected. It went through. Same user, same client, same server, same message — different directory tree. That single comparison is what identified the rule: the policy is path-scoped.
What I measured
Once I knew the policy depended on the path, the repository’s own history became the documentation. The hook’s contract is a commit message prefix, and every previous commit to that path had to satisfy it:
svn log -l 5 svn://svn.example.com/repo/trunk/projects/app/env
Five messages, all starting with the same short uppercase token. I copied the shape and the commit was accepted on the first try.
Then I looked wider, and the picture was less tidy than “the repository requires a prefix”. Different trees in the same repository wanted different things:
- one tree accepted a bare uppercase project key,
PROJ_A something something; - another wanted the key with a colon,
PROJ_B: something something; - a third, holding several related components, wanted a generic family key,
GROUP: something something, and rejected the per-component keys.
(Those tokens are invented for this post; yours will be whatever your repository grew.) None of this was
written down anywhere a client could reach. There is no file in the working copy that declares it, no
property on the directory, nothing svn info will tell you. It lives in a script in the repository’s
hooks/ directory on the server, which ordinary committers cannot read.
So the practical rule is: read svn log on the exact path you are about to commit to, and mirror the
message format you find there. Five entries are enough. It is faster than any number of guesses, and it
works for undocumented conventions in general, not just hook-enforced ones.
Stated fairly, the policy has a reason. A token in the message lets an issue tracker and a release pipeline attribute a commit to a project without maintaining a directory-to-project mapping somewhere else — a mapping that would rot the first time a tree is renamed or a component moves. It is crude, it puts metadata in prose, and it works. The failure mode is not that the rule is wrong; it is that the rule is invisible until it fires.
The fix, and the second half of the problem
The message was only half of it. The tree in question is a release tree: large, shared, and full of files
other people are staging. Checking it out to edit one script means a long transfer, and then a working
copy that will happily commit everything modified in it if someone runs svn commit at the top without
looking. I have watched that happen.
svnmucc avoids the working copy entirely. It applies a list of operations directly to a URL and turns
them into a single atomic commit:
svnmucc -U svn://svn.example.com/repo/trunk/projects/app/env \
-m 'PROJ_A: bump image tag to 1.4.2' \
--username builder --non-interactive --no-auth-cache \
put ./deploy.sh deploy.sh \
rm obsolete.sh
-U sets a root URL so the operands can be relative to it; put uploads a local file over a repository
path; rm deletes one. Two operations, one revision. That last detail matters more than it looks: if a
post-commit hook, a build trigger or a mirror watches revisions, a put and an rm as separate commits
produce an intermediate revision in which the script has been updated but the obsolete one is still there.
One svnmucc invocation never exposes that state.
What svnmucc does not do is exempt you from the hook. It bypasses the working copy, not server-side
policy — the -m message goes through exactly the same pre-commit script, and an unprefixed message is
rejected here just as it is from svn commit. That is worth internalising, because the two problems feel
related (both are “I cannot commit”) and are completely independent. There is more on the mechanics in
committing without a working copy.
A note on the credentials, since this is the shape of command that ends up in a CI job: do not pass the
password on the command line. It is visible in ps output to every user on the machine and it lands in
shell history. Let the client’s credential store or an interactive prompt supply it, and keep
--no-auth-cache when you are on a shared or build machine so the run does not leave a cached credential
behind for the next person. --non-interactive is what makes that combination safe to automate: it fails
loudly instead of blocking on a prompt nobody will answer.
How to read a hook rejection in general
The hook’s stderr is the contract. It is the only interface the policy exposes, so read it literally rather than paraphrasing it into what you expect it to say — “invalid identifier” and “missing identifier” point at different mistakes.
If the message is terse or unhelpful, fall back to the two moves above, in order:
svn log -l 5 <the exact URL you are committing to>and copy the format of what is already there.- Commit something trivial to a neighbouring path. If that succeeds, the policy is path-scoped and you are looking for a per-tree rule, not a repository-wide one.
What to remember
Server-side hooks are policy that lives outside your checkout and outside your tooling’s knowledge. Your client cannot warn you, your IDE cannot lint for it, and the repository will not tell you the rule until you break it. They will reject work that is otherwise entirely correct, and the only interface you get is an error string written by whoever set the repository up.
So treat the first rejection as documentation rather than an obstacle. Read it, find the shape in the history, and then encode the rule in the script you commit with — a wrapper that builds the message, a template, a variable at the top of the deployment script. Anything except remembering it, because in three months you will be committing to a different tree with a different token and you will be reworded-message deep before you notice.