Developing git stats application using F# and WPF

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

Leave a Reply

Your email address will not be published. Required fields are marked *