Dynamic step adjustement
[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
8 use strict;
9 use warnings;
10 use POSIX qw(ceil strftime);
11 use Getopt::Long;
12 use Date::Parse;
13
14 my $SECS_PER_DAY = 24*3600;
15
16 my %steps = (
17         hourly => 3600,
18         daily => $SECS_PER_DAY,
19         weekly => 7*$SECS_PER_DAY,
20         monthly => 30*$SECS_PER_DAY,
21         yearly => 12*30*$SECS_PER_DAY,
22 );
23
24 my %trunc = (
25         hourly => "%Y-%m-%d %H:00",
26         daily => "%Y-%m-%d 00:00",
27         monthly => "%Y-%m-01 00:00",
28         yearly => "%Y-01-01 00:00",
29 );
30
31 sub usage() {
32         # TODO
33         print "Usage: git chart\n";
34 }
35
36 sub gather_data($) {
37         my $options = shift;
38
39         print "Gathering data ...\n";
40
41         my @commits;
42
43         open my $fd, '-|', qw(git log --date=local), '--pretty=%ad,%s', @{$options->{cmdline}};
44         die "failed to get revs" unless $fd;
45         while (<$fd>) {
46                 chomp;
47                 my ($date, $sub) = split ',', $_, 2;
48
49                 push @commits, str2time($date);
50
51         }
52         close $fd;
53
54         @commits = sort @commits;
55
56         print "...done, " . @commits . " commits.\n";
57
58         my $gap = $commits[$#commits] - $commits[0];
59
60         my $step;
61         if (defined $options->{step}) {
62                 $step = $options->{step};
63         } else {
64                 $step = $gap > $steps{yearly} ? 'monthly' :
65                         $gap > $steps{monthly} ? 'weekly' :
66                         $gap > $steps{weekly}/2 ? 'daily' :
67                         'hourly'
68                 ;
69                 $options->{step} = $step;
70         }
71
72         # truncate each commit (date) to the wanted step
73         # e.g. if the step is 'monthly', then commit YYYY/MM/DD
74         # maps to YYYY/MM
75         my %dataset;
76         for my $commit (@commits) {
77                 my $key;
78                 if (exists $trunc{$step}) {
79                         # LOL -- no, seriously, is there a smarter way to do this?
80                         $key = str2time(strftime($trunc{$step}, localtime($commit)));
81                 } else {
82                         $key = $commit - ($commit % $step);
83                 }
84                 if (exists $dataset{$key}) {
85                         $dataset{$key} += 1;
86                 } else {
87                         $dataset{$key} = 1;
88                 }
89         }
90
91         # fill missing steps and find max
92         my @keys = sort keys %dataset;
93
94         # ensure numeric step, taking into account
95         # that our numerical steps are approximate
96         my $tolerance = 0;
97         if (exists $steps{$step}) {
98                 $step = $steps{$step};
99                 $tolerance = $step/2;
100         }
101
102         my $max = 0;
103         my $key = shift @keys;
104         while (1) {
105                 $max = $dataset{$key} if $max < $dataset{$key};
106                 my $next_step = $key + $step;
107                 my $next = shift @keys;
108                 last unless $next;
109                 while (abs($next - $next_step) > $tolerance) {
110                         # next step is too far away
111                         $dataset{$next_step} = 0;
112                         $next_step += $step;
113                 }
114                 $key = $next;
115         }
116
117         $options->{max} = $max;
118         return \%dataset;
119 }
120
121 # functions to plot the datasets.
122 # each function can be called with either one or two parameters.
123 # when called with two parameters, the first is assumed to be the dataset, and the second the options
124 # (array and hash ref respectively).
125 # when called with a single parameter, it is assumed to be an options hash ref, and the dataset is 
126 # created by calling gather_data with the passed options.
127
128 # google chart API
129 # TODO needs a lot of customization
130 sub google_chart($;$) {
131         my $dataset = shift;
132         my $options = shift;
133         if (! defined $options) {
134                 $options = $dataset;
135                 $dataset = gather_data($options);
136         }
137
138         my $height=$options->{chart_height};
139         my $max = $options->{max};
140
141         my @keys = sort keys %$dataset;
142         my $from = $keys[0];
143         my $to = $keys[@keys-1];
144
145         my @data;
146         while (my $key = shift @keys) {
147                 push @data, $dataset->{$key};
148         }
149
150         my $width=ceil(4*$height/3);
151
152         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";
153
154         my $launch = sprintf $url, join(",",@data);
155         print $launch, "\n";
156         # `git web--browse "$launch"`
157 }
158
159 # gnuplot
160 sub gnuplot_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 @keys = sort keys %$dataset;
169         my $step=$options->{step};
170         my $from = $keys[0];
171         my $to = $keys[@keys-1];
172         my $data = '';
173         while (my $key = shift @keys) {
174                 $data .= "$key $dataset->{$key}\n";
175         }
176         my $max = $options->{max};
177
178         # TODO allow customization
179         # in particular, detect (lack of) display and set term to dumb accordingly
180         my $termcmd = $options->{gnuplot_term};
181
182         my $plotsetup = $options->{gnuplot_setup};
183         $plotsetup .= "\nset yrange [0:$max]\n";
184         $plotsetup .= "set xrange ['$from':'$to']\n";
185         my ($formatx, $ticks);
186         if ($to - $from > $steps{yearly}) {
187                 $formatx = "%Y";
188                 $ticks = $steps{yearly};
189         } elsif ($to - $from > $steps{monthly}) {
190                 $formatx = "%b\\n%Y";
191                 $ticks = $steps{monthly};
192         } elsif ($to - $from > 2*$steps{daily}) {
193                 $formatx = "%d\\n%b";
194                 $ticks = $steps{daily};
195         } else {
196                 $formatx = "%H:%M";
197                 $ticks = $steps{hourly};
198         }
199         $plotsetup .= "set format x \"$formatx\"\n";
200         $plotsetup .= "set xtics $ticks\n";
201         my $plotstyle = $options->{gnuplot_style};
202         my $plotoptions = $options->{gnuplot_plotwith};
203
204         open my $gp, "|gnuplot -persist";
205
206         my $gp_script = <<GPCMD
207 $termcmd
208 set xdata time
209 set timefmt "%s"
210 $plotsetup
211 $plotstyle
212 plot "-" using 1:2 $plotoptions
213 $data
214 GPCMD
215         ;
216
217         print STDOUT $gp_script;
218         print $gp $gp_script;
219         close $gp;
220 }
221
222 # some defaults
223 my %options = (
224         cmdline => [],
225         # charting/plotting options
226         plotter => \&gnuplot_chart,
227         chart_height => 144,
228         gnuplot_term => '',
229         gnuplot_setup => "set nokey",
230         gnuplot_style => 'set style data histeps',
231         gnuplot_plotwith => '',
232 );
233
234 sub parse_step(@) {
235         my $key = shift;
236         my $step = shift;
237         if (exists $steps{$key}) {
238                 $options{step} = $key;
239                 return;
240         }
241         die "this can't happen ($key)" unless $key eq 'step';
242
243         if ($step =~/^\d+$/) {
244                 $options{step} = 0 + $step;
245                 return
246         } else {
247                 if (exists $steps{$step}) {
248                         $options{step} = $step;
249                         return;
250                 }
251                 die "unknown step $step";
252         }
253 }
254
255 # read our options first
256 Getopt::Long::Configure('pass_through');
257 GetOptions(
258         'hourly' => \&parse_step,
259         'daily' => \&parse_step,
260         'weekly' => \&parse_step,
261         'monthly' => \&parse_step,
262         'yearly' => \&parse_step,
263         'step=s' => sub { parse_step(@_) },
264         'chart-height=i' => sub { $options{chart_height} = $_[1]},
265         google => sub { $options{plotter} = \&google_chart },
266         gnuplot => sub { $options{plotter} = \&gnuplot_chart },
267 );
268
269 # if anything was left, check for log options
270 if (@ARGV) {
271         $options{cmdline} = \@ARGV;
272 }
273
274 die "step must be strictly positive!" if defined $options{step} and not $options{step} > 0;
275
276 $options{plotter}->(\%options);