3 # git-chart: plot a distribution of commits over time
5 # Copyright (C) 2011-2017 Giuseppe Bilotta <giuseppe.bilotta@gmail.com>
7 # Licensed under the Mozilla Public License, version 2.0
8 # Refer to the attached LICENSE file for details.
10 # List of general TODO
11 # * default to something else than "the whole history", probably
12 # * if no commits are specified and a commit grouping step is specified,
13 # limit commits proportionally (e.g. --daily => one week worth of commits)
14 # * allow the steps to be counted from the most recent rather than from the
19 use POSIX qw(ceil strftime);
26 our $VERSION = '17.04';
28 my $SECS_PER_DAY = 24*3600;
32 daily => $SECS_PER_DAY,
33 weekly => 7*$SECS_PER_DAY,
34 monthly => 30*$SECS_PER_DAY,
35 yearly => 12*30*$SECS_PER_DAY,
39 hourly => "%Y-%m-%d %H:00",
40 daily => "%Y-%m-%d 00:00",
41 monthly => "%Y-%m-01 00:00",
42 yearly => "%Y-01-01 00:00",
48 Usage: git chart [options...] [log specs...]
50 Creates a chart plotting the distribution over time of the commits
51 specified as <log specs>. By default, commits are grouped by hour, day,
52 week, month or year depending on the time spanned, but this can be
53 controlled by the user.
56 --hourly, --daily, --weekly, --monthly, --yearly:
57 force a specific commit grouping
59 force commits to be grouped each <integer> seconds
61 produce a chart with gnuplot (default)
63 produce a chart with Google Charts
64 --chart-height=<integer>:
65 set the Google Charts height; the width is set
66 to a 4:3 ratio (default: 100)
74 print "Gathering data ...\n";
78 open my $fd, '-|', qw(git log --date=local), '--pretty=%aN%x00%ad%x00%s', @{$options->{cmdline}};
79 die "failed to get revs" unless $fd;
82 my ($author, $date, $sub) = split '\0', $_, 3;
84 push @commits, { date => str2time($date), author => $author };
89 @commits = sort { $a->{date} cmp $b->{date} } @commits;
91 print "...done, " . @commits . " commits.\n";
93 die "No commits!" if (@commits < 1);
95 my $gap = $commits[$#commits]->{date} - $commits[0]->{date};
98 if (defined $options->{step}) {
99 $step = $options->{step};
101 $step = $gap > $steps{yearly} ? 'monthly' :
102 $gap > $steps{monthly} ? 'weekly' :
103 $gap > $steps{weekly}/2 ? 'daily' :
106 $options->{step} = $step;
109 # truncate each commit (date) to the wanted step
110 # e.g. if the step is 'monthly', then commit YYYY/MM/DD
113 for my $commit (@commits) {
114 my $date = $commit->{date};
115 my $author = $commit->{author};
117 if (exists $trunc{$step}) {
118 # LOL -- no, seriously, is there a smarter way to do this?
119 $key = str2time(strftime($trunc{$step}, localtime($date)));
120 } elsif ($step eq 'weekly') {
121 # horrible special case: do it daily, and then subtract
122 # as many days as necessary to go back to the previous sunday
123 # TODO config week start
124 $key = str2time(strftime($trunc{daily}, localtime($date)));
125 my $wd = strftime("%w", localtime($date));
126 $key -= $wd*$steps{daily};
128 $key = $date - ($date % $step);
131 # make sure the value is a hash
132 $dataset{$key} = {_commits => 0} unless exists $dataset{$key};
133 $dataset{_authors} = { $author => 0} unless exists $dataset{_authors};
135 $dataset{$key}{_commits} += 1;
136 $dataset{$key}{$author} += 1;
137 $dataset{_authors}{$author} += 1;
140 # fill missing steps and find max
141 my @keys = sort keys %dataset;
142 pop @keys; # get rid of _authors
144 # ensure numeric step, taking into account
145 # that our numerical steps are approximate
147 if (exists $steps{$step}) {
148 $step = $steps{$step};
149 $tolerance = $step/2;
153 my $key = shift @keys;
155 $max = $dataset{$key}{_commits} if $max < $dataset{$key}{_commits};
156 my $next_step = $key + $step;
157 my $next = shift @keys;
159 while (abs($next - $next_step) > $tolerance) {
160 # next step is too far away
161 $dataset{$next_step} = {_commits => 0};
167 $options->{max} = $max;
171 # functions to plot the datasets.
172 # each function can be called with either one or two parameters.
173 # when called with two parameters, the first is assumed to be the dataset, and the second the options
174 # (array and hash ref respectively).
175 # when called with a single parameter, it is assumed to be an options hash ref, and the dataset is
176 # created by calling gather_data with the passed options.
179 # TODO needs a lot of customization
180 sub google_chart($;$) {
183 if (! defined $options) {
185 $dataset = gather_data($options);
188 my $height=$options->{chart_height};
189 my $max = $options->{max};
191 my @keys = sort keys %$dataset;
192 pop @keys; # get rid of _authors
195 my $to = $keys[@keys-1];
198 while (my $key = shift @keys) {
199 push @data, $dataset->{$key}{_commits};
202 my $width=ceil(4*$height/3);
204 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";
206 my $launch = sprintf $url, join(",",@data);
208 # `git web--browse "$launch"`
212 sub gnuplot_chart($;$) {
215 if (! defined $options) {
217 $dataset = gather_data($options);
220 my $authors = delete $dataset->{_authors};
221 my @authors = sort { $authors->{$b} <=> $authors->{$a} } keys %$authors;
222 $authors = join "\t", map { "\"$_\"" } @authors;
224 my @keys = sort keys %$dataset;
226 my $step=$options->{step};
228 my $to = $keys[$#keys];
230 my $data = "date\tcommits\t$authors\n";
231 while (my $key = shift @keys) {
232 $data .= "$key\t$dataset->{$key}{_commits}";
233 foreach my $author (@authors) {
234 if (exists $dataset->{$key}{$author}) {
235 $data .= "\t$dataset->{$key}{$author}";
242 my $max = $options->{max} + 1;
244 # add a fake datapoint lest to prevent the last
245 # datum from becoming invibile with style data steps
246 if (exists $steps{$step}) {
247 $to += $steps{$step};
252 foreach my $author (@authors) {
257 my ($formatx, $ticks);
258 if ($to - $from > $steps{yearly}) {
260 $ticks = $steps{yearly};
261 } elsif ($to - $from > $steps{monthly}) {
262 $formatx = "%b\\n%Y";
263 $ticks = $steps{monthly};
264 } elsif ($to - $from > $steps{weekly}/2) {
265 $formatx = "%d\\n%b";
266 $ticks = $steps{daily};
268 $formatx = "%H\\n%d";
269 $ticks = $steps{hourly};
272 my $nticks = ceil(($to - $from)/$ticks);
274 # Default terminal sizing. Rationale:
275 # * authors are limited to 8 rows, so the resulting number of columns
276 # should fit within some scaled 800 pixels.
277 # and assuming that each colum takes about 600 scaled pixels
278 my $sizemul = max(1, ceil(ceil(3*@authors/8)/4));
279 print "# $sizemul \n";
280 $sizemul = max($sizemul, $nticks/8);
281 print "# $sizemul \n";
282 my $xres = 800*$sizemul;
283 my $yres = 450*$sizemul;
285 # TODO allow customization
286 # in particular, detect (lack of) display and set term to dumb accordingly
287 my $termcmd = $options->{gnuplot_term} ||
288 "set terminal GNUTERM enhanced size $xres,$yres\n";
290 my $plotsetup = $options->{gnuplot_setup} ||
291 @authors == 1 ? 'set nokey' :
292 'set key above autotitle columnheader maxrows 8' ;
293 $plotsetup .= "\nset yrange [0:$max]\n";
294 $plotsetup .= "set xrange ['$from':'$to']\n";
295 $plotsetup .= "set format x \"$formatx\"\n";
296 $plotsetup .= "set xtics axis out nomirror $ticks\n";
297 $plotsetup .= "set ytics axis out nomirror\n";
298 my $plotstyle = $options->{gnuplot_style};
299 my $plotoptions = $options->{gnuplot_plotwith};
301 my $datafile = File::Temp->new(
302 TEMPLATE => 'git-chart-XXXXXX',
303 DIR => File::Spec->tmpdir()
306 print $datafile $data;
308 open my $gp, "|gnuplot -persist";
310 my $plotcmd = "plot '$datafile' using 1:2";
312 my $lastcolumn = $#authors + 3;
313 $plotcmd.= " notitle";
314 $plotcmd.= ", for [a=3:$lastcolumn] '' using 1:a";
317 my $gp_script = <<GPCMD
327 print STDOUT $gp_script;
328 print $gp $gp_script;
335 # charting/plotting options
336 plotter => \&gnuplot_chart,
340 gnuplot_style => 'set style data steps',
341 gnuplot_plotwith => '',
347 if (exists $steps{$key}) {
348 $options{step} = $key;
351 die "this can't happen ($key)" unless $key eq 'step';
353 if ($step =~/^\d+$/) {
354 $options{step} = 0 + $step;
357 if (exists $steps{$step}) {
358 $options{step} = $step;
361 die "unknown step $step";
366 # read our options first
367 Getopt::Long::Configure('pass_through');
369 'hourly' => \&parse_step,
370 'daily' => \&parse_step,
371 'weekly' => \&parse_step,
372 'monthly' => \&parse_step,
373 'yearly' => \&parse_step,
374 'step=s' => sub { parse_step(@_) },
375 'chart-height=i' => sub { $options{chart_height} = $_[1]},
376 google => sub { $options{plotter} = \&google_chart },
377 gnuplot => sub { $options{plotter} = \&gnuplot_chart },
386 # if anything was left, check for log options
388 $options{cmdline} = \@ARGV;
391 $options{plotter}->(\%options);