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