Another freshly added action - /tree.
[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
27e05d7b 19=head1 NAME
20
21Gitalist::Model::Git - the model for git interactions
22
23=head1 DESCRIPTION
24
25[enter your description here]
26
27=head1 METHODS
28
29=cut
30
b3ad9e63 31# Should these live in a separate module? Or perhaps extended Regexp::Common?
32our $SHA1RE = qr/[0-9a-fA-F]{40}/;
33
1feb3d6b 34has project => ( isa => NonEmptySimpleStr, is => 'rw');
8c032474 35has repo_dir => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 ); # Fixme - path::class
36has git => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 );
1feb3d6b 37
27e05d7b 38=head2 BUILD
39
40=cut
41
8c032474 42sub BUILD {
43 my ($self) = @_;
44 $self->git; # Cause lazy value build.
1feb3d6b 45 $self->repo_dir;
8c032474 46}
47
1ef8dc7d 48use Git::PurePerl;
49
50has gpp => (
51 #isa => 'Git::PurePerl'
52 is => 'ro',
53 required => 1,
54 lazy => 1,
55 default => sub {
56 my($self) = @_;
27e05d7b 57 (my $pd = $self->project_dir( $self->project )) =~ s{/\.git$}();
1ef8dc7d 58 return Git::PurePerl->new(
27e05d7b 59 directory => $pd
1ef8dc7d 60 );
61 },
62);
63
8c032474 64sub _build_git {
1feb3d6b 65 my $git = File::Which::which('git');
d7c9a32f 66
1feb3d6b 67 if (!$git) {
68 die <<EOR;
fbf3eb7e 69Could not find a git executable.
70Please specify the which git executable to use in gitweb.yml
71EOR
1feb3d6b 72 }
fbf3eb7e 73
1feb3d6b 74 return $git;
04d1d917 75}
1feb3d6b 76
8c032474 77sub _build_repo_dir {
1ef8dc7d 78 return Gitalist->config->{repo_dir};
79}
80
27e05d7b 81=head2 get_object
82
83A wrapper for the equivalent L<Git::PurePerl> method.
84
85=cut
86
1ef8dc7d 87sub get_object {
88 $_[0]->gpp->get_object($_[1]);
8c032474 89}
d7c9a32f 90
27e05d7b 91=head2 is_git_repo
92
93Determine whether a given directory (as a L<Path::Class::Dir> object) is a
94C<git> repo.
95
96=cut
97
fbf3eb7e 98sub is_git_repo {
1feb3d6b 99 my ($self, $dir) = @_;
fbf3eb7e 100
1feb3d6b 101 return -f $dir->file('HEAD') || -f $dir->file('.git/HEAD');
fbf3eb7e 102}
103
27e05d7b 104=head2 run_cmd
105
106Call out to the C<git> binary and return a string consisting of the output.
107
108=cut
109
1ef8dc7d 110sub run_cmd {
111 my ($self, @args) = @_;
112
113 print STDERR 'RUNNING: ', $self->git, qq[ @args], $/;
114
115 open my $fh, '-|', $self->git, @args
116 or die "failed to run git command";
117 binmode $fh, ':encoding(UTF-8)';
118
119 my $output = do { local $/ = undef; <$fh> };
120 close $fh;
121
122 return $output;
123}
124
27e05d7b 125=head2 project_dir
126
127The directory under which the given project will reside i.e C<.git/..>
128
129=cut
130
1ef8dc7d 131sub project_dir {
132 my($self, $project) = @_;
133
134 my $dir = blessed($project) && $project->isa('Path::Class::Dir')
135 ? $project->stringify
27e05d7b 136 : $self->dir_from_project_name($project);
1ef8dc7d 137
27e05d7b 138 $dir .= '/.git'
139 if -f dir($dir)->file('.git/HEAD');
1ef8dc7d 140
141 return $dir;
142}
143
27e05d7b 144=head2 run_cmd_in
145
146Run a C<git> command in a given project and return the output as a string.
147
148=cut
149
1ef8dc7d 150sub run_cmd_in {
151 my ($self, $project, @args) = @_;
152
27e05d7b 153 return $self->run_cmd('--git-dir' => $self->project_dir($project), @args);
1ef8dc7d 154}
155
27e05d7b 156=head2 command
157
158Run a C<git> command for the project specified in the C<p> parameter and
159return the output as a list of strings corresponding to the lines of output.
160
161=cut
162
b3ad9e63 163sub command {
164 my($self, @args) = @_;
165
27e05d7b 166 my $output = $self->run_cmd('--git-dir' => $self->project_dir($self->project), @args);
b3ad9e63 167
168 return $output ? split(/\n/, $output) : ();
169}
170
27e05d7b 171=head2 project_info
172
173Returns a hash corresponding to a given project's properties. The keys will
174be:
175
176 name
177 description (empty if .git/description is empty/unnamed)
178 owner
179 last_change
180
181=cut
182
fbf3eb7e 183sub project_info {
1feb3d6b 184 my ($self, $project) = @_;
fbf3eb7e 185
1feb3d6b 186 return {
187 name => $project,
188 $self->get_project_properties(
27e05d7b 189 $self->dir_from_project_name($project),
190 ),
191 };
fbf3eb7e 192}
193
27e05d7b 194=head2 get_project_properties
195
196Called by C<project_info> to get a project's properties.
197
198=cut
199
fbf3eb7e 200sub get_project_properties {
1feb3d6b 201 my ($self, $dir) = @_;
202 my %props;
fbf3eb7e 203
1feb3d6b 204 eval {
205 $props{description} = $dir->file('description')->slurp;
206 chomp $props{description};
fbf3eb7e 207 };
208
1feb3d6b 209 if ($props{description} && $props{description} =~ /^Unnamed repository;/) {
210 delete $props{description};
211 }
fbf3eb7e 212
1feb3d6b 213 ($props{owner} = (getpwuid $dir->stat->uid)[6]) =~ s/,+$//;
fbf3eb7e 214
1feb3d6b 215 my $output = $self->run_cmd_in($dir, qw{
216 for-each-ref --format=%(committer)
217 --sort=-committerdate --count=1 refs/heads
218 });
fbf3eb7e 219
1feb3d6b 220 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
221 my $dt = DateTime->from_epoch(epoch => $epoch);
222 $dt->set_time_zone($tz);
223 $props{last_change} = $dt;
224 }
fbf3eb7e 225
1feb3d6b 226 return %props;
fbf3eb7e 227}
228
27e05d7b 229=head2 list_projects
230
231For the C<repo_dir> specified in the config return an array of projects where
232each item will contain the contents of L</project_info>.
233
234=cut
235
fbf3eb7e 236sub list_projects {
27e05d7b 237 my ($self, $dir) = @_;
fbf3eb7e 238
27e05d7b 239 my $base = dir($dir || $self->repo_dir);
fbf3eb7e 240
1feb3d6b 241 my @ret;
242 my $dh = $base->open;
243 while (my $file = $dh->read) {
244 next if $file =~ /^.{1,2}$/;
245
246 my $obj = $base->subdir($file);
247 next unless -d $obj;
248 next unless $self->is_git_repo($obj);
249
250 # XXX Leaky abstraction alert!
251 my $is_bare = !-d $obj->subdir('.git');
d7c9a32f 252
1feb3d6b 253 my $name = (File::Spec->splitdir($obj))[-1];
254 push @ret, {
27e05d7b 255 name => ($name . ( $is_bare ? '' : '/.git' )),
1feb3d6b 256 $self->get_project_properties(
257 $is_bare ? $obj : $obj->subdir('.git')
258 ),
259 };
260 }
261
262 return [sort { $a->{name} cmp $b->{name} } @ret];
fbf3eb7e 263}
264
27e05d7b 265=head2 dir_from_project_name
266
267Get the corresponding directory of a given project.
268
269=cut
270
271sub dir_from_project_name {
1feb3d6b 272 my ($self, $project) = @_;
fbf3eb7e 273
1feb3d6b 274 return dir($self->repo_dir)->subdir($project);
fbf3eb7e 275}
276
27e05d7b 277=head2 head_hash
278
279Find the C<HEAD> of given (or current) project.
280
281=cut
282
c8870bd3 283sub head_hash {
1feb3d6b 284 my ($self, $project) = @_;
fbf3eb7e 285
c8870bd3 286 my $output = $self->run_cmd_in($project || $self->project, qw/rev-parse --verify HEAD/ );
1feb3d6b 287 return unless defined $output;
fbf3eb7e 288
b3ad9e63 289 my ($head) = $output =~ /^($SHA1RE)$/;
1feb3d6b 290 return $head;
fbf3eb7e 291}
292
27e05d7b 293=head2 list_tree
294
295For a given tree sha1 return an array describing the tree's contents. Where
296the keys for each item will be:
297
298 mode
299 type
300 object
301 file
302
303=cut
304
fbf3eb7e 305sub list_tree {
27e05d7b 306 my ($self, $rev, $project) = @_;
fbf3eb7e 307
27e05d7b 308 $project ||= $self->project;
c8870bd3 309 $rev ||= $self->head_hash($project);
fbf3eb7e 310
1feb3d6b 311 my $output = $self->run_cmd_in($project, qw/ls-tree -z/, $rev);
312 return unless defined $output;
fbf3eb7e 313
1feb3d6b 314 my @ret;
315 for my $line (split /\0/, $output) {
316 my ($mode, $type, $object, $file) = split /\s+/, $line, 4;
d7c9a32f 317
1feb3d6b 318 push @ret, {
b3fa97cd 319 mode => oct $mode,
320 modestr => $self->get_object_mode_string({mode=>oct $mode}),
321 type => $type,
322 object => $object,
323 file => $file,
27e05d7b 324 };
1feb3d6b 325 }
326
327 return @ret;
fbf3eb7e 328}
329
27e05d7b 330=head2 get_object_mode_string
331
332Provide a string equivalent of an octal mode e.g 0644 eq '-rw-r--r--'.
333
334=cut
335
fbf3eb7e 336sub get_object_mode_string {
1feb3d6b 337 my ($self, $object) = @_;
fbf3eb7e 338
1feb3d6b 339 return unless $object && $object->{mode};
340 return mode_to_string($object->{mode});
fbf3eb7e 341}
342
27e05d7b 343=head2 get_object_type
344
345=cut
346
fbf3eb7e 347sub get_object_type {
27e05d7b 348 my ($self, $object, $project) = @_;
1feb3d6b 349
27e05d7b 350 chomp(my $output = $self->run_cmd_in($project || $self->project, qw/cat-file -t/, $object));
1feb3d6b 351 return unless $output;
352
1feb3d6b 353 return $output;
354}
355
27e05d7b 356=head2 cat_file
357
358Return the contents of a given file.
359
360=cut
361
362sub cat_file {
363 my ($self, $object, $project) = @_;
364
365 my $type = $self->get_object_type($object);
366 die "object `$object' is not a file\n"
367 if (!defined $type || $type ne 'blob');
368
369 my $output = $self->run_cmd_in($project || $self->project, qw/cat-file -p/, $object);
370 return unless $output;
371
372 return $output;
373}
374
375=head2 hash_by_path
376
377For a given sha1 and path find the corresponding hash. Useful for find blobs.
378
379=cut
380
c8870bd3 381sub hash_by_path {
1feb3d6b 382 my($self, $base, $path, $type) = @_;
fbf3eb7e 383
1feb3d6b 384 $path =~ s{/+$}();
fbf3eb7e 385
c8870bd3 386 my($line) = $self->command('ls-tree', $base, '--', $path)
1feb3d6b 387 or return;
388
389 #'100644 blob 0fa3f3a66fb6a137f6ec2c19351ed4d807070ffa panic.c'
b3ad9e63 390 $line =~ m/^([0-9]+) (.+) ($SHA1RE)\t/;
1feb3d6b 391 return defined $type && $type ne $2
392 ? ()
c8870bd3 393 : $3;
295c9703 394}
395
27e05d7b 396=head2 valid_rev
fbf3eb7e 397
27e05d7b 398Check whether a given rev is valid i.e looks like a sha1.
fbf3eb7e 399
27e05d7b 400=cut
fbf3eb7e 401
402sub valid_rev {
1feb3d6b 403 my ($self, $rev) = @_;
fbf3eb7e 404
1feb3d6b 405 return unless $rev;
b3ad9e63 406 return ($rev =~ /^($SHA1RE)$/);
fbf3eb7e 407}
408
27e05d7b 409=head2 diff
410
411
412
413=cut
414
fbf3eb7e 415sub diff {
27e05d7b 416 my ($self, @revs, $project) = @_;
fbf3eb7e 417
1feb3d6b 418 croak("Gitalist::Model::Git::diff needs a project and either one or two revisions")
419 if scalar @revs < 1
420 || scalar @revs > 2
421 || any { !$self->valid_rev($_) } @revs;
fbf3eb7e 422
27e05d7b 423 my $output = $self->run_cmd_in($project || $self->project, 'diff', @revs);
1feb3d6b 424 return unless $output;
fbf3eb7e 425
1feb3d6b 426 return $output;
fbf3eb7e 427}
428
27e05d7b 429=head2 parse_rev_list
430
431Given the output of the C<rev-list> command return a list of hashes.
432
433=cut
434
47495599 435sub parse_rev_list {
436 my ($self, $output) = @_;
437 my @ret;
438
439 my @revs = split /\0/, $output;
1feb3d6b 440
47495599 441 for my $rev (split /\0/, $output) {
442 for my $line (split /\n/, $rev, 6) {
443 chomp $line;
444 next unless $line;
445
446 if ($self->valid_rev($line)) {
447 push @ret, $self->get_object($line);
448 }
449 }
1feb3d6b 450 }
47495599 451
452 return @ret;
fbf3eb7e 453}
454
27e05d7b 455=head2 list_revs
456
457Calls the C<rev-list> command (a low-level from of C<log>) and returns an
458array of hashes.
459
460=cut
461
fbf3eb7e 462sub list_revs {
27e05d7b 463 my ($self, %args) = @_;
fbf3eb7e 464
27e05d7b 465 $args{rev} ||= $self->head_hash($args{project});
fbf3eb7e 466
27e05d7b 467 my $output = $self->run_cmd_in($args{project} || $self->project, 'rev-list',
1feb3d6b 468 '--header',
469 (defined $args{ count } ? "--max-count=$args{count}" : ()),
27e05d7b 470 (defined $args{ skip } ? "--skip=$args{skip}" : ()),
1feb3d6b 471 $args{rev},
472 '--',
27e05d7b 473 ($args{file} ? $args{file} : ()),
474 );
1feb3d6b 475 return unless $output;
fbf3eb7e 476
1feb3d6b 477 my @revs = $self->parse_rev_list($output);
fbf3eb7e 478
1feb3d6b 479 return \@revs;
fbf3eb7e 480}
481
27e05d7b 482=head2 rev_info
483
484Get a single piece of revision information for a given sha1.
485
486=cut
487
fbf3eb7e 488sub rev_info {
27e05d7b 489 my($self, $rev, $project) = @_;
fbf3eb7e 490
1feb3d6b 491 return unless $self->valid_rev($rev);
c5065c66 492
27e05d7b 493 return $self->list_revs(
494 rev => $rev, count => 1,
495 ( $project ? (project => $project) : () )
496 );
1feb3d6b 497}
498
27e05d7b 499=head2 reflog
500
501Calls the C<reflog> command and returns a list of hashes.
502
503=cut
504
1feb3d6b 505sub reflog {
506 my ($self, @logargs) = @_;
507
508 my @entries
509 = $self->run_cmd_in($self->project, qw(log -g), @logargs)
510 =~ /(^commit.+?(?:(?=^commit)|(?=\z)))/msg;
511
512=begin
513
514 commit 02526fc15beddf2c64798a947fecdd8d11bf993d
515 Reflog: HEAD@{14} (The Git Server <git@git.dev.venda.com>)
516 Reflog message: push
1ef8dc7d 517 Author: Foo Barsby <fbarsby@example.com>
1feb3d6b 518 Date: Thu Sep 17 12:26:05 2009 +0100
519
1ef8dc7d 520 Merge branch 'abc123'
1feb3d6b 521=cut
522
523 return map {
524
525 # XXX Stuff like this makes me want to switch to Git::PurePerl
526 my($sha1, $type, $author, $date)
527 = m{
b3ad9e63 528 ^ commit \s+ ($SHA1RE)$
1feb3d6b 529 .*?
530 Reflog[ ]message: \s+ (.+?)$ \s+
531 Author: \s+ ([^<]+) <.*?$ \s+
532 Date: \s+ (.+?)$
27e05d7b 533 }xms;
1feb3d6b 534
535 pos($_) = index($_, $date) + length $date;
536
537 # Yeah, I just did that.
538
539 my($msg) = /\G\s+(\S.*)/sg;
540
541 {
542 hash => $sha1,
543 type => $type,
544 author => $author,
545
546 # XXX Add DateTime goodness.
547 date => $date,
548 message => $msg,
549 };
1ef8dc7d 550 } @entries;
c5065c66 551}
552
27e05d7b 553=head2 get_heads
554
555Returns an array of hashes representing the heads (aka branches) for the
556given, or current, project.
557
558=cut
559
fbf3eb7e 560sub get_heads {
1feb3d6b 561 my ($self, $project) = @_;
fbf3eb7e 562
27e05d7b 563 my $output = $self->run_cmd_in($project || $self->project, qw/for-each-ref --sort=-committerdate /, '--format=%(objectname)%00%(refname)%00%(committer)', 'refs/heads');
1feb3d6b 564 return unless $output;
fbf3eb7e 565
1feb3d6b 566 my @ret;
567 for my $line (split /\n/, $output) {
568 my ($rev, $head, $commiter) = split /\0/, $line, 3;
569 $head =~ s!^refs/heads/!!;
fbf3eb7e 570
1feb3d6b 571 push @ret, { rev => $rev, name => $head };
fbf3eb7e 572
1feb3d6b 573 #FIXME: That isn't the time I'm looking for..
574 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
575 my $dt = DateTime->from_epoch(epoch => $epoch);
576 $dt->set_time_zone($tz);
577 $ret[-1]->{last_change} = $dt;
fbf3eb7e 578 }
1feb3d6b 579 }
fbf3eb7e 580
1feb3d6b 581 return \@ret;
fbf3eb7e 582}
583
1ef8dc7d 584=head2 refs_for
585
27e05d7b 586For a given sha1 check which branches currently point at it.
1ef8dc7d 587
588=cut
589
590sub refs_for {
591 my($self, $sha1) = @_;
592
593 my $refs = $self->references->{$sha1};
594
595 return $refs ? @$refs : ();
596}
597
27e05d7b 598=head2 references
1ef8dc7d 599
600A wrapper for C<git show-ref --dereference>. Based on gitweb's
601C<git_get_references>.
602
603=cut
604
605sub references {
606 my($self) = @_;
607
608 return $self->{references}
609 if $self->{references};
610
611 # 5dc01c595e6c6ec9ccda4f6f69c131c0dd945f8c refs/tags/v2.6.11
612 # c39ae07f393806ccf406ef966e9a15afc43cc36a refs/tags/v2.6.11^{}
b3ad9e63 613 my @reflist = $self->command(qw(show-ref --dereference))
1ef8dc7d 614 or return;
615
616 my %refs;
b3ad9e63 617 for(@reflist) {
1ef8dc7d 618 push @{$refs{$1}}, $2
b3ad9e63 619 if m!^($SHA1RE)\srefs/(.*)$!;
1ef8dc7d 620 }
621
622 return $self->{references} = \%refs;
623}
624
b3ad9e63 625=begin
626
627$ git diff-tree -r --no-commit-id -M b222ff0a7260cc1777c7e455dfcaf22551a512fc 7e54e579e196c6c545fee1030175f65a111039d4
628:100644 100644 8976ebc7df65475b3def53a1653533c3f61070d0 852b6e170f1bad1fbd9930d3178dda8fdf1feae7 M TODO
629:100644 100644 75f5e5f9ed10ae82a960fde77ecf138159c37610 7f54f8c3a4ad426f6889b13cfba5f5ad9969e3c6 M lib/Gitalist/Controller/Root.pm
630:100644 100644 2c65caa46b56302502b9e6eef952b6f379c71fee e418acf5f7b5f771b0b2ef8be784e8dcd60a4271 M lib/Gitalist/View/Default.pm
631:000000 100644 0000000000000000000000000000000000000000 642599f9ccfc4dbc7034987ad3233655010ff348 A lib/Gitalist/View/SyntaxHighlight.pm
632:000000 100644 0000000000000000000000000000000000000000 3d2e533c41f01276b6f844bae98297273b38dffc A root/static/css/syntax-dark.css
633:100644 100644 6a85d6c6315b55a99071974eb6ce643aeb2799d6 44c03ed6c328fa6de4b1d9b3f19a3de96b250370 M templates/blob.tt2
634
635=cut
636
637use List::MoreUtils qw(zip);
638# XXX Hrm, getting called twice, not sure why.
27e05d7b 639=head2 diff_tree
640
641Given a L<Git::PurePerl> commit object return a list of hashes corresponding
642to the C<diff-tree> output.
643
644=cut
645
b3ad9e63 646sub diff_tree {
647 my($self, $commit) = @_;
648
649 my @dtout = $self->command(
650 # XXX should really deal with multple parents ...
651 qw(diff-tree -r --no-commit-id -M), $commit->parent_sha1, $commit->sha1
652 );
653
654 my @keys = qw(modesrc modedst sha1src sha1dst status src dst);
655 my @difftree = map {
656 # see. man git-diff-tree for more info
657 # mode src, mode dst, sha1 src, sha1 dst, status, src[, dst]
658 my @vals = /^:(\d+) (\d+) ($SHA1RE) ($SHA1RE) ([ACDMRTUX])\t([^\t]+)(?:\t([^\n]+))?$/;
659 my %line = zip @keys, @vals;
660 # Some convenience keys
661 $line{file} = $line{src};
c8870bd3 662 $line{sha1} = $line{sha1dst};
b3ad9e63 663 $line{is_new} = $line{sha1src} =~ /^0+$/;
664 \%line;
665 } @dtout;
666
667 return @difftree;
668}
669
fbf3eb7e 6701;
671
672__PACKAGE__->meta->make_immutable;