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