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