I've recently taken an interest in analyzing commit history to find files with a lot of churn. The idea being that high churn files are likely targets for refactoring. Right now it's a simple console app that returns the number of times a given file was committed. (Renames are not currently followed. It's a known area for improvement.)
The basic design is to have a visitor walk the commit graph and raise events for a listener to gather information about them. Currently there is a single listener that diffs each commit with its parents and caches those diffs. This should be flexible enough to create another one that, say, collects committer stats though.
GitNStats v1.0.1 is on Github if you prefer to view it there.
Visitor.cs
using LibGit2Sharp;
namespace GitNStats
{
public abstract class Visitor
{
public delegate void VisitedHandler(CommitVisitor visitor, Commit commit);
public event VisitedHandler Visited;
public abstract void Walk(Commit commit);
protected virtual void OnVisited(CommitVisitor visitor, Commit commit)
{
Visited?.Invoke(visitor, commit);
}
}
}
CommitVisitor.cs
Git's commit history isn't a tree, it's a graph and we don't want to visit any commit more than once, so this class needs to keep track of the commits it's already visited.
using System;
using System.Collections.Generic;
using LibGit2Sharp;
namespace GitNStats
{
/// <summary>
/// Walks the commit graph back to the beginning of time.
/// Guaranteed to only visit a commit once.
/// </summary>
public class CommitVisitor : Visitor
{
/// <summary>
/// Walk the graph from this commit back.
/// </summary>
/// <param name="commit">The commit to start at.</param>
public override void Walk(Commit commit)
{
Walk(commit, new HashSet<string>());
}
private void Walk(Commit commit, ISet<string> visited)
{
if (visited.Contains(commit.Sha))
{
// Exit so we don't walk the same path multiple times.
return;
}
visited.Add(commit.Sha);
OnVisited(this, commit);
foreach(var parent in commit.Parents)
{
Walk(parent, visited);
}
}
}
Listener.cs
using LibGit2Sharp;
namespace GitNStats
{
/// <summary>
/// Listens for a <see cref="Visitor"/> to raise the <see cref="Visitor.Visited"/> event.
/// </summary>
public interface Listener
{
/// <summary>
/// To be executed whenever a visitor vistis a commit.
/// </summary>
/// <param name="visitor">The <see cref="Visitor"/> that raised the <see cref="Visitor.Visited"/> event.</param>
/// <param name="visited">The <see cref="Commit"/> currently being visited.</param>
void OnCommitVisited(Visitor visitor, Commit visited);
}
}
DiffListener.cs
This is the class performs a diff that is roughly equivalent to a git log --stat
and caches them for later use.
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using LibGit2Sharp;
namespace GitNStats
{
/// <summary>
/// When a Commit is visited, compares that commit to it's parents
/// and stores the resulting TreeEntryChanges in the <see cref="Diffs"/> property.
/// </summary>
public class DiffListener : Listener
{
private readonly IRepository _repository;
private readonly ConcurrentBag<TreeEntryChanges> _diffs = new ConcurrentBag<TreeEntryChanges>();
/// <summary>
/// The diff cache.
/// Clients should wait until the <see cref="Visitor"/> is done walking the graph before accessing.
/// </summary>
public IEnumerable<TreeEntryChanges> Diffs => _diffs;
public DiffListener(IRepository repository)
{
_repository = repository;
}
/// <summary>
/// Compares the <paramref name="visited"/> commit to it's parents and caches the diffs in <see cref="Diffs"/>.
/// </summary>
/// <param name="visitor">The <see cref="Visitor"/> that raised the <see cref="Visitor.Visited"/> event.</param>
/// <param name="visited">The <see cref="Commit"/> currently being visited.</param>
public void OnCommitVisited(Visitor visitor, Commit visited)
{
foreach (var parent in visited.Parents)
{
var diff = _repository.Diff.Compare<TreeChanges>(parent.Tree, visited.Tree);
foreach (var changed in diff)
{
_diffs.Add(changed);
}
}
}
}
}
Program.cs
Last, but not least, the main program. (And easily the code most in need of some love.)
using System;
using LibGit2Sharp;
using System.Linq;
using System.IO;
using CommandLine;
namespace GitNStats
{
class Program
{
public static int Main(string[] args)
{
return Parser.Default.ParseArguments<Options>(args)
.MapResult(options =>
{
var repoPath = String.IsNullOrWhiteSpace(options.RepositoryPath)
? Directory.GetCurrentDirectory()
: options.RepositoryPath;
return RunAnalysis(repoPath, options.BranchName);
}, _ => 1);
}
private static int RunAnalysis(string repositoryPath, string branchName)
{
try
{
using (var repo = new Repository(repositoryPath))
{
var branch = (branchName == null) ? repo.Head : repo.Branches[branchName];
if (branch == null)
{
WriteError($"Invalid branch: {branchName}");
return 1;
}
Console.WriteLine($"Repository: {repositoryPath}");
Console.WriteLine($"Branch: {branch.FriendlyName}");
Console.WriteLine();
var listener = new DiffListener(repo);
var visitor = new CommitVisitor();
visitor.Visited += listener.OnCommitVisited;
visitor.Walk(branch.Tip);
var changeCounts = listener.Diffs
.GroupBy(c => c.Path)
.Select(x => new {Path = x.Key, Count = x.Count()})
.OrderByDescending(s => s.Count);
Console.WriteLine("Commits\tPath");
foreach (var summary in changeCounts)
{
Console.WriteLine($"{summary.Count}\t{summary.Path}");
}
return 0;
}
}
catch (RepositoryNotFoundException)
{
WriteError($"{repositoryPath} is not a git repository.");
return 1;
}
}
private static void WriteError(string message)
{
var currentColor = Console.ForegroundColor;
Console.ForegroundColor = ConsoleColor.Red;
try
{
Console.Error.WriteLine(message);
}
finally
{
Console.ForegroundColor = currentColor;
}
}
}
}