A long time ago I implemented some code to walk the git commit graph. I recently updated the accompanying analytics part of the code base to follow file moves/renames in order to get a more accurate count of the number of times a particular file was committed.
Here's the original implementation of the commit count function. It naively stops tracking the number of commits after a file was renamed.(Not for review, just here for historical context.)
public static IEnumerable<PathCount> CountFileChanges(IEnumerable<(Commit, TreeEntryChanges)> diffs) { return diffs .GroupBy<(Commit Commit, TreeEntryChanges Diff), string>(c => c.Diff.Path) .Select(x => new PathCount(x.Key, x.Count())) .OrderByDescending(s => s.Count); }
This is my first implementation that correctly aggregates the commit counts for a file, regardless of renames.
using System;
using System.Collections.Generic;
using System.Linq;
using LibGit2Sharp;
namespace GitNStats.Core
{
public delegate bool DiffFilterPredicate((Commit Commit, TreeEntryChanges TreeEntryChanges) diff);
public static class Analysis
{
public static IEnumerable<PathCount> CountFileChanges(IEnumerable<(Commit, TreeEntryChanges)> diffs)
{
return diffs.Aggregate<(Commit Commit, TreeEntryChanges Diff), Dictionary<string, int>>(
new Dictionary<string, int>(), //filename, count
(acc, x) => {
/* OldPath == NewPath when file was created or removed,
so this it's okay to just always use OldPath */
acc[x.Diff.Path] = acc.GetOrDefault(x.Diff.OldPath, 0) + 1;
if (x.Diff.Status == ChangeKind.Renamed) {
acc.Remove(x.Diff.OldPath);
}
return acc;
}
)
.Select(x => new PathCount(x.Key, x.Value))
.OrderByDescending(s => s.Count);
}
}
static class DictionaryExtensions
{
public static V GetOrDefault<K,V>(this Dictionary<K,V> dictionary, K key, V defaultValue)
{
return dictionary.TryGetValue(key, out V value) ? value : defaultValue;
}
}
}
I was unhappy with this because I'm directly modifying the state of the accumulator in the fold (Aggregate
) operation, so I implemented the immutable version below.
public static IEnumerable<PathCount> CountFileChanges(IEnumerable<(Commit, TreeEntryChanges)> diffs)
{
// Union must take an IEnumerable
IEnumerable<KeyValuePair<K, V>> KeyValuePairEnumerable<K, V>(K key, V value) =>
Enumerable.Repeat(new KeyValuePair<K, V>(key, value), 1);
IEnumerable<KeyValuePair<string, int>> IncrementedPathCount(Dictionary<string, int> pathcounts, string currentPath, string lastPath) =>
KeyValuePairEnumerable(currentPath, pathcounts.GetOrDefault(lastPath, 0) + 1);
bool NotRenamed(KeyValuePair<string, int> kv, TreeEntryChanges diff) =>
diff.Status != ChangeKind.Renamed || (diff.Status == ChangeKind.Renamed && kv.Key != diff.OldPath);
return diffs.Aggregate<(Commit Commit, TreeEntryChanges Diff), Dictionary<string, int>>(
new Dictionary<string, int>(), //filename, count
(acc, x) =>
acc.Where(kv => kv.Key != x.Diff.Path) //All records except the current one
.Union(IncrementedPathCount(acc, x.Diff.Path, x.Diff.OldPath)) //Plus the current one, renamed if applicable
.Where(kv => NotRenamed(kv, x.Diff)) //Strip away obsolete file names
.ToDictionary(kv => kv.Key, kv => kv.Value)
)
.Select(x => new PathCount(x.Key, x.Value))
.OrderByDescending(s => s.Count);
}
But I honestly don't know if the second, more functional implementation is any better. It is now immutable, but I think I might have just muddied the waters and made the code harder to follow. The first version was stateful, but perhaps more simple and easy to follow. Thoughts?
git log --follow | wc -l
for every file in your working tree? That seems like a good way to kill time :) \$\endgroup\$—follow
only works on a single file (according to the docs). My goal is to get the commit count for every file in the repository to identify “hot spots” to target for testing/refactoring. \$\endgroup\$PathCount
that is missing. Could you add this class or tell me where I can find it? \$\endgroup\$