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