backtrack.pl

(plain)

    1 #!/usr/bin/env perl
    2 #
    3 # Copyright (c) 2024, 2025, 2026 Amelia Campbell
    4 #
    5 # Redistribution and use in source and binary forms, with or without
    6 # modification, are permitted provided that the following conditions are met:
    7 #
    8 #  1. Redistributions of source code must retain the above copyright notice,
    9 #     this list of conditions and the following disclaimer.
   10 #  2. Redistributions in binary form must reproduce the above copyright
   11 #     notice, this list of conditions and the following disclaimer in the
   12 #     documentation and/or other materials provided with the distribution.
   13 #
   14 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
   15 # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
   16 # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
   17 # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
   18 # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
   19 # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
   20 # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
   21 # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
   22 # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
   23 # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
   24 # POSSIBILITY OF SUCH DAMAGE.
   25 
   26 use strict;
   27 use warnings;
   28 use List::Util qw(min);
   29 use File::Basename;
   30 use Getopt::Long;
   31 use Pod::Usage;
   32 use Cwd;
   33 use Cwd 'abs_path';
   34 
   35 my $multirepo = 0;
   36 my $do_changed = 0;
   37 
   38 # create unique IDs for each data table, so that we don't plot the same data
   39 # twice when writing to the same gnuplot command file.
   40 sub cookie { return sprintf "%08X", int(rand(0xffffffff)); }
   41 
   42 # start a data table and return the generated cookie for it
   43 sub print_eod_start {
   44     my $c = cookie;
   45     print {shift} "\$M$c << EOD\n";
   46     return $c;
   47 }
   48 
   49 # data table end (nothing special, but just in case)
   50 sub print_eod_end { print {shift} "EOD\n"; }
   51 
   52 # data table separator, time axis, grid, y-label and format
   53 sub print_time_format {
   54     printf {shift} "set datafile separator ','
   55 set xdata time
   56 set xtics rotate
   57 set style fill transparent
   58 set timefmt '%%s'
   59 set format x '%%d/%%m/%%y'
   60 set format y '%%.0f'
   61 set ylabel '%s'
   62 set grid\n", shift;
   63 }
   64 
   65 sub print_y2tics {
   66     printf {shift} "set y2tics
   67 set format y2 '%%.0f'
   68 set y2label '%s'\n", shift;
   69 }
   70 
   71 sub print_loctics {
   72     my $o = shift;
   73     print_y2tics($o, 'line delta') if $do_changed;
   74     print_time_format($o, 'lines of code');
   75 }
   76 
   77 # in: git log --reverse --format="%h,%ct,%ae,%an"
   78 sub parse_contributors ($$$) {
   79     my ($in, $out, $lim) = @_;
   80     my (%rnames, %emails, %contrib, @history);
   81 
   82     $rnames{OTHER} = 'Others';
   83     $emails{OTHER} = '...';
   84 
   85     # return the keys of %t containing the top values (in descending order)
   86     # incl. a special "OTHER" token denoting all inferior keys
   87     sub top {
   88         my ($sz, $t) = @_;
   89         my @k = keys %$t;
   90         my @a = (sort { $t->{$b} <=> $t->{$a} } @k)[0..min($#k, $sz-1)];
   91         unshift(@a, 'OTHER') unless grep(/^OTHER$/, @a);
   92         return @a;
   93     }
   94 
   95     # trim keys of %t not in @p if the quantity of keys in %t exceeds length
   96     # of @p, this is primarily a memory-saving measure for large repositories
   97     sub trim {
   98         my ($t, @p) = @_;
   99         my @k = keys %$t;
  100         my %s; @s{@p} = ();
  101         return if $#k <= $#p;
  102         foreach (grep { !exists $s{$_} } @k) {
  103             # accumulate all of our trimmed values into the special OTHER token
  104             $t->{OTHER} += $t->{$_};
  105             delete $t->{$_};
  106         }
  107     }
  108 
  109     my $it; while (<$in>) {
  110         chomp;
  111         # extract relevant details from each record, filtering emails into a
  112         # unique username token where the domain is stripped only if the user
  113         # exceeds 3 characters.
  114         my ($t, $e, $u, $n) =
  115             grep(defined, /\w*,(\d*),(([^\s]{4,})@[^\s]*|([^\s]*)),(.*)/);
  116         next if (!$t);
  117 
  118         # bizarre workaround for rendering issue...for now
  119         $e =~ s/@/@/g;
  120 
  121         # $u is our uniquely identifying token per contributor, but we want to
  122         # store useful details about who this person actually is.  we update
  123         # these values constantly, just in case the user changes their name
  124         # or address.  note that we have a deliberately fuzzy definition of a
  125         # unique user entry.
  126         $u = lc $u;
  127         $rnames{$u} = $n;
  128         $emails{$u} = $e;
  129 
  130         $contrib{$u}++;
  131 
  132         # store a trimmed copy of the contributions table at this exact moment
  133         my %copy = %contrib;
  134         trim(\%copy, top(2*$lim, \%contrib));
  135         push(@history, [$t, \%copy]);
  136 
  137         # print useful progress data
  138         $it++; printf STDERR "visit %d commits, %d authors\n",
  139                    $it, 0+keys(%contrib);
  140     }
  141 
  142     # we've reached the end of the commit history, so we can now compute the
  143     #  final list of top contributors.
  144     my @win = top($lim+1, \%contrib);
  145 
  146     # if we never needed to trim, OTHER will not exist in the history, thus
  147     # there is no purpose in plotting it.
  148     shift @win if !exists $history[$#history]->[1]->{OTHER};
  149 
  150     my $ck = print_eod_start $out;
  151     foreach (@history) {
  152         my ($t, $h) = @$_;
  153         # do another trim to bring each history snapshot into alignment with
  154         # the final computed list of top contributors.
  155         trim($h, @win);
  156 
  157         # create a data table of their contributions at this point in history.
  158         my ($s, $c, $i);
  159         foreach (@win) {
  160             # accumulate values in $c so that they form a stack graph on plot.
  161             $s .= ($c += (defined $$h{$_} ? $$h{$_} : 0)) . ',' if $i++ < $lim;
  162         }
  163         # remove the trailing comma.
  164         chop $s;
  165 
  166         print $out "$t,$s\n";
  167     }
  168     print_eod_end $out;
  169     print $out "set key below\n";
  170 
  171     # finally, plot each column of the data table on top of each other
  172     # (starting with the tallest due to paint order), all along a time axis,
  173     # and provide gnuplot with their real names and email addresses.
  174     my @plots;
  175     for (reverse 0..min($#win, $lim-1)) {
  176         push(@plots, sprintf(
  177                  "\$M$ck using 1:%d title '%s (%s)' w filledcurves x1",
  178                  $_+2, $rnames{$win[$_]}, $emails{$win[$_]}));;
  179     }
  180 
  181     return @plots;
  182 }
  183 
  184 # in: git log --reverse --numstat --format="%h %ct"
  185 sub parse_loc ($$$) {
  186     my ($in, $out, $title) = @_;
  187     my @s = (0)x4; # values for this line
  188 
  189     # we'll need to dump the accumulated line count data both when we switch
  190     # commits and when we finish the input file
  191     sub commit_dump { printf {shift} join(',', @_)."\n" if $_[1]; return @_; }
  192 
  193     my $ck = print_eod_start $out;
  194     my $it; while (<$in>) {
  195         chomp;
  196         # match a pattern for the commit header or a numstat record
  197         my ($a, $d, $c, $t) = /^(\d+)\s+(\d*)\s+.*|(\w+)\s+(\d+)$/;
  198 
  199         # if this is a commit header: dump the current accumulated values and
  200         # reset the state for a new commit.
  201         @s = ($c, $t, (commit_dump($out, @s))[2], 0) if defined $c;
  202 
  203         # if this is the additions/deletions to a file: accumulate those values
  204         map { $_ += ($a - $d) } @s[2,3] if defined $a;
  205 
  206         # print useful progress data
  207         $it++; printf STDERR "visit %d commits, %d lines\n", $it, $s[2];
  208     }
  209 
  210     # the file is over, dump the last record
  211     commit_dump($out, @s);
  212     print_eod_end $out;
  213 
  214     # finally, plot the line for this repository
  215     my ($plines, $pdelta) = (
  216         "\$M$ck using 2:3 w steps title '$title'",
  217         "\$M$ck using 2:4 w impulses axes x1y2 title 'Δ$title'");
  218     return $do_changed ? join(', ', $plines, $pdelta) : $plines;
  219 }
  220 
  221 # format and print the commands to plot an array of line descriptions
  222 sub show_plots { print {shift} "plot " . join(', ', @_) . "\n"; }
  223 
  224 # we'll be switching repositories a lot
  225 my $origin;
  226 
  227 sub switch_repo { $origin = getcwd; chdir(shift); }
  228 sub restore_origin { chdir($origin); }
  229 
  230 sub repo_name { return (basename shift) =~ s/.git$//r; }
  231 
  232 # run a command and open its output as a file handle, failing if necessary
  233 sub failrun {
  234     open(my $h, '-|', @_) || die "$0: failed to execute $_[0]\n";
  235     return $h;
  236 }
  237 
  238 # run a git command in a given repository, returning its file handle
  239 sub git_read_cmd {
  240     switch_repo shift;
  241     my $h = failrun('git', @_);
  242     restore_origin;
  243     return $h;
  244 }
  245 
  246 # loc & contributor commands respectively
  247 sub get_filestats {
  248     return git_read_cmd(shift,
  249                         'log', '--reverse', '--numstat', '--format=%h %ct',
  250                         @_);
  251 }
  252 
  253 sub get_contribstats {
  254     return git_read_cmd(shift,
  255                         'log', '--reverse', '--format=%h,%ct,%ae,%an',
  256                         @_);
  257 }
  258 
  259 # functions for plotting those graphs
  260 sub repo_contrib {
  261     # <outfh> <repo> <lim> <...>
  262     my ($o, $r, $l) = (shift, shift, shift);
  263     my $h = get_contribstats($r, @_);
  264 
  265     print_time_format($o, 'commits');
  266     show_plots($o, parse_contributors($h, $o, $l));
  267 }
  268 
  269 sub repo_loc {
  270     # <outfh> <repo> <...>
  271     my ($o, $r) = (shift, shift);
  272     my $h = get_filestats($r, @_);
  273 
  274     return parse_loc($h, $o, repo_name $r);
  275 }
  276 
  277 sub repo_loc1 {
  278     # <outfh> <repo> <...>
  279     my ($o) = @_;
  280 
  281     print_loctics $o;
  282     show_plots($o, repo_loc @_);
  283 }
  284 
  285 sub repo_locs {
  286     # <outfh> <repo...>
  287     my @plots;
  288     my ($o, @rs) = @_;
  289 
  290     print_loctics $o;
  291     push(@plots, repo_loc($o, $_)) foreach @rs;
  292     show_plots($o, @plots);
  293 }
  294 
  295 my ($man, $help) = (0, 0);
  296 
  297 my ($contrib_lim,
  298     $width,
  299     $height,
  300     $canvasname,
  301     $output,
  302     $outfile,
  303     $gnuplot,
  304     $mode,
  305     @repos) = (8, 1024, 768, 'plot');
  306 
  307 GetOptions('r|repo=s'          => \@repos,
  308            'l|contrib-limit=i' => \$contrib_lim,
  309            't|output=s'        => \$output,
  310            'o|outfile=s'       => \$outfile,
  311            'w|width=i'         => \$width,
  312            'h|height=i'        => \$height,
  313            'g|gnuplot'         => \$gnuplot,
  314            'n|canvas-name=s'   => \$canvasname,
  315            'm|mode=s'          => \$mode,
  316            'c|show-changed'    => \$do_changed,
  317 
  318            'help|?'          => \$help,
  319            man               => \$man) or pod2usage(2);
  320 pod2usage(1) if $help;
  321 pod2usage(-exitval => 0, -verbose => 2) if $man;
  322 
  323 die "$0: no subcommand specified! (contrib/loc)\n" if ! defined $mode;
  324 die "$0: you must specify at least one repository!\n" if 0+@repos < 1;
  325 $multirepo = 0+@repos > 1;
  326 
  327 my $fh = *STDOUT;
  328 open($fh, '|-', 'gnuplot -p')
  329     or die "$0: couldn't execute gnuplot: $!\n" if defined $gnuplot;
  330 
  331 if (defined $output) {
  332     my $ov = $output =~ /^ov-/;
  333 
  334     if (! $ov) {
  335         die "$0: no output file specified!\n" if ! defined $outfile;
  336         $outfile = abs_path($outfile) =~ s/'/\\'/gr;
  337     }
  338 
  339     if ($output eq "png") {
  340         print $fh "set terminal png size $width,$height\n";
  341     } elsif ($output eq "js") {
  342         $canvasname =~ s/'/\\'/g;
  343 
  344         print $fh
  345             "set terminal canvas name '$canvasname' size $width,$height\n";
  346     } elsif ($output eq "svg") {
  347         print $fh "set terminal svg size $width,$height dynamic\n";
  348     } elsif ($ov) {
  349         print $fh "set terminal " . $output =~ s/^ov-//r . "\n";
  350     } else {
  351         die "$0: invalid output mode specification (png/js/svg)\n";
  352     }
  353 
  354     print $fh "set output '$outfile'\n" if ! $ov;
  355 }
  356 
  357 if ($mode =~ /^(c|contrib)$/) {
  358     die "$0: contrib subcommand can only process one repository!\n"
  359         if $multirepo;
  360 
  361     repo_contrib($fh, $repos[0], $contrib_lim, @ARGV);
  362 } elsif ($mode =~ /^(l|loc)$/) {
  363     if ($multirepo) {
  364         repo_locs($fh, @repos);
  365     } else {
  366         repo_loc1($fh, $repos[0], @ARGV);
  367     }
  368 } else {
  369     die "$0: invalid mode specification ([c]ontrib/[l]oc)\n";
  370 }
  371 
  372 if (defined $gnuplot) {
  373     close STDOUT; wait
  374 }
  375 
  376 __END__
  377 
  378 =head1 NAME
  379 
  380 backtrack - Walk the history of a Git repository and compute statistics
  381 
  382 =head1 SYNOPSIS
  383 
  384 backtrack [options | -rltowhgnmc] -- [git options]
  385 
  386    options          a list of options to backtrack itself
  387    git options      a list of options to be fed to git subcommands
  388 
  389 =head1 OPTIONS
  390 
  391 =over 8
  392 
  393 =item B<--man>
  394 
  395 Prints the manual page and exits.
  396 
  397 =item B<--help>
  398 
  399 Print a brief help message and exits.
  400 
  401 =item B<-r|--repo=STR>
  402 
  403 Specify the path to a repository to examine.  This argument can be provided
  404 more than once.
  405 
  406 =item B<-l|--contrib-limit=INT>
  407 
  408 Specifies the maximum number of top contributors to keep track of when in
  409 "contrib" mode.
  410 
  411 =item B<-t|--output=STR>
  412 
  413 Specifies the output terminal that gnuplot should use.  Can be either "js,"
  414 "svg," or "png."  If no terminal is specified, gnuplot will simply use the
  415 default, which on most systems is an interactive GUI.  If the input to this
  416 argument begins with "ov-" then the rest of the argument will be passed through
  417 to gnuplot's "set terminal" directly.
  418 
  419 =item B<-o|--outfile=STR>
  420 
  421 Specifies the path to the output file that gnuplot should write to.
  422 
  423 =item B<-w|--width=INT>
  424 
  425 Specifies the width in pixels.
  426 
  427 =item B<-h|--height=INT>
  428 
  429 Specifies the height in pixels.
  430 
  431 =item B<-g|--gnuplot>
  432 
  433 Actually execute gnuplot automatically instead of writing gnuplot commands to
  434 the standard output.
  435 
  436 =item B<-n|--canvas-name>
  437 
  438 Specifies the name of the canvas to write to when output is "js."
  439 
  440 =item B<-m|--mode>
  441 
  442 Specifies the subcommand to use, and thus the statistics to be collected.  Can
  443 be either [c]ontrib or [l]oc.  In contrib mode, the program generates a stacked
  444 graph of the top contributors to the repository over time.  In loc mode, the
  445 program generates a graph of the lines of code in the repository over time.  If
  446 more than one repository is to be plotted on the same graph, the "git options"
  447 arguments are ignored.
  448 
  449 =item B<-c|--show-changed>
  450 
  451 In loc mode, overlay a graph onto the second Y-axis showing lines added or
  452 deleted over time.
  453 
  454 =back
  455 
  456 =head1 DESCRIPTION
  457 
  458 B<This program> will walk the log of a Git repository, starting from its first
  459 commit (by default) and generate graphs (via gnuplot) of various statistics
  460 that can be ascertained from this data, determined by the -m/--mode argument.
  461 
  462 =cut