Developing git stats application using F# and WPF
Some time ago mostly by curiosity I wanted to check out git statistics of our work repo. Quick search in google didn’t show anything quick and easy, so I thought about developing such an app by myself and learn a bit of F# for bonus.
In solution I have two projects – GitStats and Gitstats.Domain. GitStats is a WPF application and WPF + F# = mostly experimental stuff in my opinion, so I decided to go with proven solution and write XAML backend code in C#. Also the application itself is quite easy, doesn’t have a lot of features or user interactions and that’s why I’ve chosen not to use any MVVM frameworks and stick to old fashioned event-handler approach, although you can find data binding in few places.
On XAML part I use MahApps which is really easy to add to your project and make application look a little prettier, but it is of course a matter of opinion 🙂 To display data in charts I use WPFToolkit library. And that is pretty much all if we are talking about xaml, everything else should be familiar if you developed something in WPF before.
Frontend code in XAML – MainWindow.xaml:
<mah:MetroWindow x:Class="GitStats.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mah="http://metro.mahapps.com/winfx/xaml/controls" xmlns:chart="clr-namespace:System.Windows.Controls.DataVisualization.Charting;assembly=System.Windows.Controls.DataVisualization.Toolkit" xmlns:vis="clr-namespace:System.Windows.Controls.DataVisualization;assembly=System.Windows.Controls.DataVisualization.Toolkit" mc:Ignorable="d" Name="_this" Title="GitStats" Height="500" Width="1000" BorderThickness="1" BorderBrush="LightBlue"> <Grid Margin="3"> <Grid.RowDefinitions> <RowDefinition Height="40"/> <RowDefinition Height="40"/> <RowDefinition Height="*"/> </Grid.RowDefinitions> <StackPanel Orientation="Horizontal" Grid.Row="0"> <TextBlock Text="Path to repository" Margin="3" VerticalAlignment="Center"/> <ComboBox Margin="3" Width="240" Height="23" IsEditable="True" ItemsSource="{Binding SavedPaths}" Text="{Binding PathToRepo, UpdateSourceTrigger=PropertyChanged}"/> <Button Name="bOpenFolderPicker" FontWeight="Bold" Content="..." Width="35" Height="23" Margin="0,0,3,0" Click="BOpenFolderPicker_OnClick"/> <Button Name="bLoadRepo" Content="Load" Width="120" Height="23" Click="bLoadRepo_Click"/> </StackPanel> <StackPanel Grid.Row="1" Orientation="Horizontal"> <TextBlock Text="Commits count - "/> <TextBlock Text="{Binding Stats.CommitsCount}" Margin="0,0,3,0"/> <TextBlock Text="Days from last commit - "/> <TextBlock Text="{Binding Stats.DaysFromLastCommit}" Margin="0,0,3,0"/> <TextBlock Text="Commits per day - "/> <TextBlock Text="{Binding Stats.CommitsPerDay, StringFormat={}{0:F2}}" Margin="0,0,3,0"/> <TextBlock Margin="0,0,3,0"> <Run Text="Branches count - " /> <Run Text="{Binding Stats.BranchesCount, Mode=OneWay}" /> </TextBlock> <TextBlock Margin="0,0,3,0"> <Run Text="Tags count - " /> <Run Text="{Binding Stats.TagsCount, Mode=OneWay}" /> </TextBlock> </StackPanel> <TabControl Grid.Row="2"> <TabItem Header="By author"> <DataGrid ItemsSource="{Binding Stats.AuthorStats}" AutoGenerateColumns="False"> <DataGrid.Columns> <DataGridTextColumn Header="Name" Width="Auto" Binding="{Binding Name}" /> <DataGridTextColumn Header="Commits count" Width="125" Binding="{Binding CommitsCount}" /> <DataGridTextColumn Header="Commits per day" Width="130" Binding="{Binding CommitsPerDay, StringFormat={}{0:F2}}" /> <DataGridTextColumn Header="Files modifications" Width="145" Binding="{Binding FilesModifications}" /> <DataGridTextColumn Header="Added files" Width="100" Binding="{Binding AddedFiles}" /> <DataGridTextColumn Header="Removed files" Width="110" Binding="{Binding RemovedFiles}" /> <DataGridTextColumn Header="Renamed files" Width="110" Binding="{Binding RenamedFiles}" /> </DataGrid.Columns> </DataGrid> </TabItem> <TabItem Header="By files"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="200"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <StackPanel Orientation="Vertical" Grid.Column="0"> <TextBlock> <Run Text="Files - " /> <Run Text="{Binding Stats.DirectoryStats.FilesCount, Mode=OneWay}" /> </TextBlock> <TextBlock> <Run Text="Directories - " /> <Run Text="{Binding Stats.DirectoryStats.DirectoriesCount, Mode=OneWay}" /> </TextBlock> </StackPanel> <chart:Chart Grid.Column="1" BorderThickness="0" Margin="0,-30,0,0"> <chart:PieSeries ItemsSource="{Binding Stats.DirectoryStats.ExtensionStats}" IndependentValuePath="Extension" DependentValuePath="Count" IsSelectionEnabled="True"/> </chart:Chart> </Grid> </TabItem> <TabItem Header="By date"> <chart:Chart BorderThickness="0" Margin="0,-30,0,0"> <chart:Chart.LegendStyle> <Style TargetType="vis:Legend"> <Setter Property="Width" Value="0" /> </Style> </chart:Chart.LegendStyle> <chart:LineSeries ItemsSource="{Binding Stats.DateStats}" IndependentValuePath="Date" DependentValuePath="Count" IsSelectionEnabled="True"/> </chart:Chart> </TabItem> <TabItem Header="By day of week"> <chart:Chart BorderThickness="0" Margin="0,-30,0,0"> <chart:Chart.LegendStyle> <Style TargetType="vis:Legend"> <Setter Property="Width" Value="0" /> </Style> </chart:Chart.LegendStyle> <chart:ColumnSeries ItemsSource="{Binding Stats.DayOfWeekStats}" IndependentValuePath="Day" DependentValuePath="Count" IsSelectionEnabled="True"/> </chart:Chart> </TabItem> <TabItem Header="By hour"> <chart:Chart BorderThickness="0" Margin="0,-30,0,0"> <chart:Chart.LegendStyle> <Style TargetType="vis:Legend"> <Setter Property="Width" Value="0" /> </Style> </chart:Chart.LegendStyle> <chart:ColumnSeries ItemsSource="{Binding Stats.HourStats}" IndependentValuePath="Hour" DependentValuePath="Count" IsSelectionEnabled="True"/> </chart:Chart> </TabItem> </TabControl> </Grid> </mah:MetroWindow>
Backend code is also really simple and consist of few event handlers and two additional notify properties to reload data. Most important is bLoadRepo_Click, which loads repository data from path provided before by user and reloads DataContext manually (I know it’s ugly, but as I mention before – simplicity in small application) to display statistics to user. If path to repository is correct it’s also saved using .Net Settings class in HandleSavedPaths method. If any errors occurs during computing statistics the error message is displayed.
Backend code in C# – MainWindow.xaml.cs:
using System; using System.Collections.Specialized; using System.ComponentModel; using System.Windows; using GitStats.Properties; using Forms = System.Windows.Forms; namespace GitStats { /// <summary> /// Interaction logic for MainWindow.xaml /// </summary> public partial class MainWindow : INotifyPropertyChanged { #region Prop changed public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); #endregion #region Notify props private string _pathToRepo; public string PathToRepo { get { return _pathToRepo; } set { _pathToRepo = value; OnPropertyChanged(nameof(PathToRepo)); } } private StringCollection _savedPaths; public StringCollection SavedPaths { get { return _savedPaths; } set { _savedPaths = value; OnPropertyChanged(nameof(SavedPaths)); } } #endregion public Types.RepoStats Stats { get; set; } public MainWindow() { InitializeComponent(); SavedPaths = Settings.Default.SavedPaths; DataContext = this; } private void bLoadRepo_Click(object sender, RoutedEventArgs e) { try { Stats = Logic.getRepoStats(PathToRepo); HandleSavedPaths(); } catch (Exception ex) { MessageBox.Show("Could not load repository from given path"); } ManuallyRefreshDataContext(); } private void HandleSavedPaths() { if (Settings.Default.SavedPaths.Contains(PathToRepo)) return; Settings.Default.SavedPaths.Add(PathToRepo); Settings.Default.Save(); SavedPaths = Settings.Default.SavedPaths; } private void ManuallyRefreshDataContext() { DataContext = null; DataContext = this; } private void BOpenFolderPicker_OnClick(object sender, RoutedEventArgs e) { var folderDialog = new Forms.FolderBrowserDialog(); var result = folderDialog.ShowDialog(); if (result == Forms.DialogResult.OK) PathToRepo = folderDialog.SelectedPath; } } }
GitStats.Domain is a F# project that uses LibGit2Sharp library to connect to git. Source code is split into two files Types.fs and Logic.fs
Types.fs as you may imagine contains all types used to compute and transfer data:
module Types open System type AuthorStats = { Name : string FilesModifications : int AddedFiles : int RemovedFiles : int RenamedFiles : int CommitsCount : int CommitsPerDay : double } type DateStats = { Date : DateTime Count : int } type DayOfWeekStats = { Day : DayOfWeek Count : int } type HourStats = { Hour : int Count : int } type ExtensionStats = { Extension : string Count : int } type DirectoryStats = { FilesCount : int DirectoriesCount : int ExtensionStats : ExtensionStats seq } type RepoStats = { CommitsCount : int CommitsPerDay : double BranchesCount : int TagsCount : int DaysFromLastCommit : int AuthorStats : AuthorStats seq DateStats : DateStats seq DayOfWeekStats : DayOfWeekStats seq HourStats : HourStats seq DirectoryStats : DirectoryStats }
Logic.fs contains all logic for computing git repository statistics. Initial step is to connect to repository and get all the commits, which is done in getCommits function. Application computes all sorts of statistics – commits and file modifications by author, files count and distribution, commits by date, day of week and by hour. All this statistics are computed in their respective functions and gather together in getRepoStats function which is main entry point, called by C# backend code.
Logic.fs:
module Logic open LibGit2Sharp open System open System.IO open Types let getCommits pathToRepo = let repo = new Repository(pathToRepo) let filter = new CommitFilter() repo.Commits.QueryBy filter, repo let getDateStats (commits : Commit seq) = commits |> Seq.groupBy (fun x -> x.Author.When.LocalDateTime.Date) |> Seq.map (fun (date, commits) -> { Date = date Count = (Seq.length commits) }) let addMissingDaysOfWeek (dayStats : DayOfWeekStats seq) = Enum.GetValues(typeof<DayOfWeek>) |> Seq.cast<DayOfWeek> |> Seq.map (fun x -> let validStat = Seq.tryFind (fun y -> y.Day = x) dayStats match validStat with | Some stat -> { Day = x Count = stat.Count } | None -> { Day = x Count = 0 }) let getDayOfWeekStats (commits : Commit seq) = commits |> Seq.groupBy (fun x -> x.Author.When.LocalDateTime.Date.DayOfWeek) |> Seq.map (fun (day, commits) -> { Day = day Count = (Seq.length commits) }) |> addMissingDaysOfWeek |> Seq.sortBy (fun x -> ((int x.Day) + 6) % 7) let addMissingHours (hourStats : HourStats seq) = [ 0..23 ] |> Seq.map (fun x -> let validStat = Seq.tryFind (fun y -> y.Hour = x) hourStats match validStat with | Some stat -> { Hour = x Count = stat.Count } | None -> { Hour = x Count = 0 }) let getHourStats (commits : Commit seq) = commits |> Seq.groupBy (fun x -> x.Author.When.LocalDateTime.Hour) |> Seq.map (fun (hour, commits) -> { Hour = hour Count = (Seq.length commits) }) |> addMissingHours |> Seq.sortBy (fun x -> x.Hour) let getDaysFromLastCommit (commits : Commit seq) = let lastCommit = commits |> Seq.map (fun x -> x.Author.When.LocalDateTime.Date) |> Seq.max int (DateTime.Today.Subtract lastCommit).TotalDays let getRepoDaysSpan (commits : Commit seq) = let firstCommit = commits |> Seq.map (fun x -> x.Author.When.LocalDateTime.Date) |> Seq.min let lastCommit = commits |> Seq.map (fun x -> x.Author.When.LocalDateTime.Date) |> Seq.max (lastCommit - firstCommit).TotalDays let getCommitsPerDay daysSpan (commits : Commit seq) = (double (Seq.length commits)) / daysSpan let getRepoTreeForDiff (commits : Commit seq) = commits |> Seq.pairwise |> Seq.rev |> Seq.append [ Seq.last commits, null ] let getCommitDiff (repo : Repository) (toDiff : Commit * Commit) = let newCommit = fst toDiff let oldTree = if snd toDiff = null then null else (snd toDiff).Tree let diff = repo.Diff.Compare<TreeChanges>(oldTree, newCommit.Tree) newCommit, diff let computeAuthorStats daysSpan (commits : (Commit * TreeChanges) seq) = commits |> Seq.groupBy (fun x -> (fst x).Author.Name) |> Seq.map (fun (name, commitAndChange) -> (name, Seq.map snd commitAndChange)) |> Seq.map (fun (name, changes) -> { Name = name CommitsCount = (Seq.length changes) FilesModifications = changes |> Seq.sumBy (fun x -> Seq.length x.Modified) AddedFiles = changes |> Seq.sumBy (fun x -> Seq.length x.Added) RemovedFiles = changes |> Seq.sumBy (fun x -> Seq.length x.Deleted) RenamedFiles = changes |> Seq.sumBy (fun x -> Seq.length x.Renamed) CommitsPerDay = double (Seq.length changes) / double daysSpan }) |> Seq.sortBy (fun x -> x.Name) let computeExtensionStats (files : FileInfo seq) = files |> Seq.countBy (fun x -> x.Extension) |> Seq.map (fun x -> { Extension = fst x Count = snd x }) |> Seq.sortByDescending (fun x -> x.Count) let getDirectoryStats pathToRepo = let files = Directory.EnumerateFiles(pathToRepo, "*.*", SearchOption.AllDirectories) |> Seq.map (fun x -> new FileInfo(x)) |> Seq.filter (fun x -> not (x.Attributes.HasFlag FileAttributes.Hidden)) |> Seq.filter (fun x -> not (x.Extension = "")) let directories = Directory.EnumerateDirectories(pathToRepo, "*.*", SearchOption.AllDirectories) |> Seq.map (fun x -> new DirectoryInfo(x)) |> Seq.filter (fun x -> not (x.Attributes.HasFlag FileAttributes.Hidden)) { FilesCount = files |> Seq.length DirectoriesCount = directories |> Seq.length ExtensionStats = files |> computeExtensionStats } let getBranchesCount (repo : Repository) = Seq.length repo.Branches let getTagsCount (repo : Repository) = Seq.length repo.Tags let getRepoStats pathToRepo = let commits, repo = getCommits pathToRepo let daysSpan = getRepoDaysSpan commits let getAuthorStats = getRepoTreeForDiff >> Seq.map (getCommitDiff repo) >> computeAuthorStats daysSpan { CommitsCount = Seq.length commits CommitsPerDay = getCommitsPerDay daysSpan commits BranchesCount = getBranchesCount repo TagsCount = getTagsCount repo DaysFromLastCommit = getDaysFromLastCommit commits AuthorStats = getAuthorStats commits DateStats = getDateStats commits DayOfWeekStats = getDayOfWeekStats commits HourStats = getHourStats commits DirectoryStats = getDirectoryStats pathToRepo }
There is still some to be done here, some additional statistics that could be computed, but for now I hope you like this article. You can download working apllication from here or checkout out whole source code on github.
Edit – Added some screenshots:
2 thoughts on “Developing git stats application using F# and WPF”
Any screen?
Added some screens, sorry it took so long, I’ve had a busy week