some TODOs
[git-chart] / git-chart
1 #!/usr/bin/perl
2
3 # List of general TODO
4 # * autodetect optimal frequency depending on date range for commits
5 # * default to something else than "the whole history", probably
6 # * if no commits are specified and a commit grouping step is specified,
7 #   limit commits proportionally (e.g. --daily => one week worth of commits)
8 # * find a better way to group commits, the current is a little too inaccurate
9 #
10 # Most of the mentioned TODOs require either some peeking at the commits
11 # or some other form of pre/post-processing. For both it's probably better
12 # to just slurp them at once and then think about the grouping
13
14 use strict;
15 use warnings;
16 use POSIX qw(ceil);
17 use Getopt::Long;
18 use Date::Parse;
19
20 my $SECS_PER_DAY = 24*3600;
21
22 my %steps = (
23         hourly => 3600,
24         daily => $SECS_PER_DAY,
25         weekly => 7*$SECS_PER_DAY,
26         monthly => 30*$SECS_PER_DAY,
27         yearly => 12*30*$SECS_PER_DAY,
28 );
29
30 sub usage() {
31         # TODO
32         print "Usage: git chart\n";
33 }
34
35 sub gather_data($) {
36         my $options = shift;
37         my %dataset;
38
39         print "Gathering data ...\n";
40
41         my $groups = 0;
42         my $commits = 0;
43
44         my $step=$options->{step};
45
46         open my $fd, '-|', qw(git log --date=raw), '--pretty=%ad %s', @{$options->{cmdline}};
47         die "failed to get revs" unless $fd;
48         while (<$fd>) {
49                 chomp;
50                 $commits += 1;
51                 my ($date, $tz, $sub) = split ' ', $_, 3;
52                 # TODO use $tz
53
54                 my $key = $date - ($date % $step);
55                 if (exists $dataset{$key}) {
56                         $dataset{$key} += 1;
57                 } else {
58                         $dataset{$key} = 1;
59                         $groups += 1;
60                 }
61         }
62         close $fd;
63
64         print "...done, $commits commits in $groups groups.\n";
65
66         # fill missing steps and find max
67         my @keys = sort keys %dataset;
68         my $last = pop @keys;
69         my $max = 0;
70         while (my $key = shift @keys) {
71                 $max = $dataset{$key} if $max < $dataset{$key};
72                 my $next = $key + $step;
73                 while (! exists $dataset{$next}) {
74                         $dataset{$next} = 0;
75                         $next += $step;
76                         last if $next >= $last;
77                 }
78         }
79
80         $options->{max} = $max;
81         return \%dataset;
82 }
83
84 # functions to plot the datasets.
85 # each function can be called with either one or two parameters.
86 # when called with two parameters, the first is assumed to be the dataset, and the second the options
87 # (array and hash ref respectively).
88 # when called with a single parameter, it is assumed to be an options hash ref, and the dataset is 
89 # created by calling gather_data with the passed options.
90
91 # google chart API
92 # TODO needs a lot of customization
93 sub google_chart($;$) {
94         my $dataset = shift;
95         my $options = shift;
96         if (! defined $options) {
97                 $options = $dataset;
98                 $dataset = gather_data($options);
99         }
100
101         my $height=$options->{chart_height};
102         my $max = $options->{max};
103
104         my @keys = sort keys %$dataset;
105         my $from = $keys[0];
106         my $to = $keys[@keys-1];
107
108         my @data;
109         while (my $key = shift @keys) {
110                 push @data, $dataset->{$key};
111         }
112
113         my $width=ceil(4*$height/3);
114
115         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";
116
117         my $launch = sprintf $url, join(",",@data);
118         print $launch, "\n";
119         # `git web--browse "$launch"`
120 }
121
122 # gnuplot
123 sub gnuplot_chart($;$) {
124         my $dataset = shift;
125         my $options = shift;
126         if (! defined $options) {
127                 $options = $dataset;
128                 $dataset = gather_data($options);
129         }
130
131         my @keys = sort keys %$dataset;
132         my $step=$options->{step};
133         my $from = $keys[0];
134         my $to = $keys[@keys-1];
135         my $data = '';
136         while (my $key = shift @keys) {
137                 $data .= "$key $dataset->{$key}\n";
138         }
139         my $max = $options->{max};
140
141         # TODO allow customization
142         # in particular, detect (lack of) display and set term to dumb accordingly
143         my $termcmd = $options->{gnuplot_term};
144
145         my $plotsetup = $options->{gnuplot_setup};
146         $plotsetup .= "\nset yrange [0:$max]\n";
147         $plotsetup .= "set xrange ['$from':'$to']\n";
148         my ($formatx, $ticks);
149         if ($to - $from > $steps{yearly}) {
150                 $formatx = "%Y";
151                 $ticks = $steps{yearly};
152         } elsif ($to - $from > $steps{monthly}) {
153                 $formatx = "%b\\n%Y";
154                 $ticks = $steps{monthly};
155         } elsif ($to - $from > 2*$steps{daily}) {
156                 $formatx = "%d\\n%b";
157                 $ticks = $steps{daily};
158         } else {
159                 $formatx = "%H:%M";
160                 $ticks = $steps{hourly};
161         }
162         $plotsetup .= "set format x \"$formatx\"\n";
163         $plotsetup .= "set xtics $ticks\n";
164         my $plotstyle = $options->{gnuplot_style};
165         my $plotoptions = $options->{gnuplot_plotwith};
166
167         open my $gp, "|gnuplot -persist";
168
169         my $gp_script = <<GPCMD
170 $termcmd
171 set xdata time
172 set timefmt "%s"
173 $plotsetup
174 $plotstyle
175 plot "-" using 1:2 $plotoptions
176 $data
177 GPCMD
178         ;
179
180         print STDOUT $gp_script;
181         print $gp $gp_script;
182         close $gp;
183 }
184
185 # some defaults
186 my %options = (
187         step => $SECS_PER_DAY,
188         cmdline => [],
189         # charting/plotting options
190         plotter => \&gnuplot_chart,
191         chart_height => 144,
192         gnuplot_term => '',
193         gnuplot_setup => "set nokey",
194         gnuplot_style => 'set style data histeps',
195         gnuplot_plotwith => '',
196 );
197
198 sub parse_step(@) {
199         my $key = shift;
200         my $step = shift;
201         if (exists $steps{$key}) {
202                 $options{step} = $steps{$key};
203                 return;
204         }
205         die "this can't happen ($key)" unless $key eq 'step';
206
207         if ($step =~/^\d+$/) {
208                 $options{step} = 0 + $step;
209                 return
210         } else {
211                 if (exists $steps{$step}) {
212                         $options{step} = $steps{$step};
213                         return;
214                 }
215                 die "unknown step $step";
216         }
217 }
218
219 # read our options first
220 Getopt::Long::Configure('pass_through');
221 GetOptions(
222         'hourly' => \&parse_step,
223         'daily' => \&parse_step,
224         'weekly' => \&parse_step,
225         'monthly' => \&parse_step,
226         'yearly' => \&parse_step,
227         'step=s' => sub { parse_step(@_) },
228         'chart-height=i' => sub { $options{chart_height} = $_[1]},
229         google => sub { $options{plotter} = \&google_chart },
230         gnuplot => sub { $options{plotter} = \&gnuplot_chart },
231 );
232
233 # if anything was left, check for log options
234 if (@ARGV) {
235         $options{cmdline} = \@ARGV;
236 }
237
238 die "step must be strictly positive!" unless $options{step} > 0;
239
240 $options{plotter}->(\%options);