::Project cleaning part 2.
[catagits/Gitalist.git] / lib / Gitalist / Git / Project.pm
1 use MooseX::Declare;
2
3 =head1 NAME
4
5 Gitalist::Git::Project - Model of a git repository
6
7 =head1 SYNOPSIS
8
9     my $project = Gitalist::Git::Project->new( name => 'Gitalist',
10                                                path => $project_dir );
11     my $project = Gitalist::Git::Project->new($project_dir);
12
13 =head1 DESCRIPTION
14
15 This class models a git repository, referred to in Gitalist
16 as a "Project".
17
18 =cut
19
20 class Gitalist::Git::Project with Gitalist::Git::HasUtils {
21     # FIXME, use Types::Path::Class and coerce
22     use MooseX::Types::Common::String qw/NonEmptySimpleStr/;
23     use MooseX::Types::Path::Class qw/Dir/;
24     use MooseX::Types::Moose qw/Str Maybe Bool HashRef/;
25     use List::MoreUtils qw/any zip/;
26     use DateTime;
27     use aliased 'Gitalist::Git::Object';
28
29 =head1 ATTRIBUTES
30
31 =head2 name
32
33 =cut
34
35     has name => ( isa => NonEmptySimpleStr,
36                   is => 'ro', required => 1 );
37
38 =head2 path
39
40 L<Path::Class:Dir> for the location of the git repository.
41
42 =cut
43
44     has path => ( isa => Dir,
45                   is => 'ro', required => 1);
46
47 =head2 description
48
49 String containing .git/description
50
51 =cut
52
53     has description => ( isa => Str,
54                          is => 'ro',
55                          lazy_build => 1,
56                      );
57
58 =head2 owner
59
60 Owner of the files on disk.
61
62 =cut
63
64     has owner => ( isa => NonEmptySimpleStr,
65                    is => 'ro',
66                    lazy_build => 1,
67                );
68
69 =head2 last_change
70
71 L<DateTime> for the time of the last update.
72 undef if the repository has never been used.
73
74 =cut
75
76     has last_change => ( isa => Maybe['DateTime'],
77                          is => 'ro',
78                          lazy_build => 1,
79                      );
80
81 =head2 is_bare
82
83 Bool indicating whether this Project is bare.
84
85 =cut
86
87     has is_bare => ( isa => Bool,
88                      is => 'ro',
89                      lazy => 1,
90                      default => sub {
91                          -d $_[0]->path->parent->subdir->($_[0]->name)
92                              ? 1 : 0
93                          },
94                      );
95
96     method BUILD {
97         $self->$_() for qw/last_change owner description/; # Ensure to build early.
98     }
99
100     around BUILDARGS (ClassName $class: Dir $dir) {
101         my $name = $dir->dir_list(-1);
102         $dir = $dir->subdir('.git') if (-f $dir->file('.git', 'HEAD'));
103         confess("Can't find a git repository at " . $dir)
104             unless ( -f $dir->file('HEAD') );
105         return $class->$orig(name => $name,
106                              path => $dir);
107     }
108
109     method _build__util {
110         Gitalist::Git::Util->new(
111             project => $self,
112         );
113     }
114
115     our $SHA1RE = qr/[0-9a-fA-F]{40}/;
116
117     method _build_description {
118         my $description = "";
119         eval {
120             $description = $self->{path}->file('description')->slurp;
121             chomp $description;
122         };
123         return $description;
124     }
125
126     method _build_owner {
127         my ($gecos, $name) = (getpwuid $self->{path}->stat->uid)[6,0];
128         $gecos =~ s/,+$//;
129         return length($gecos) ? $gecos : $name;
130     }
131
132     method _build_last_change {
133         my $last_change;
134         my $output = $self->run_cmd(
135             qw{ for-each-ref --format=%(committer)
136                 --sort=-committerdate --count=1 refs/heads
137           });
138         if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
139             my $dt = DateTime->from_epoch(epoch => $epoch);
140             $dt->set_time_zone($tz);
141             $last_change = $dt;
142         }
143         return $last_change;
144     }
145
146 =head2 heads
147
148 Return an array containing the list of heads.
149
150 =cut
151
152     method heads {
153         my $cmdout = $self->run_cmd(qw/for-each-ref --sort=-committerdate /, '--format=%(objectname)%00%(refname)%00%(committer)', 'refs/heads');
154         my @output = $cmdout ? split(/\n/, $cmdout) : ();
155         my @ret;
156         for my $line (@output) {
157             my ($rev, $head, $commiter) = split /\0/, $line, 3;
158             $head =~ s!^refs/heads/!!;
159
160             push @ret, { sha1 => $rev, name => $head };
161
162             #FIXME: That isn't the time I'm looking for..
163             if (my ($epoch, $tz) = $line =~ /\s(\d+)\s+([+-]\d+)$/) {
164                 my $dt = DateTime->from_epoch(epoch => $epoch);
165                 $dt->set_time_zone($tz);
166                 $ret[-1]->{last_change} = $dt;
167             }
168         }
169
170         return @ret;
171     }
172
173 =head2 references
174
175 Return a hash of references.
176
177 =cut
178
179     method references {
180         return $self->{references}
181                 if $self->{references};
182
183         # 5dc01c595e6c6ec9ccda4f6f69c131c0dd945f8c refs/tags/v2.6.11
184         # c39ae07f393806ccf406ef966e9a15afc43cc36a refs/tags/v2.6.11^{}
185         my $cmdout = $self->run_cmd(qw(show-ref --dereference))
186                 or return;
187         my @reflist = $cmdout ? split(/\n/, $cmdout) : ();
188         my %refs;
189         for(@reflist) {
190                 push @{$refs{$1}}, $2
191                         if m!^($SHA1RE)\srefs/(.*)$!;
192         }
193
194         return $self->{references} = \%refs;
195 }
196
197 =head2 head_hash
198
199 Find the hash of a given head (defaults to HEAD).
200
201 =cut
202
203     method head_hash (Str $head?) {
204         my $output = $self->run_cmd(qw/rev-parse --verify/, $head || 'HEAD' );
205         return unless defined $output;
206
207         my($sha1) = $output =~ /^($SHA1RE)$/;
208         return $sha1;
209     }
210
211 =head2 list_tree
212
213 Return an array of contents for a given tree.
214 The tree is specified by sha1, and defaults to HEAD.
215 The keys for each item will be:
216
217         mode
218         type
219         object
220         file
221
222 =cut
223
224     method list_tree (Str $sha1?) {
225         $sha1 ||= $self->head_hash;
226
227         my $output = $self->run_cmd(qw/ls-tree -z/, $sha1);
228         return unless defined $output;
229
230         my @ret;
231         for my $line (split /\0/, $output) {
232             my ($mode, $type, $object, $file) = split /\s+/, $line, 4;
233             push @ret, Object->new( mode => oct $mode,
234                                     type => $type,
235                                     sha1 => $object,
236                                     file => $file,
237                                     project => $self,
238                                   );
239         }
240         return @ret;
241     }
242
243     method get_object (NonEmptySimpleStr $sha1) {
244         unless ( $self->_is_valid_rev($sha1) ) {
245             $sha1 = $self->head_hash($sha1);
246         }
247         return Object->new(
248             project => $self,
249             sha1 => $sha1,
250         );
251     }
252
253     method _is_valid_rev (Str $rev) {
254         return ($rev =~ /^($SHA1RE)$/);
255     }
256
257     # Should be in ::Object
258     method get_object_mode_string (Gitalist::Git::Object $object) {
259         return $object->modestr;
260     }
261
262     method get_object_type (NonEmptySimpleStr $sha1) {
263         return $self->get_object($sha1)->type;
264     }
265
266     method cat_file (NonEmptySimpleStr $sha1) {
267         return $self->get_object($sha1)->contents;
268     }
269
270     method hash_by_path ($base, $path?, $type?) {
271         $path ||= '';
272         $path =~ s{/+$}();
273
274         my $output = $self->run_cmd('ls-tree', $base, '--', $path)
275             or return;
276         my($line) = $output ? split(/\n/, $output) : ();
277
278         #'100644 blob 0fa3f3a66fb6a137f6ec2c19351ed4d807070ffa  panic.c'
279         $line =~ m/^([0-9]+) (.+) ($SHA1RE)\t/;
280         return defined $type && $type ne $2
281             ? ()
282                 : $3;
283     }
284
285     method list_revs ( NonEmptySimpleStr :$sha1!,
286                        Int :$count?,
287                        Int :$skip?,
288                        HashRef :$search?,
289                        NonEmptySimpleStr :$file?
290                    ) {
291         $sha1 = $self->head_hash($sha1)
292             if !$sha1 || $sha1 !~ $SHA1RE;
293
294         my @search_opts;
295         if($search) {
296             $search->{type} = 'grep'
297                 if $search->{type} eq 'commit';
298             @search_opts = (
299                 # This seems a little fragile ...
300                 qq[--$search->{type}=$search->{text}],
301                 '--regexp-ignore-case',
302                 $search->{regexp} ? '--extended-regexp' : '--fixed-strings'
303             );
304         }
305
306         my $output = $self->run_cmd(
307             'rev-list',
308             '--header',
309             (defined $count ? "--max-count=$count" : ()),
310             (defined $skip ? "--skip=$skip"       : ()),
311             @search_opts,
312             $sha1,
313             '--',
314             ($file ? $file : ()),
315         );
316         return unless $output;
317
318         my @revs = $self->parse_rev_list($output);
319
320         return @revs;
321     }
322
323     method parse_rev_list ($output) {
324         return
325             map  $self->get_gpp_object($_),
326                 grep $self->_is_valid_rev($_),
327                     map  split(/\n/, $_, 6), split /\0/, $output;
328     }
329
330     # XXX Ideally this would return a wee object instead of ad hoc structures.
331     method diff ( Gitalist::Git::Object :$commit,
332                   Bool :$patch?,
333                   Maybe[NonEmptySimpleStr] :$parent?,
334                   NonEmptySimpleStr :$file? ) {
335         # Use parent if specifed, else take the parent from the commit
336         # if there is only one, otherwise it was a merge commit.
337         $parent = $parent
338             ? $parent
339             : $commit->parents <= 1
340             ? $commit->parent_sha1
341             : '-c';
342         my @etc = (
343             ( $file  ? ('--', $file) : () ),
344         );
345
346         my @out = $self->raw_diff(
347             ( $patch ? '--patch-with-raw' : () ),
348             ( $parent ? $parent : () ),
349             $commit->sha1, @etc,
350         );
351
352         # XXX Yes, there is much wrongness having parse_diff_tree be destructive.
353         my @difftree = $self->parse_diff_tree(\@out);
354
355         return \@difftree
356             unless $patch;
357
358         # The blank line between the tree and the patch.
359         shift @out;
360
361         # XXX And no I'm not happy about having diff return tree + patch.
362         return \@difftree, [$self->parse_diff(@out)];
363     }
364
365     method parse_diff (@diff) {
366         my @ret;
367         for (@diff) {
368             # This regex is a little pathological.
369             if(m{^diff --git (a/(.*?)) (b/\2)}) {
370                 push @ret, {
371                     head => $_,
372                     a    => $1,
373                     b    => $3,
374                     file => $2,
375                     diff => '',
376                 };
377                 next;
378             }
379
380             if(/^index (\w+)\.\.(\w+) (\d+)$/) {
381                 @{$ret[-1]}{qw(index src dst mode)} = ($_, $1, $2, $3);
382                 next
383             }
384
385             # XXX Somewhat hacky. Ahem.
386             $ret[@ret ? -1 : 0]{diff} .= "$_\n";
387         }
388
389         return @ret;
390     }
391
392     # gitweb uses the following sort of command for diffing merges:
393 # /home/dbrook/apps/bin/git --git-dir=/home/dbrook/dev/app/.git diff-tree -r -M --no-commit-id --patch-with-raw --full-index --cc 316cf158df3f6207afbae7270bcc5ba0 --
394 # and for regular diffs
395 # /home/dbrook/apps/bin/git --git-dir=/home/dbrook/dev/app/.git diff-tree -r -M --no-commit-id --patch-with-raw --full-index 2e3454ca0749641b42f063730b0090e1 316cf158df3f6207afbae7270bcc5ba0 --
396
397     method raw_diff (@args) {
398         my $cmdout = $self->run_cmd(
399             qw(diff-tree -r -M --no-commit-id --full-index),
400             @args
401         );
402         return $cmdout ? split(/\n/, $cmdout) : ();
403     }
404
405     method parse_diff_tree ($diff) {
406         my @keys = qw(modesrc modedst sha1src sha1dst status src dst);
407         my @ret;
408         while (@$diff and $diff->[0] =~ /^:\d+/) {
409             my $line = shift @$diff;
410             # see. man git-diff-tree for more info
411             # mode src, mode dst, sha1 src, sha1 dst, status, src[, dst]
412             my @vals = $line =~ /^:(\d+) (\d+) ($SHA1RE) ($SHA1RE) ([ACDMRTUX]\d*)\t([^\t]+)(?:\t([^\n]+))?$/;
413             my %line = zip @keys, @vals;
414             # Some convenience keys
415             $line{file}   = $line{src};
416             $line{sha1}   = $line{sha1dst};
417             $line{is_new} = $line{sha1src} =~ /^0+$/
418                 if $line{sha1src};
419             @line{qw/status sim/} = $line{status} =~ /(R)(\d+)/
420                 if $line{status} =~ /^R/;
421             push @ret, \%line;
422         }
423
424         return @ret;
425     }
426
427     method reflog (@logargs) {
428         my @entries
429             =  $self->run_cmd(qw(log -g), @logargs)
430                 =~ /(^commit.+?(?:(?=^commit)|(?=\z)))/msg;
431
432 =pod
433   commit 02526fc15beddf2c64798a947fecdd8d11bf993d
434   Reflog: HEAD@{14} (The Git Server <git@git.dev.venda.com>)
435   Reflog message: push
436   Author: Foo Barsby <fbarsby@example.com>
437   Date:   Thu Sep 17 12:26:05 2009 +0100
438
439       Merge branch 'abc123'
440
441 =cut
442
443         return map {
444             # XXX Stuff like this makes me want to switch to Git::PurePerl
445             my($sha1, $type, $author, $date)
446                 = m{
447                        ^ commit \s+ ($SHA1RE)$
448                        .*?
449                        Reflog[ ]message: \s+ (.+?)$ \s+
450                      Author: \s+ ([^<]+) <.*?$ \s+
451                    Date: \s+ (.+?)$
452                }xms;
453
454             pos($_) = index($_, $date) + length $date;
455
456             # Yeah, I just did that.
457             my($msg) = /\G\s+(\S.*)/sg;
458             {
459                 hash    => $sha1,
460                 type    => $type,
461                 author  => $author,
462
463                 # XXX Add DateTime goodness.
464                 date    => $date,
465                 message => $msg,
466             }
467             ;
468         } @entries;
469     }
470
471     # Compatibility
472
473 =head2 info
474
475 Returns a hash containing properties of this project. The keys will
476 be:
477
478         name
479         description (empty if .git/description is empty/unnamed)
480         owner
481         last_change
482
483 =cut
484
485     method info {
486         return {
487             name => $self->name,
488             description => $self->description,
489             owner => $self->owner,
490             last_change => $self->last_change,
491         };
492     };
493
494 =head1 SEE ALSO
495
496 L<Gitalist::Git::Util> L<Gitalist::Git::Object>
497
498 =head1 AUTHORS AND COPYRIGHT
499
500   Catalyst application:
501     (C) 2009 Venda Ltd and Dan Brook <dbrook@venda.com>
502
503   Original gitweb.cgi from which this was derived:
504     (C) 2005-2006, Kay Sievers <kay.sievers@vrfy.org>
505     (C) 2005, Christian Gierke
506
507 =head1 LICENSE
508
509 FIXME - Is this going to be GPLv2 as per gitweb? If so this is broken..
510
511 This library is free software. You can redistribute it and/or modify
512 it under the same terms as Perl itself.
513
514 =cut
515
516 } # end class