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

Advertisements