Tidied up the /blob action and the commit-nav.tt2 links.
[catagits/Gitalist.git] / lib / Gitalist / Model / Git.pm
CommitLineData
fbf3eb7e 1package Gitalist::Model::Git;
2
3use Moose;
4use namespace::autoclean;
5
86382b95 6BEGIN { extends 'Catalyst::Model' }
7
fbf3eb7e 8use DateTime;
9use Path::Class;
1feb3d6b 10use File::Which;
fbf3eb7e 11use Carp qw/croak/;
12use File::Find::Rule;
13use DateTime::Format::Mail;
14use File::Stat::ModeString;
15use List::MoreUtils qw/any/;
1feb3d6b 16use Scalar::Util qw/blessed/;
17use MooseX::Types::Common::String qw/NonEmptySimpleStr/; # FIXME, use Types::Path::Class and coerce
fbf3eb7e 18
b3ad9e63 19# Should these live in a separate module? Or perhaps extended Regexp::Common?
20our $SHA1RE = qr/[0-9a-fA-F]{40}/;
21
1feb3d6b 22has project => ( isa => NonEmptySimpleStr, is => 'rw');
8c032474 23has repo_dir => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 ); # Fixme - path::class
24has git => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 );
1feb3d6b 25
8c032474 26sub BUILD {
27 my ($self) = @_;
28 $self->git; # Cause lazy value build.
1feb3d6b 29 $self->repo_dir;
8c032474 30}
31
1ef8dc7d 32use Git::PurePerl;
33
34has gpp => (
35 #isa => 'Git::PurePerl'
36 is => 'ro',
37 required => 1,
38 lazy => 1,
39 default => sub {
40 my($self) = @_;
41 return Git::PurePerl->new(
42 directory => $self->project_dir( $self->project )
43 );
44 },
45);
46
8c032474 47sub _build_git {
1feb3d6b 48 my $git = File::Which::which('git');
d7c9a32f 49
1feb3d6b 50 if (!$git) {
51 die <<EOR;
fbf3eb7e 52Could not find a git executable.
53Please specify the which git executable to use in gitweb.yml
54EOR
1feb3d6b 55 }
fbf3eb7e 56
1feb3d6b 57 return $git;
04d1d917 58}
1feb3d6b 59
8c032474 60sub _build_repo_dir {
1ef8dc7d 61 return Gitalist->config->{repo_dir};
62}
63
64sub get_object {
65 $_[0]->gpp->get_object($_[1]);
8c032474 66}
d7c9a32f 67
fbf3eb7e 68sub is_git_repo {
1feb3d6b 69 my ($self, $dir) = @_;
fbf3eb7e 70
1feb3d6b 71 return -f $dir->file('HEAD') || -f $dir->file('.git/HEAD');
fbf3eb7e 72}
73
1ef8dc7d 74sub run_cmd {
75 my ($self, @args) = @_;
76
77 print STDERR 'RUNNING: ', $self->git, qq[ @args], $/;
78
79 open my $fh, '-|', $self->git, @args
80 or die "failed to run git command";
81 binmode $fh, ':encoding(UTF-8)';
82
83 my $output = do { local $/ = undef; <$fh> };
84 close $fh;
85
86 return $output;
87}
88
89sub project_dir {
90 my($self, $project) = @_;
91
92 my $dir = blessed($project) && $project->isa('Path::Class::Dir')
93 ? $project->stringify
94 : $self->git_dir_from_project_name($project);
95
96 $dir =~ s/\.git$//;
97
98 return $dir;
99}
100
101sub run_cmd_in {
102 my ($self, $project, @args) = @_;
103
104 return $self->run_cmd('--git-dir' => $self->project_dir($project)."/.git", @args);
105}
106
b3ad9e63 107sub command {
108 my($self, @args) = @_;
109
110 my $output = $self->run_cmd('--git-dir' => $self->project_dir($self->project)."/.git", @args);
111
112 return $output ? split(/\n/, $output) : ();
113}
114
fbf3eb7e 115sub project_info {
1feb3d6b 116 my ($self, $project) = @_;
fbf3eb7e 117
1feb3d6b 118 return {
119 name => $project,
120 $self->get_project_properties(
121 $self->git_dir_from_project_name($project),
122 ),
fbf3eb7e 123 };
124}
125
126sub get_project_properties {
1feb3d6b 127 my ($self, $dir) = @_;
128 my %props;
fbf3eb7e 129
1feb3d6b 130 eval {
131 $props{description} = $dir->file('description')->slurp;
132 chomp $props{description};
fbf3eb7e 133 };
134
1feb3d6b 135 if ($props{description} && $props{description} =~ /^Unnamed repository;/) {
136 delete $props{description};
137 }
fbf3eb7e 138
1feb3d6b 139 ($props{owner} = (getpwuid $dir->stat->uid)[6]) =~ s/,+$//;
fbf3eb7e 140
1feb3d6b 141 my $output = $self->run_cmd_in($dir, qw{
142 for-each-ref --format=%(committer)
143 --sort=-committerdate --count=1 refs/heads
144 });
fbf3eb7e 145
1feb3d6b 146 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
147 my $dt = DateTime->from_epoch(epoch => $epoch);
148 $dt->set_time_zone($tz);
149 $props{last_change} = $dt;
150 }
fbf3eb7e 151
1feb3d6b 152 return %props;
fbf3eb7e 153}
154
155sub list_projects {
1feb3d6b 156 my ($self) = @_;
fbf3eb7e 157
1feb3d6b 158 my $base = dir($self->repo_dir);
fbf3eb7e 159
1feb3d6b 160 my @ret;
161 my $dh = $base->open;
162 while (my $file = $dh->read) {
163 next if $file =~ /^.{1,2}$/;
164
165 my $obj = $base->subdir($file);
166 next unless -d $obj;
167 next unless $self->is_git_repo($obj);
168
169 # XXX Leaky abstraction alert!
170 my $is_bare = !-d $obj->subdir('.git');
d7c9a32f 171
1feb3d6b 172 my $name = (File::Spec->splitdir($obj))[-1];
173 push @ret, {
174 name => ($name . ( $is_bare ? '.git' : '/.git' )),
175 $self->get_project_properties(
176 $is_bare ? $obj : $obj->subdir('.git')
177 ),
178 };
179 }
180
181 return [sort { $a->{name} cmp $b->{name} } @ret];
fbf3eb7e 182}
183
fbf3eb7e 184sub git_dir_from_project_name {
1feb3d6b 185 my ($self, $project) = @_;
fbf3eb7e 186
1feb3d6b 187 return dir($self->repo_dir)->subdir($project);
fbf3eb7e 188}
189
c8870bd3 190sub head_hash {
1feb3d6b 191 my ($self, $project) = @_;
fbf3eb7e 192
c8870bd3 193 my $output = $self->run_cmd_in($project || $self->project, qw/rev-parse --verify HEAD/ );
1feb3d6b 194 return unless defined $output;
fbf3eb7e 195
b3ad9e63 196 my ($head) = $output =~ /^($SHA1RE)$/;
1feb3d6b 197 return $head;
fbf3eb7e 198}
199
200sub list_tree {
1feb3d6b 201 my ($self, $project, $rev) = @_;
fbf3eb7e 202
c8870bd3 203 $rev ||= $self->head_hash($project);
fbf3eb7e 204
1feb3d6b 205 my $output = $self->run_cmd_in($project, qw/ls-tree -z/, $rev);
206 return unless defined $output;
fbf3eb7e 207
1feb3d6b 208 my @ret;
209 for my $line (split /\0/, $output) {
210 my ($mode, $type, $object, $file) = split /\s+/, $line, 4;
d7c9a32f 211
1feb3d6b 212 push @ret, {
213 mode => oct $mode,
214 type => $type,
215 object => $object,
216 file => $file,
217 };
218 }
219
220 return @ret;
fbf3eb7e 221}
222
223sub get_object_mode_string {
1feb3d6b 224 my ($self, $object) = @_;
fbf3eb7e 225
1feb3d6b 226 return unless $object && $object->{mode};
227 return mode_to_string($object->{mode});
fbf3eb7e 228}
229
230sub get_object_type {
1feb3d6b 231 my ($self, $project, $object) = @_;
232
233 my $output = $self->run_cmd_in($project, qw/cat-file -t/, $object);
234 return unless $output;
235
236 chomp $output;
237 return $output;
238}
239
c8870bd3 240sub hash_by_path {
1feb3d6b 241 my($self, $base, $path, $type) = @_;
fbf3eb7e 242
1feb3d6b 243 $path =~ s{/+$}();
fbf3eb7e 244
c8870bd3 245 my($line) = $self->command('ls-tree', $base, '--', $path)
1feb3d6b 246 or return;
247
248 #'100644 blob 0fa3f3a66fb6a137f6ec2c19351ed4d807070ffa panic.c'
b3ad9e63 249 $line =~ m/^([0-9]+) (.+) ($SHA1RE)\t/;
1feb3d6b 250 return defined $type && $type ne $2
251 ? ()
c8870bd3 252 : $3;
295c9703 253}
254
fbf3eb7e 255sub cat_file {
1feb3d6b 256 my ($self, $object) = @_;
fbf3eb7e 257
1feb3d6b 258 my $type = $self->get_object_type($self->project, $object);
259 die "object `$object' is not a file\n"
260 if (!defined $type || $type ne 'blob');
fbf3eb7e 261
1feb3d6b 262 my $output = $self->run_cmd_in($self->project, qw/cat-file -p/, $object);
263 return unless $output;
fbf3eb7e 264
1feb3d6b 265 return $output;
fbf3eb7e 266}
267
268sub valid_rev {
1feb3d6b 269 my ($self, $rev) = @_;
fbf3eb7e 270
1feb3d6b 271 return unless $rev;
b3ad9e63 272 return ($rev =~ /^($SHA1RE)$/);
fbf3eb7e 273}
274
275sub diff {
1feb3d6b 276 my ($self, $project, @revs) = @_;
fbf3eb7e 277
1feb3d6b 278 croak("Gitalist::Model::Git::diff needs a project and either one or two revisions")
279 if scalar @revs < 1
280 || scalar @revs > 2
281 || any { !$self->valid_rev($_) } @revs;
fbf3eb7e 282
1feb3d6b 283 my $output = $self->run_cmd_in($project, 'diff', @revs);
284 return unless $output;
fbf3eb7e 285
1feb3d6b 286 return $output;
fbf3eb7e 287}
288
289{
1feb3d6b 290 my $formatter = DateTime::Format::Mail->new;
291
292 sub parse_rev_list {
293 my ($self, $output) = @_;
294 my @ret;
295
296 my @revs = split /\0/, $output;
297
298 for my $rev (split /\0/, $output) {
299 for my $line (split /\n/, $rev, 6) {
300 chomp $line;
301 next unless $line;
302
303 if ($self->valid_rev($line)) {
304 push @ret, {rev => $line};
305 next;
d7c9a32f 306 }
307
1feb3d6b 308 if (my ($key, $value) = $line =~ /^(tree|parent)\s+(.*)$/) {
309 $ret[-1]->{$key} = $value;
310 next;
311 }
312
313 if (my ($key, $value, $epoch, $tz) = $line =~ /^(author|committer)\s+(.*)\s+(\d+)\s+([+-]\d+)$/) {
314 $ret[-1]->{$key} = $value;
315 eval {
316 $ret[-1]->{ $key . "_datetime" } = DateTime->from_epoch(epoch => $epoch);
317 $ret[-1]->{ $key . "_datetime" }->set_time_zone($tz);
318 $ret[-1]->{ $key . "_datetime" }->set_formatter($formatter);
319 };
320
321 if ($@) {
322 $ret[-1]->{ $key . "_datetime" } = "$epoch $tz";
323 }
324
325 if (my ($name, $email) = $value =~ /^([^<]+)\s+<([^>]+)>$/) {
326 $ret[-1]->{ $key . "_name" } = $name;
327 $ret[-1]->{ $key . "_email" } = $email;
328 }
329 }
330
331 $line =~ s/^\n?\s{4}//;
332 $ret[-1]->{longmessage} = $line;
333 $ret[-1]->{message} = (split /\n/, $line, 2)[0];
334 }
fbf3eb7e 335 }
1feb3d6b 336
337 return @ret;
338 }
fbf3eb7e 339}
340
341sub list_revs {
1feb3d6b 342 my ($self, $project, %args) = @_;
fbf3eb7e 343
c8870bd3 344 $args{rev} ||= $self->head_hash($project);
fbf3eb7e 345
1feb3d6b 346 my $output = $self->run_cmd_in($project, 'rev-list',
347 '--header',
348 (defined $args{ count } ? "--max-count=$args{count}" : ()),
349 (defined $args{ skip } ? "--skip=$args{skip}" : ()),
350 $args{rev},
351 '--',
352 ($args{file} || ()),
fbf3eb7e 353 );
1feb3d6b 354 return unless $output;
fbf3eb7e 355
1feb3d6b 356 my @revs = $self->parse_rev_list($output);
fbf3eb7e 357
1feb3d6b 358 return \@revs;
fbf3eb7e 359}
360
361sub rev_info {
1feb3d6b 362 my ($self, $project, $rev) = @_;
fbf3eb7e 363
1feb3d6b 364 return unless $self->valid_rev($rev);
c5065c66 365
1feb3d6b 366 return $self->list_revs($project, rev => $rev, count => 1);
367}
368
369sub reflog {
370 my ($self, @logargs) = @_;
371
372 my @entries
373 = $self->run_cmd_in($self->project, qw(log -g), @logargs)
374 =~ /(^commit.+?(?:(?=^commit)|(?=\z)))/msg;
375
376=begin
377
378 commit 02526fc15beddf2c64798a947fecdd8d11bf993d
379 Reflog: HEAD@{14} (The Git Server <git@git.dev.venda.com>)
380 Reflog message: push
1ef8dc7d 381 Author: Foo Barsby <fbarsby@example.com>
1feb3d6b 382 Date: Thu Sep 17 12:26:05 2009 +0100
383
1ef8dc7d 384 Merge branch 'abc123'
1feb3d6b 385=cut
386
387 return map {
388
389 # XXX Stuff like this makes me want to switch to Git::PurePerl
390 my($sha1, $type, $author, $date)
391 = m{
b3ad9e63 392 ^ commit \s+ ($SHA1RE)$
1feb3d6b 393 .*?
394 Reflog[ ]message: \s+ (.+?)$ \s+
395 Author: \s+ ([^<]+) <.*?$ \s+
396 Date: \s+ (.+?)$
397}xms;
398
399 pos($_) = index($_, $date) + length $date;
400
401 # Yeah, I just did that.
402
403 my($msg) = /\G\s+(\S.*)/sg;
404
405 {
406 hash => $sha1,
407 type => $type,
408 author => $author,
409
410 # XXX Add DateTime goodness.
411 date => $date,
412 message => $msg,
413 };
1ef8dc7d 414 } @entries;
c5065c66 415}
416
fbf3eb7e 417sub get_heads {
1feb3d6b 418 my ($self, $project) = @_;
fbf3eb7e 419
1feb3d6b 420 my $output = $self->run_cmd_in($project, qw/for-each-ref --sort=-committerdate /, '--format=%(objectname)%00%(refname)%00%(committer)', 'refs/heads');
421 return unless $output;
fbf3eb7e 422
1feb3d6b 423 my @ret;
424 for my $line (split /\n/, $output) {
425 my ($rev, $head, $commiter) = split /\0/, $line, 3;
426 $head =~ s!^refs/heads/!!;
fbf3eb7e 427
1feb3d6b 428 push @ret, { rev => $rev, name => $head };
fbf3eb7e 429
1feb3d6b 430 #FIXME: That isn't the time I'm looking for..
431 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
432 my $dt = DateTime->from_epoch(epoch => $epoch);
433 $dt->set_time_zone($tz);
434 $ret[-1]->{last_change} = $dt;
fbf3eb7e 435 }
1feb3d6b 436 }
fbf3eb7e 437
1feb3d6b 438 return \@ret;
fbf3eb7e 439}
440
1ef8dc7d 441=head2 refs_for
442
443Return a list of refs (e.g branches) for a given sha1.
444
445=cut
446
447sub refs_for {
448 my($self, $sha1) = @_;
449
450 my $refs = $self->references->{$sha1};
451
452 return $refs ? @$refs : ();
453}
454
455=head2
456
457A wrapper for C<git show-ref --dereference>. Based on gitweb's
458C<git_get_references>.
459
460=cut
461
462sub references {
463 my($self) = @_;
464
465 return $self->{references}
466 if $self->{references};
467
468 # 5dc01c595e6c6ec9ccda4f6f69c131c0dd945f8c refs/tags/v2.6.11
469 # c39ae07f393806ccf406ef966e9a15afc43cc36a refs/tags/v2.6.11^{}
b3ad9e63 470 my @reflist = $self->command(qw(show-ref --dereference))
1ef8dc7d 471 or return;
472
473 my %refs;
b3ad9e63 474 for(@reflist) {
1ef8dc7d 475 push @{$refs{$1}}, $2
b3ad9e63 476 if m!^($SHA1RE)\srefs/(.*)$!;
1ef8dc7d 477 }
478
479 return $self->{references} = \%refs;
480}
481
b3ad9e63 482=begin
483
484$ git diff-tree -r --no-commit-id -M b222ff0a7260cc1777c7e455dfcaf22551a512fc 7e54e579e196c6c545fee1030175f65a111039d4
485:100644 100644 8976ebc7df65475b3def53a1653533c3f61070d0 852b6e170f1bad1fbd9930d3178dda8fdf1feae7 M TODO
486:100644 100644 75f5e5f9ed10ae82a960fde77ecf138159c37610 7f54f8c3a4ad426f6889b13cfba5f5ad9969e3c6 M lib/Gitalist/Controller/Root.pm
487:100644 100644 2c65caa46b56302502b9e6eef952b6f379c71fee e418acf5f7b5f771b0b2ef8be784e8dcd60a4271 M lib/Gitalist/View/Default.pm
488:000000 100644 0000000000000000000000000000000000000000 642599f9ccfc4dbc7034987ad3233655010ff348 A lib/Gitalist/View/SyntaxHighlight.pm
489:000000 100644 0000000000000000000000000000000000000000 3d2e533c41f01276b6f844bae98297273b38dffc A root/static/css/syntax-dark.css
490:100644 100644 6a85d6c6315b55a99071974eb6ce643aeb2799d6 44c03ed6c328fa6de4b1d9b3f19a3de96b250370 M templates/blob.tt2
491
492=cut
493
494use List::MoreUtils qw(zip);
495# XXX Hrm, getting called twice, not sure why.
496sub diff_tree {
497 my($self, $commit) = @_;
498
499 my @dtout = $self->command(
500 # XXX should really deal with multple parents ...
501 qw(diff-tree -r --no-commit-id -M), $commit->parent_sha1, $commit->sha1
502 );
503
504 my @keys = qw(modesrc modedst sha1src sha1dst status src dst);
505 my @difftree = map {
506 # see. man git-diff-tree for more info
507 # mode src, mode dst, sha1 src, sha1 dst, status, src[, dst]
508 my @vals = /^:(\d+) (\d+) ($SHA1RE) ($SHA1RE) ([ACDMRTUX])\t([^\t]+)(?:\t([^\n]+))?$/;
509 my %line = zip @keys, @vals;
510 # Some convenience keys
511 $line{file} = $line{src};
c8870bd3 512 $line{sha1} = $line{sha1dst};
b3ad9e63 513 $line{is_new} = $line{sha1src} =~ /^0+$/;
514 \%line;
515 } @dtout;
516
517 return @difftree;
518}
519
fbf3eb7e 520sub archive {
1feb3d6b 521 my ($self, $project, $rev) = @_;
fbf3eb7e 522
1feb3d6b 523 #FIXME: huge memory consuption
524 #TODO: compression
525 return $self->run_cmd_in($project, qw/archive --format=tar/, "--prefix=${project}/", $rev);
fbf3eb7e 526}
527
5281;
529
530__PACKAGE__->meta->make_immutable;