NULL-separate commit fields when gathering 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=%ad%x00%s', @{$options->{cmdline}};
67         die "failed to get revs" unless $fd;
68         while (<$fd>) {
69                 chomp;
70                 my ($date, $sub) = split '\0', $_, 2;
71
72                 push @commits, str2time($date);
73
74         }
75         close $fd;
76
77         @commits = sort @commits;
78
79         print "...done, " . @commits . " commits.\n";
80
81         die "No commits!" if (@commits < 1);
82
83         my $gap = $commits[$#commits] - $commits[0];
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 $key;
103                 if (exists $trunc{$step}) {
104                         # LOL -- no, seriously, is there a smarter way to do this?
105                         $key = str2time(strftime($trunc{$step}, localtime($commit)));
106                 } elsif ($step eq 'weekly') {
107                         # horrible special case: do it daily, and then subtract
108                         # as many days as necessary to go back to the previous sunday
109                         # TODO config week start
110                         $key = str2time(strftime($trunc{daily}, localtime($commit)));
111                         my $wd = strftime("%w", localtime($commit));
112                         $key -= $wd*$steps{daily};
113                 } else {
114                         $key = $commit - ($commit % $step);
115                 }
116                 if (exists $dataset{$key}) {
117                         $dataset{$key} += 1;
118                 } else {
119                         $dataset{$key} = 1;
120                 }
121         }
122
123         # fill missing steps and find max
124         my @keys = sort keys %dataset;
125
126         # ensure numeric step, taking into account
127         # that our numerical steps are approximate
128         my $tolerance = 0;
129         if (exists $steps{$step}) {
130                 $step = $steps{$step};
131                 $tolerance = $step/2;
132         }
133
134         my $max = 0;
135         my $key = shift @keys;
136         while (1) {
137                 $max = $dataset{$key} if $max < $dataset{$key};
138                 my $next_step = $key + $step;
139                 my $next = shift @keys;
140                 last unless $next;
141                 while (abs($next - $next_step) > $tolerance) {
142                         # next step is too far away
143                         $dataset{$next_step} = 0;
144                         $next_step += $step;
145                 }
146                 $key = $next;
147         }
148
149         $options->{max} = $max;
150         return \%dataset;
151 }
152
153 # functions to plot the datasets.
154 # each function can be called with either one or two parameters.
155 # when called with two parameters, the first is assumed to be the dataset, and the second the options
156 # (array and hash ref respectively).
157 # when called with a single parameter, it is assumed to be an options hash ref, and the dataset is 
158 # created by calling gather_data with the passed options.
159
160 # google chart API
161 # TODO needs a lot of customization
162 sub google_chart($;$) {
163         my $dataset = shift;
164         my $options = shift;
165         if (! defined $options) {
166                 $options = $dataset;
167                 $dataset = gather_data($options);
168         }
169
170         my $height=$options->{chart_height};
171         my $max = $options->{max};
172
173         my @keys = sort keys %$dataset;
174         my $from = $keys[0];
175         my $to = $keys[@keys-1];
176
177         my @data;
178         while (my $key = shift @keys) {
179                 push @data, $dataset->{$key};
180         }
181
182         my $width=ceil(4*$height/3);
183
184         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";
185
186         my $launch = sprintf $url, join(",",@data);
187         print $launch, "\n";
188         # `git web--browse "$launch"`
189 }
190
191 # gnuplot
192 sub gnuplot_chart($;$) {
193         my $dataset = shift;
194         my $options = shift;
195         if (! defined $options) {
196                 $options = $dataset;
197                 $dataset = gather_data($options);
198         }
199
200         my @keys = sort keys %$dataset;
201         my $step=$options->{step};
202         my $from = $keys[0];
203         my $to = $keys[$#keys];
204
205         my $data = '';
206         while (my $key = shift @keys) {
207                 $data .= "$key $dataset->{$key}\n";
208         }
209         my $max = $options->{max} + 1;
210
211         # add a fake datapoint lest to prevent the last
212         # datum from becoming invibile with style data steps
213         if (exists $steps{$step}) {
214                 $to += $steps{$step};
215         } else {
216                 $to += $step;
217         }
218         $data .="$to 0\n";
219
220         # TODO allow customization
221         # in particular, detect (lack of) display and set term to dumb accordingly
222         my $termcmd = $options->{gnuplot_term};
223
224         my $plotsetup = $options->{gnuplot_setup};
225         $plotsetup .= "\nset yrange [0:$max]\n";
226         $plotsetup .= "set xrange ['$from':'$to']\n";
227         my ($formatx, $ticks);
228         if ($to - $from > $steps{yearly}) {
229                 $formatx = "%Y";
230                 $ticks = $steps{yearly};
231         } elsif ($to - $from > $steps{monthly}) {
232                 $formatx = "%b\\n%Y";
233                 $ticks = $steps{monthly};
234         } elsif ($to - $from > $steps{weekly}/2) {
235                 $formatx = "%d\\n%b";
236                 $ticks = $steps{daily};
237         } else {
238                 $formatx = "%H\\n%d";
239                 $ticks = $steps{hourly};
240         }
241         $plotsetup .= "set format x \"$formatx\"\n";
242         $plotsetup .= "set xtics $ticks\n";
243         my $plotstyle = $options->{gnuplot_style};
244         my $plotoptions = $options->{gnuplot_plotwith};
245
246         open my $gp, "|gnuplot -persist";
247
248         my $gp_script = <<GPCMD
249 $termcmd
250 set xdata time
251 set timefmt "%s"
252 $plotsetup
253 $plotstyle
254 plot "-" using 1:2 $plotoptions
255 $data
256 GPCMD
257         ;
258
259         print STDOUT $gp_script;
260         print $gp $gp_script;
261         close $gp;
262 }
263
264 # some defaults
265 my %options = (
266         cmdline => [],
267         # charting/plotting options
268         plotter => \&gnuplot_chart,
269         chart_height => 144,
270         gnuplot_term => '',
271         gnuplot_setup => "set nokey",
272         gnuplot_style => 'set style data steps',
273         gnuplot_plotwith => '',
274 );
275
276 sub parse_step(@) {
277         my $key = shift;
278         my $step = shift;
279         if (exists $steps{$key}) {
280                 $options{step} = $key;
281                 return;
282         }
283         die "this can't happen ($key)" unless $key eq 'step';
284
285         if ($step =~/^\d+$/) {
286                 $options{step} = 0 + $step;
287                 return
288         } else {
289                 if (exists $steps{$step}) {
290                         $options{step} = $step;
291                         return;
292                 }
293                 die "unknown step $step";
294         }
295 }
296
297 my $help = 0;
298 # read our options first
299 Getopt::Long::Configure('pass_through');
300 GetOptions(
301         'hourly' => \&parse_step,
302         'daily' => \&parse_step,
303         'weekly' => \&parse_step,
304         'monthly' => \&parse_step,
305         'yearly' => \&parse_step,
306         'step=s' => sub { parse_step(@_) },
307         'chart-height=i' => sub { $options{chart_height} = $_[1]},
308         google => sub { $options{plotter} = \&google_chart },
309         gnuplot => sub { $options{plotter} = \&gnuplot_chart },
310         'help|?' => \$help,
311 );
312
313 if ($help) {
314         usage();
315         exit;
316 }
317
318 # if anything was left, check for log options
319 if (@ARGV) {
320         $options{cmdline} = \@ARGV;
321 }
322
323 $options{plotter}->(\%options);