Subversion hooks in C#

Reason

A colleague recently asked for a mechanism to prevent inadvertent commits of JavaScript files containing “debug session leftovers” like alert() and console.log() to our Subversion repositories. Since a pre-commit hook would be a viable solution to the issue, I attempted to extend our existing hook – which enforces the entry of log messages when committing changes, the “Hello World!” of pre-commit hooks as it seems – only to find that it was simply a copy of a batch file example, which proved difficult to extend sensibly.

The code provided below is the C# implementation of a SVN pre-commit hook, which encompasses the requirements outlined above by checking for:

  • empty SVN log messages
  • JavaScript alert()’s
  • JavaScript console.log()’s and its derivatives

Examples are based on .NET 4.5 and Subversion 1.7, using AnkhSVN 2.4, TortoiseSVN 1.7 and VisualSVN Server 2.5.

Code

The implementation consists of the following classes that are compiled as a console application:

  • PreCommitHook – contains the entry point of the program and runs the pre-commit checks.
  • SvnlookFacade – a facade to some of the features provided by svnlook.exe, which is used to query SVN repositories.
  • PreCommitCheck – a base class for the following concrete implementations:
    • EmptyCommentCheck
    • JavaScriptAlertCheck
    • JavaScriptConsoleCheck

The implementations are very straight forward and don’t provide any robustness when confronted with code like “var c = console; c.log(‘Hello World!’);”.

PreCommitHook

As suggested in the TortoiseSVN documentation, commit validation can be skipped by entering a certain phrase in the log message.

using System;
using System.Text;
using System.Text.RegularExpressions;

public class PreCommitHook
{
  private const string SkipValidationPhrase = "force commit";
  private enum ValidationResult
  {
    Succes = 0,
    Failed = 1,
    InvalidParameters = 2,
    Exception = 3
  }

  public static int Main(string[] args)
  {
    if (args.Length < 2)
    {
      Console.Error.WriteLine("Expected at least 2 parameters (repository path and transaction name).");
      return (int)ValidationResult.InvalidParameters;
    }

    try
    {
      string repositoryPath = args[0];
      string transactionName = args[1];

      if (ShouldSkipValidation(repositoryPath, transactionName))
        return (int)ValidationResult.Succes;

      string checkResults = RunPreCommitChecks(repositoryPath, transactionName);
      if (string.IsNullOrEmpty(checkResults))
        return (int)ValidationResult.Succes;

      Console.Error.WriteLine(checkResults);
      Console.Error.WriteLine();
      Console.Error.WriteLine("To commit changes without validation, enter \"{0}\" as part of the comment/log message.", SkipValidationPhrase);
      return (int)ValidationResult.Failed;
    }
    catch (Exception ex)
    {
      Console.Error.WriteLine(ex.ToString());
      return (int)ValidationResult.Exception;
    }
  }

  private static bool ShouldSkipValidation(string repositoryPath, string transactionName)
  {
    string comment = SvnlookFacade.GetComment(repositoryPath, transactionName);
    return Regex.IsMatch(comment, SkipValidationPhrase, RegexOptions.IgnoreCase);
  }

  private static string RunPreCommitChecks(string repositoryPath, string transactionName)
  {
    PreCommitCheck[] preCommitChecks = new PreCommitCheck[]
    {
      new EmptyCommentCheck(repositoryPath, transactionName),
      new JavaScriptAlertCheck(repositoryPath, transactionName),
      new JavaScriptConsoleCheck(repositoryPath, transactionName)
    };

    StringBuilder checkResults = new StringBuilder();
    foreach (PreCommitCheck preCommitCheck in preCommitChecks)
    {
      checkResults.AppendLine(preCommitCheck.GetCheckResult());
    }
    return checkResults.ToString().Trim();
  }
}

SvnlookFacade

As stated previously SvnlookFacade only supports the few commands relevant to this example, but it should be fairly intuitive to extend the class to provide any of the available features described in the svnlook documentation. The executable itself (svnlook.exe) is usually found alongside an installed SVN server. Various server implementations can be downloaded from http://subversion.apache.org/packages.html#windows.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

public static class SvnlookFacade
{
  public static string GetComment(string repositoryPath, string transactionName)
  {
    string commandLineArguments = string.Format("log --transaction {0} \"{1}\"", transactionName, repositoryPath);
    return GetSvnlookOutput(commandLineArguments) ?? string.Empty;
  }

  private static string GetSvnlookOutput(string commandLineArguments)
  {
    Process process = new Process();
    process.StartInfo.UseShellExecute = false;
    process.StartInfo.RedirectStandardOutput = true;
    process.StartInfo.FileName = @"C:\Program Files (x86)\VisualSVN Server\bin\svnlook.exe";
    process.StartInfo.Arguments = commandLineArguments;
    process.Start();
    string output = process.StandardOutput.ReadToEnd();
    process.WaitForExit();
    return output;
  }

  public static IEnumerable<string> GetChangedFileNames(string repositoryPath, string transactionName)
  {
    string commandLineArguments = string.Format("changed --transaction {0} \"{1}\"", transactionName, repositoryPath);
    string output = GetSvnlookOutput(commandLineArguments);
    string[] fileNames = output.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries);
    return fileNames.Select(RemoveChangeDescriptorPrefix).ToArray();
  }

  private static string RemoveChangeDescriptorPrefix(string fileName)
  {
    // Prefix denoting "item added", "item deleted", "file contents changed",
    // "item properties changed", "file contents and properties changed"
    string[] changeDescriptors = new[] { "A ", "D ", "U ", "_U", "UU" };
    foreach (string changeDescriptor in changeDescriptors)
    {
      if (fileName.StartsWith(changeDescriptor))
        return fileName.Substring(changeDescriptor.Length).Trim();
    }
    return fileName;
  }

  public static string GetFileContents(string repositoryPath, string transactionName, string fileName)
  {
    string commandLineArguments = string.Format("cat --transaction {0} \"{1}\" \"{2}\"", transactionName, repositoryPath, fileName);
    return GetSvnlookOutput(commandLineArguments);
  }
}

PreCommitCheck

public abstract class PreCommitCheck
{
  protected string RepositoryPath { get; private set; }
  protected string TransactionName { get; private set; }

  protected PreCommitCheck(string repositoryPath, string transactionName)
  {
    RepositoryPath = repositoryPath;
    TransactionName = transactionName;
  }

  public abstract string GetCheckResult();
}

EmptyCommentCheck

public class EmptyCommentCheck : PreCommitCheck
{
  public EmptyCommentCheck(string repositoryPath, string transactionName)
    : base(repositoryPath, transactionName)
  {
  }

  public override string GetCheckResult()
  {
    string comment = SvnlookFacade.GetComment(RepositoryPath, TransactionName);
    return string.IsNullOrWhiteSpace(comment) ? "Please write a comment describing the changes you're committing." : string.Empty;
  }
}

JavaScriptAlertCheck

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

public class JavaScriptAlertCheck: PreCommitCheck
{
  public JavaScriptAlertCheck(string repositoryPath, string transactionName)
    : base(repositoryPath, transactionName)
  {
  }

  public override string GetCheckResult()
  {
    StringBuilder messages = new StringBuilder();
    IEnumerable<string> changedFiles = SvnlookFacade.GetChangedFileNames(RepositoryPath, TransactionName);
    IEnumerable<string> changedJavaScriptFiles = changedFiles.Where(fn => fn.EndsWith(".js", StringComparison.OrdinalIgnoreCase)).ToArray();
    foreach (string fileName in changedJavaScriptFiles)
    {
      string fileContents = SvnlookFacade.GetFileContents(RepositoryPath, TransactionName, fileName);
      if (ContainsAlertCall(fileContents))
        messages.AppendFormat("File \"{0}\" contains one or more calls to \"alert\".", fileName);
    }
    return messages.ToString();
  }

  private bool ContainsAlertCall(string fileContents)
  {
    return Regex.IsMatch(fileContents, @"alert\(", RegexOptions.IgnoreCase);
  }
}

JavaScriptConsoleCheck

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;

public class JavaScriptConsoleCheck: PreCommitCheck
{
  public JavaScriptConsoleCheck(string repositoryPath, string transactionName)
    : base(repositoryPath, transactionName)
  {
  }

  public override string GetCheckResult()
  {
    StringBuilder messages = new StringBuilder();
    IEnumerable<string> changedFiles = SvnlookFacade.GetChangedFileNames(RepositoryPath, TransactionName);
    IEnumerable<string> changedJavaScriptFiles = changedFiles.Where(fn => fn.EndsWith(".js", StringComparison.OrdinalIgnoreCase)).ToArray();
    foreach (string fileName in changedJavaScriptFiles)
    {
      string fileContents = SvnlookFacade.GetFileContents(RepositoryPath, TransactionName, fileName);
      if (ContainsConsoleCall(fileContents))
        messages.AppendFormat("File \"{0}\" contains one or more calls to \"console\".", fileName);
    }
    return messages.ToString();
  }

  private bool ContainsConsoleCall(string fileContents)
  {
    return Regex.IsMatch(fileContents, @"console\.(log|error|debug|warn|info)\(", RegexOptions.IgnoreCase);
  }
}

Example

Using the hook simply requires naming the executable “pre-commit.exe” and copying it into the “hooks” folder of the repository in question.

Output using AnkhSVN

Output using TortoiseSVN

5 thoughts on “Subversion hooks in C#

  1. Subject: Worked partially.

    Hi,
    Appreciate your effort for putting it all here in a neat fashion. However, when I put this exe in the visual svn’s repository’s hooks folder, it did validated correctly but doesn’t return the right display message (whatever that’s sent to the Console.Error). the TortoiseSVN window just says :

    Commit blocked by pre-commit hook (exit code -2146232576) with no output.

    Any chance you can help to understand what am I missing?

    Note – I used the above code as is, in addition to adding the needed references : Linq, etc..at the top of the c# script file. I can send you the script file or paste it here, if needed. Please let me know.

    Thanks in advance,

    VC

    • By The Way, I tried the above with VisualSVN and TortoiseSVN combination. I also tried removing the new line characters in the error messages. I also tried removing all the logic and just did a plain “no matter what reject the commit by displaying an error message” – The commit expectedly got rejected, how ever the error message isn’t getting out via the TortoiseSVN window. That’s where I got stuck.

    • Hi VC,

      You can send your code to M8R-c2k4aw@mailinator.com – I’d be happy to take a look at it.

      Since it seems there’s an issue redirecting and reading the standard output stream you could try replacing the call to Console.Error in the catch block of static int Main with a basic file write (i.e. log any exception incl. stack trace to a temporary file). That way you might get a bit more debug info in case the Windows Event Log doesn’t contain anything useful (based on the returned error code it looks like *something* throws an exception).

      My implementation is still used where I work. We currently use TortoiseSVN 1.7.6, VisualSVN Server 2.5.8, VisualSVN 3.0.4 and Visual Studio 2012 on Windows 7 64-bit.

      • Appreciate for the Code Review Offer Uli. I sent the code to the above email. I will also try redirecting the error-output to a file and see if really any error text is getting out at all.

  2. @VC: just in case you still have that problem: in my case it was a missing .Net Framework on the server running svn. After I changed the project settings to 3.5 (which was installed on the server) it worked without problems.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s