File Coverage

blib/lib/SVN/Churn.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             package SVN::Churn;
2 2     2   1288 use strict;
  2         5  
  2         64  
3 2     2   10 use warnings;
  2         4  
  2         91  
4             our $VERSION = '0.02';
5 2     2   1950 use Chart::Strip;
  0            
  0            
6             use Date::Parse qw( str2time );
7             use List::Util qw( min max );
8             use SVN::Log;
9             use Storable qw( nstore retrieve );
10             use String::ShellQuote qw( shell_quote );
11             use base qw( Class::Accessor::Fast );
12             __PACKAGE__->mk_accessors(qw( path database revisions skip_to granularity ));
13              
14             =head1 NAME
15              
16             SVN::Churn - generate a graph for repository churn
17              
18             =head1 SYNOPSIS
19              
20             use SVN::Churn;
21             my $churn = SVN::Churn->new(
22             path => 'http://opensource.fotango.com/svn/trunk/SVN-Churn',
23             database => 'churn.db' );
24             $churn->update;
25             $churn->save;
26             $churn->graph( 'churn.png' );
27              
28             =head1 DESCRIPTION
29              
30             SVN::Churn is a module for generating Churn graphs. Churn graphs
31             simply track the number of changed lines in a repository, grouped by a
32             time period; they might be useful for judging the stability of a
33             codebase, or the activeness of a project, or they may not be.
34              
35             =cut
36              
37             sub new {
38             my $class = shift;
39             my $self = $class->SUPER::new({
40             granularity => 60 * 60 * 24,
41             revisions => [],
42             @_,
43             });
44             return $self;
45             }
46              
47             sub save {
48             my $self = shift;
49             nstore $self, $self->database;
50             }
51              
52             sub load {
53             my $class = shift;
54             my $from = shift;
55             return retrieve $from;
56             }
57              
58             sub head_revision {
59             my $self = shift;
60             if (eval {
61             # load SVN::Core before SVN::Ra for future compatibility -- clkao
62             require SVN::Core;
63             require SVN::Ra;
64             1;
65             }) { # we have the bindings
66             return SVN::Ra->new(url => $self->path)->get_latest_revnum;
67             }
68             else {
69             my $path = shell_quote $self->path;
70             return $1
71             if `svn log -r HEAD $path` =~ m{^r(\d+) }m;
72             my ($parent, $chunk) = $self->path =~ m{(.*?/)([^/]+/?)$}
73             or die "couldn't guess what the parent was for ".$self->path;
74             $parent = shell_quote $parent;
75             `svn ls -v $parent` =~ m{^\s*(\d+).*? \Q$chunk\E$}m
76             or die "couldn't figure out head revision";
77             return $1;
78             }
79             }
80              
81              
82             sub start_at {
83             my $self = shift;
84             if (my $skip_to = $self->skip_to) {
85             $self->skip_to( 0 ); # clear the flag
86             return $skip_to;
87             }
88             my $highest = max map { $_->{revision} } @{ $self->revisions };
89             return $highest ? $highest + 1 : 1;
90             }
91              
92             sub update {
93             my $self = shift;
94             my ($from, $to) = ( $self->start_at, $self->head_revision );
95             return if $from > $to;
96             my $revisions = SVN::Log::retrieve( $self->path, $from, $to );
97             local $| = 1;
98             for my $revision (@$revisions) {
99             print "r$revision->{revision} | $revision->{author} | $revision->{date}";
100             $self->add_churn_to( $revision );
101             print " -$revision->{lines_removed}+$revision->{lines_added}\n";
102             push @{ $self->revisions }, $revision;
103             }
104             }
105              
106             sub get_diff {
107             my $self = shift;
108             my $revision = shift;
109             my $to = $revision->{revision};
110             my $from = $to - 1;
111             my $path = shell_quote $self->path;
112              
113             my @diff = `svn diff -r $from:$to $path 2>/dev/null`;
114             # if it's nonzero, it could be that it's the initial add, so fake
115             # it so it's a total add diff
116             if ($?) {
117             # apart from cat doesn't work on paths, hmmm
118             # @diff = map "+$_", `svn cat -r $to $path` if $?;
119             }
120              
121             return @diff;
122             }
123              
124             sub add_churn_to {
125             my $self = shift;
126             my $revision = shift;
127              
128             my @diff = $self->get_diff( $revision );
129              
130             #print Dump $revision, \@diff;
131             $revision->{ndate} = str2time $revision->{date};
132             $revision->{lines_added} = $revision->{lines_removed} = 0;
133             for (@diff) {
134             next if /^[-+]{3,3} \S/;
135             ++$revision->{lines_added} if /^\+/;
136             ++$revision->{lines_removed} if /^\-/;
137             }
138             }
139              
140             sub graph {
141             my $self = shift;
142             my $filename = shift;
143             my $chart = Chart::Strip->new( title => 'Churn for '. $self->path );
144              
145             my @colours = qw( green red blue FF9900 990099 00FFFF 993300 CC0066 black );
146             my $colour = 0;
147             for my $key (qw( lines_added lines_removed )) {
148             $chart->add_data(
149             $self->churn_data( $key ),
150             {
151             style => 'line',
152             color => $colours[ $colour++ % @colours ],
153             label => $key,
154             } );
155             }
156             open my $fh, ">$filename";
157             local $^W; # XXX lazy
158             print $fh $chart->png;
159             }
160              
161             sub granulate {
162             my $self = shift;
163             my $time = shift;
164             return int( $time / $self->granularity ) * $self->granularity;
165             }
166              
167             sub churn_data {
168             my $self = shift;
169             my $key = shift;
170              
171             my $from = $self->granulate( min map $_->{ndate}, @{ $self->revisions } );
172             my $to = $self->granulate( max map $_->{ndate}, @{ $self->revisions } );
173              
174             my %granular;
175             # prefill with zeros
176             while ($from <= $to) {
177             $granular{ $from } = 0;
178             $from += $self->granularity;
179             }
180              
181             for my $revision (@{$self->revisions}) {
182             $granular{ $self->granulate( $revision->{ndate} ) }
183             += $revision->{ $key };
184             }
185              
186             [ map {
187             { time => $_, value => $granular{$_} }
188             } sort { $a <=> $b } keys %granular ];
189             }
190              
191             1;
192              
193             __END__