YY.MM versioning FTW
[git-chart] / git-chart
1 #!/usr/bin/perl
2
3 # git-chart: plot a distribution of commits over time
4 #
5 # Copyright (C) 2011-2017 Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
6 #
7 # Licensed under the Mozilla Public License, version 2.0
8 # Refer to the attached LICENSE file for details.
9
10 # List of general TODO
11 # * default to something else than "the whole history", probably
12 # * if no commits are specified and a commit grouping step is specified,
13 #   limit commits proportionally (e.g. --daily => one week worth of commits)
14 # * allow the steps to be counted from the most recent rather than from the
15 #   oldest commit
16
17 use strict;
18 use warnings;
19 use POSIX qw(ceil strftime);
20 use Getopt::Long;
21 use Date::Parse;
22 use File::Spec;
23 use File::Temp;
24 use List::Util 'max';
25
26 our $VERSION = '17.04';
27
28 my $SECS_PER_DAY = 24*3600;
29
30 my %steps = (
31         hourly => 3600,
32         daily => $SECS_PER_DAY,
33         weekly => 7*$SECS_PER_DAY,
34         monthly => 30*$SECS_PER_DAY,
35         yearly => 12*30*$SECS_PER_DAY,
36 );
37
38 my %trunc = (
39         hourly => "%Y-%m-%d %H:00",
40         daily => "%Y-%m-%d 00:00",
41         monthly => "%Y-%m-01 00:00",
42         yearly => "%Y-01-01 00:00",
43 );
44
45 sub usage() {
46         # TODO
47         print <<EOU
48 Usage: git chart [options...] [log specs...]
49
50 Creates a chart plotting the distribution over time of the commits
51 specified as <log specs>. By default, commits are grouped by hour, day,
52 week, month or year depending on the time spanned, but this can be
53 controlled by the user.
54
55 Options:
56         --hourly, --daily, --weekly, --monthly, --yearly:
57                 force a specific commit grouping
58         --step=<integer>:
59                 force commits to be grouped each <integer> seconds
60         --gnuplot:
61                 produce a chart with gnuplot (default)
62         --google:
63                 produce a chart with Google Charts
64         --chart-height=<integer>:
65                 set the Google Charts height; the width is set
66                 to a 4:3 ratio (default: 100)
67 EOU
68
69 }
70
71 sub gather_data($) {
72         my $options = shift;
73
74         print "Gathering data ...\n";
75
76         my @commits;
77
78         open my $fd, '-|', qw(git log --date=local), '--pretty=%aN%x00%ad%x00%s', @{$options->{cmdline}};
79         die "failed to get revs" unless $fd;
80         while (<$fd>) {
81                 chomp;
82                 my ($author, $date, $sub) = split '\0', $_, 3;
83
84                 push @commits, { date => str2time($date), author => $author };
85
86         }
87         close $fd;
88
89         @commits = sort { $a->{date} cmp $b->{date} } @commits;
90
91         print "...done, " . @commits . " commits.\n";
92
93         die "No commits!" if (@commits < 1);
94
95         my $gap = $commits[$#commits]->{date} - $commits[0]->{date};
96
97         my $step;
98         if (defined $options->{step}) {
99                 $step = $options->{step};
100         } else {
101                 $step = $gap > $steps{yearly} ? 'monthly' :
102                         $gap > $steps{monthly} ? 'weekly' :
103                         $gap > $steps{weekly}/2 ? 'daily' :
104                         'hourly'
105                 ;
106                 $options->{step} = $step;
107         }
108
109         # truncate each commit (date) to the wanted step
110         # e.g. if the step is 'monthly', then commit YYYY/MM/DD
111         # maps to YYYY/MM
112         my %dataset;
113         for my $commit (@commits) {
114                 my $date = $commit->{date};
115                 my $author = $commit->{author};
116                 my $key;
117                 if (exists $trunc{$step}) {
118                         # LOL -- no, seriously, is there a smarter way to do this?
119                         $key = str2time(strftime($trunc{$step}, localtime($date)));
120                 } elsif ($step eq 'weekly') {
121                         # horrible special case: do it daily, and then subtract
122                         # as many days as necessary to go back to the previous sunday
123                         # TODO config week start
124                         $key = str2time(strftime($trunc{daily}, localtime($date)));
125                         my $wd = strftime("%w", localtime($date));
126                         $key -= $wd*$steps{daily};
127                 } else {
128                         $key = $date - ($date % $step);
129                 }
130
131                 # make sure the value is a hash
132                 $dataset{$key} = {_commits => 0} unless exists $dataset{$key};
133                 $dataset{_authors} = { $author => 0} unless exists $dataset{_authors};
134
135                 $dataset{$key}{_commits} += 1;
136                 $dataset{$key}{$author} += 1;
137                 $dataset{_authors}{$author} += 1;
138         }
139
140         # fill missing steps and find max
141         my @keys = sort keys %dataset;
142         pop @keys; # get rid of _authors
143
144         # ensure numeric step, taking into account
145         # that our numerical steps are approximate
146         my $tolerance = 0;
147         if (exists $steps{$step}) {
148                 $step = $steps{$step};
149                 $tolerance = $step/2;
150         }
151
152         my $max = 0;
153         my $key = shift @keys;
154         while (1) {
155                 $max = $dataset{$key}{_commits} if $max < $dataset{$key}{_commits};
156                 my $next_step = $key + $step;
157                 my $next = shift @keys;
158                 last unless $next;
159                 while (abs($next - $next_step) > $tolerance) {
160                         # next step is too far away
161                         $dataset{$next_step} = {_commits => 0};
162                         $next_step += $step;
163                 }
164                 $key = $next;
165         }
166
167         $options->{max} = $max;
168         return \%dataset;
169 }
170
171 # functions to plot the datasets.
172 # each function can be called with either one or two parameters.
173 # when called with two parameters, the first is assumed to be the dataset, and the second the options
174 # (array and hash ref respectively).
175 # when called with a single parameter, it is assumed to be an options hash ref, and the dataset is 
176 # created by calling gather_data with the passed options.
177
178 # google chart API
179 # TODO needs a lot of customization
180 sub google_chart($;$) {
181         my $dataset = shift;
182         my $options = shift;
183         if (! defined $options) {
184                 $options = $dataset;
185                 $dataset = gather_data($options);
186         }
187
188         my $height=$options->{chart_height};
189         my $max = $options->{max};
190
191         my @keys = sort keys %$dataset;
192         pop @keys; # get rid of _authors
193
194         my $from = $keys[0];
195         my $to = $keys[@keys-1];
196
197         my @data;
198         while (my $key = shift @keys) {
199                 push @data, $dataset->{$key}{_commits};
200         }
201
202         my $width=ceil(4*$height/3);
203
204         my $url="https://chart.googleapis.com/chart?chs=${width}x${height}&cht=bvg&chd=t:%s&chds=0,$max&chbh=a&chxt=y&chxr=0,0,$max";
205
206         my $launch = sprintf $url, join(",",@data);
207         print $launch, "\n";
208         # `git web--browse "$launch"`
209 }
210
211 # gnuplot
212 sub gnuplot_chart($;$) {
213         my $dataset = shift;
214         my $options = shift;
215         if (! defined $options) {
216                 $options = $dataset;
217                 $dataset = gather_data($options);
218         }
219
220         my $authors = delete $dataset->{_authors};
221         my @authors = sort { $authors->{$b} <=> $authors->{$a} } keys %$authors;
222         $authors = join "\t", map  { "\"$_\"" } @authors;
223
224         my @keys = sort keys %$dataset;
225
226         my $step=$options->{step};
227         my $from = $keys[0];
228         my $to = $keys[$#keys];
229
230         my $data = "date\tcommits\t$authors\n";
231         while (my $key = shift @keys) {
232                 $data .= "$key\t$dataset->{$key}{_commits}";
233                 foreach my $author (@authors) {
234                         if (exists $dataset->{$key}{$author}) {
235                                 $data .= "\t$dataset->{$key}{$author}";
236                         } else {
237                                 $data .= "\t0";
238                         }
239                 }
240                 $data .= "\n";
241         }
242         my $max = $options->{max} + 1;
243
244         # add a fake datapoint lest to prevent the last
245         # datum from becoming invibile with style data steps
246         if (exists $steps{$step}) {
247                 $to += $steps{$step};
248         } else {
249                 $to += $step;
250         }
251         $data .="$to\t0";
252         foreach my $author (@authors) {
253                 $data .= "\t0";
254         }
255         $data .= "\n";
256
257         my ($formatx, $ticks);
258         if ($to - $from > $steps{yearly}) {
259                 $formatx = "%Y";
260                 $ticks = $steps{yearly};
261         } elsif ($to - $from > $steps{monthly}) {
262                 $formatx = "%b\\n%Y";
263                 $ticks = $steps{monthly};
264         } elsif ($to - $from > $steps{weekly}/2) {
265                 $formatx = "%d\\n%b";
266                 $ticks = $steps{daily};
267         } else {
268                 $formatx = "%H\\n%d";
269                 $ticks = $steps{hourly};
270         }
271
272         my $nticks = ceil(($to - $from)/$ticks);
273
274         # Default terminal sizing. Rationale:
275         # * authors are limited to 8 rows, so the resulting number of columns
276         #   should fit within some scaled 800 pixels. 
277         #   and assuming that each colum takes about 600 scaled pixels
278         my $sizemul = max(1, ceil(ceil(3*@authors/8)/4));
279         print "# $sizemul \n";
280         $sizemul = max($sizemul, $nticks/8);
281         print "# $sizemul \n";
282         my $xres = 800*$sizemul;
283         my $yres = 450*$sizemul;
284
285         # TODO allow customization
286         # in particular, detect (lack of) display and set term to dumb accordingly
287         my $termcmd = $options->{gnuplot_term} ||
288                 "set terminal GNUTERM enhanced size $xres,$yres\n";
289
290         my $plotsetup = $options->{gnuplot_setup} ||
291                 @authors == 1 ? 'set nokey' :
292                 'set key above autotitle columnheader maxrows 8' ;
293         $plotsetup .= "\nset yrange [0:$max]\n";
294         $plotsetup .= "set xrange ['$from':'$to']\n";
295         $plotsetup .= "set format x \"$formatx\"\n";
296         $plotsetup .= "set xtics axis out nomirror $ticks\n";
297         $plotsetup .= "set ytics axis out nomirror\n";
298         my $plotstyle = $options->{gnuplot_style};
299         my $plotoptions = $options->{gnuplot_plotwith};
300
301         my $datafile = File::Temp->new(
302                 TEMPLATE => 'git-chart-XXXXXX',
303                 DIR => File::Spec->tmpdir()
304         );
305
306         print $datafile $data;
307
308         open my $gp, "|gnuplot -persist";
309
310         my $plotcmd = "plot '$datafile' using 1:2";
311         if (@authors > 1) {
312                 my $lastcolumn = $#authors + 3;
313                 $plotcmd.= " notitle";
314                 $plotcmd.= ", for [a=3:$lastcolumn] '' using 1:a";
315         }
316
317         my $gp_script = <<GPCMD
318 $termcmd
319 set xdata time
320 set timefmt "%s"
321 $plotsetup
322 $plotstyle
323 $plotcmd
324 GPCMD
325         ;
326
327         print STDOUT $gp_script;
328         print $gp $gp_script;
329         close $gp;
330 }
331
332 # some defaults
333 my %options = (
334         cmdline => [],
335         # charting/plotting options
336         plotter => \&gnuplot_chart,
337         chart_height => 144,
338         gnuplot_term => '',
339         gnuplot_setup => '',
340         gnuplot_style => 'set style data steps',
341         gnuplot_plotwith => '',
342 );
343
344 sub parse_step(@) {
345         my $key = shift;
346         my $step = shift;
347         if (exists $steps{$key}) {
348                 $options{step} = $key;
349                 return;
350         }
351         die "this can't happen ($key)" unless $key eq 'step';
352
353         if ($step =~/^\d+$/) {
354                 $options{step} = 0 + $step;
355                 return
356         } else {
357                 if (exists $steps{$step}) {
358                         $options{step} = $step;
359                         return;
360                 }
361                 die "unknown step $step";
362         }
363 }
364
365 my $help = 0;
366 # read our options first
367 Getopt::Long::Configure('pass_through');
368 GetOptions(
369         'hourly' => \&parse_step,
370         'daily' => \&parse_step,
371         'weekly' => \&parse_step,
372         'monthly' => \&parse_step,
373         'yearly' => \&parse_step,
374         'step=s' => sub { parse_step(@_) },
375         'chart-height=i' => sub { $options{chart_height} = $_[1]},
376         google => sub { $options{plotter} = \&google_chart },
377         gnuplot => sub { $options{plotter} = \&gnuplot_chart },
378         'help|?' => \$help,
379 );
380
381 if ($help) {
382         usage();
383         exit;
384 }
385
386 # if anything was left, check for log options
387 if (@ARGV) {
388         $options{cmdline} = \@ARGV;
389 }
390
391 $options{plotter}->(\%options);