fca63dcf980aaf6631e7fe6184a9b3829175c9ae
[catagits/Gitalist.git] / lib / Gitalist / Git / Repository.pm
1 use MooseX::Declare;
2
3 class Gitalist::Git::Repository with (Gitalist::Git::HasUtils, Gitalist::Git::Serializable) {
4     use MooseX::Storage::Meta::Attribute::Trait::DoNotSerialize;
5
6     use MooseX::Types::Common::String qw/NonEmptySimpleStr/;
7     use MooseX::Types::Moose          qw/Str Maybe Bool HashRef ArrayRef/;
8     use Gitalist::Git::Types          qw/SHA1 DateTime Dir/;
9
10     use Moose::Autobox;
11     use aliased 'DateTime' => 'DT';
12     use List::MoreUtils qw/any zip/;
13     use Encode          qw/decode/;
14
15     use if $^O ne 'MSWin32' => 'I18N::Langinfo', qw/langinfo CODESET/; 
16
17     use Gitalist::Git::Object::Blob;
18     use Gitalist::Git::Object::Tree;
19     use Gitalist::Git::Object::Commit;
20     use Gitalist::Git::Object::Tag;
21     use Gitalist::Git::Head;
22     use Gitalist::Git::Tag;
23
24     our $SHA1RE = qr/[0-9a-fA-F]{40}/;
25
26     around BUILDARGS (ClassName $class: Dir $dir, Str $override_name = '') {
27         # Allows us to be called as Repository->new($dir)
28         # Last path component becomes $self->name
29         # Full path to git objects becomes $self->path
30         my $name = $dir->dir_list(-1);
31         if(-f $dir->file('.git', 'HEAD')) { # Non-bare repo above .git
32             $dir  = $dir->subdir('.git');
33             $name = $dir->dir_list(-2, 1); # .../name/.git
34         } elsif('.git' eq $dir->dir_list(-1)) { # Non-bare repo in .git
35             $name = $dir->dir_list(-2);
36         }
37         confess("Can't find a git repository at " . $dir)
38             unless -f $dir->file('HEAD');
39         return $class->$orig(name => $override_name || $name,
40                              path => $dir);
41     }
42
43     has name => ( isa => NonEmptySimpleStr,
44                   is => 'ro', required => 1 );
45
46     has path => ( isa => Dir,
47                   is => 'ro', required => 1,
48                   traits => ['DoNotSerialize'] );
49
50     has description => ( isa => Str,
51                          is => 'ro',
52                          lazy_build => 1,
53                      );
54
55     has owner => ( isa => NonEmptySimpleStr,
56                    is => 'ro',
57                    lazy_build => 1,
58                );
59
60     has last_change => ( isa => Maybe[DateTime],
61                          is => 'ro',
62                          lazy_build => 1,
63                      );
64
65     has is_bare => ( isa => Bool,
66                      is => 'ro',
67                      lazy => 1,
68                      default => sub {
69                          -d $_[0]->path->parent->subdir($_[0]->name)
70                              ? 1 : 0
71                          },
72                      );
73     has heads => ( isa => ArrayRef['Gitalist::Git::Head'],
74                    is => 'ro',
75                    lazy_build => 1);
76     has tags => ( isa => ArrayRef['Gitalist::Git::Tag'],
77                    is => 'ro',
78                    lazy_build => 1);
79     has references => ( isa => HashRef[ArrayRef[Str]],
80                         is => 'ro',
81                         lazy_build => 1 );
82
83     method BUILD {
84         $self->$_() for qw/last_change owner description /; # Ensure to build early.
85     }
86
87     ## Public methods
88
89     method head_hash (Str $head?) {
90         my $output = $self->run_cmd(qw/rev-parse --verify/, $head || 'HEAD' );
91         confess("No such head: " . $head) unless defined $output;
92
93         my($sha1) = $output =~ /^($SHA1RE)$/;
94         return $sha1;
95     }
96
97     method get_object (NonEmptySimpleStr $sha1) {
98         unless (is_SHA1($sha1)) {
99             $sha1 = $self->head_hash($sha1);
100         }
101         my $type = $self->run_cmd('cat-file', '-t', $sha1);
102         chomp($type);
103         my $class = 'Gitalist::Git::Object::' . ucfirst($type);
104         $class->new(
105             repository => $self,
106             sha1 => $sha1,
107             type => $type,
108         );
109     }
110
111     method list_revs ( NonEmptySimpleStr :$sha1!,
112                        Int :$count?,
113                        Int :$skip?,
114                        HashRef :$search?,
115                        NonEmptySimpleStr :$file? ) {
116         $sha1 = $self->head_hash($sha1)
117             if !$sha1 || $sha1 !~ $SHA1RE;
118
119         my @search_opts;
120         if ($search and exists $search->{text}) {
121             $search->{type} = 'grep'
122                 if $search->{type} eq 'commit';
123             @search_opts = (
124                 # This seems a little fragile ...
125                 qq[--$search->{type}=$search->{text}],
126                 '--regexp-ignore-case',
127                 $search->{regexp} ? '--extended-regexp' : '--fixed-strings'
128             );
129         }
130
131         my $output = $self->run_cmd(
132             'rev-list',
133             '--header',
134             (defined $count ? "--max-count=$count" : ()),
135             (defined $skip ? "--skip=$skip"       : ()),
136             @search_opts,
137             $sha1,
138             '--',
139             ($file ? $file : ()),
140         );
141         return unless $output;
142
143         my @revs = $self->_parse_rev_list($output);
144
145         return @revs;
146     }
147
148     method snapshot (NonEmptySimpleStr :$sha1,
149                  NonEmptySimpleStr :$format
150                ) {
151         # TODO - only valid formats are 'tar' and 'zip'
152         my $formats = { tgz => 'tar', zip => 'zip' };
153         unless ($formats->exists($format)) {
154             die("No such format: $format");
155         }
156         $format = $formats->{$format};
157         my $name = $self->name;
158         $name =~ s,([^/])/*\.git$,$1,;
159         my $filename = $name;
160         $filename .= "-$sha1.$format";
161         $name =~ s/\047/\047\\\047\047/g;
162
163         my @cmd = ('archive', "--format=$format", "--prefix=$name/", $sha1);
164         return ($filename, $self->run_cmd_fh(@cmd));
165         # TODO - support compressed archives
166     }
167
168     method reflog (@logargs) {
169         my @entries
170             =  $self->run_cmd(qw(log -g), @logargs)
171                 =~ /(^commit.+?(?:(?=^commit)|(?=\z)))/msg;
172
173         #  commit 02526fc15beddf2c64798a947fecdd8d11bf993d
174         #  Reflog: HEAD@{14} (The Git Server <git@git.dev.venda.com>)
175         #  Reflog message: push
176         #  Author: Foo Barsby <fbarsby@example.com>
177         #  Date:   Thu Sep 17 12:26:05 2009 +0100
178         #
179         #      Merge branch 'abc123'
180
181         return map {
182             # XXX Stuff like this makes me want to switch to Git::PurePerl
183             my($sha1, $type, $author, $date)
184                 = m{
185                        ^ commit \s+ ($SHA1RE)$
186                        .*?
187                        Reflog[ ]message: \s+ (.+?)$ \s+
188                      Author: \s+ ([^<]+) <.*?$ \s+
189                    Date: \s+ (.+?)$
190                }xms;
191
192             pos($_) = index($_, $date) + length $date;
193
194             # Yeah, I just did that.
195             my($msg) = /\G\s+(\S.*)/sg;
196             {
197                 hash    => $sha1,
198                 type    => $type,
199                 author  => $author,
200
201                 # XXX Add DateTime goodness.
202                 date    => $date,
203                 message => $msg,
204             }
205             ;
206         } @entries;
207     }
208
209     ## BUILDERS
210     method _build_util {
211         Gitalist::Git::Util->new(
212             repository => $self,
213         );
214     }
215
216     method _build_description {
217         my $description = "";
218         eval {
219             $description = $self->path->file('description')->slurp;
220             chomp $description;
221         };
222         $description = "Unnamed repository, edit the .git/description file to set a description"
223             if $description eq "Unnamed repository; edit this file 'description' to name the repository.";
224         return $description;
225     }
226
227     method _build_owner {
228         return 'system' if $^O =~ 'MSWin32';
229
230         my ($gecos, $name) = map { decode(langinfo(CODESET()), $_) } (getpwuid $self->path->stat->uid)[6,0];
231         $gecos =~ s/,+$//;
232         return length($gecos) ? $gecos : $name;
233     }
234
235     method _build_last_change {
236         my $last_change;
237         my $output = $self->run_cmd(
238             qw{ for-each-ref --format=%(committer)
239                 --sort=-committerdate --count=1 refs/heads
240           });
241         if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
242             my $dt = DT->from_epoch(epoch => $epoch);
243             $dt->set_time_zone($tz);
244             $last_change = $dt;
245         }
246         return $last_change;
247     }
248
249     method _build_heads {
250         my @revlines = $self->run_cmd_list(qw/for-each-ref --sort=-committerdate /, '--format=%(objectname)%00%(refname)%00%(committer)', 'refs/heads');
251         my @ret;
252         for my $line (@revlines) {
253             push @ret, Gitalist::Git::Head->new($line);
254         }
255         return \@ret;
256     }
257
258     method _build_tags {
259         my @revlines = $self->run_cmd_list('for-each-ref',
260           '--sort=-creatordate',
261           '--format=%(objectname) %(objecttype) %(refname) %(*objectname) %(*objecttype) %(subject)%00%(creator)',
262           'refs/tags'
263         );
264         return [
265             map  Gitalist::Git::Tag->new($_),
266             grep Gitalist::Git::Tag::is_valid_tag($_), @revlines
267         ];
268     }
269
270     method _build_references {
271         # 5dc01c595e6c6ec9ccda4f6f69c131c0dd945f8c refs/tags/v2.6.11
272         # c39ae07f393806ccf406ef966e9a15afc43cc36a refs/tags/v2.6.11^{}
273         my @reflist = $self->run_cmd_list(qw(show-ref --dereference))
274             or return;
275         my %refs;
276         for (@reflist) {
277             push @{$refs{$1}}, $2
278                 if m!^($SHA1RE)\srefs/(.*)$!;
279         }
280
281         return \%refs;
282     }
283
284     ## Private methods
285     method _parse_rev_list ($output) {
286         return
287             map  $self->get_gpp_object($_),
288                 grep is_SHA1($_),
289                     map  split(/\n/, $_, 6), split /\0/, $output;
290     }
291
292 } # end class
293
294 __END__
295
296 =head1 NAME
297
298 Gitalist::Git::Repository - Model of a git repository
299
300 =head1 SYNOPSIS
301
302     my $gitrepo = dir('/repo/base/Gitalist');
303     my $repository = Gitalist::Git::Repository->new($gitrepo);
304      $repository->name;        # 'Gitalist'
305      $repository->path;        # '/repo/base/Gitalist/.git'
306      $repository->description; # 'Unnamed repository.'
307
308 =head1 DESCRIPTION
309
310 This class models a git repository, referred to in Gitalist
311 as a "Repository".
312
313
314 =head1 ATTRIBUTES
315
316 =head2 name
317
318 The name of the Repository.  If unspecified, this will be derived from the path to the git repository.
319
320 =head2 path
321
322 L<Path::Class:Dir> for the filesystem path to the git repository.
323
324 =head2 description
325
326 The contents of .git/description.
327
328 =head2 owner
329
330 Owner of the files on the filesystem.
331
332 =head2 last_change
333
334 The L<DateTime> of the last modification of the repository.  This will be C<undef> if the repository has never been used.
335
336 =head2 is_bare
337
338 True if this is a bare git repository.
339
340 =head2 heads
341
342 =head2 tags
343
344 An array of the name and sha1 of all heads/tags in the repository.
345
346 =head2 references
347
348 Hashref of ArrayRefs for each reference.
349
350
351 =head1 METHODS
352
353 =head2 head_hash ($head?)
354
355 Return the sha1 for HEAD, or any specified head.
356
357 =head2 get_object ($sha1)
358
359 Return an appropriate subclass of L<Gitalist::Git::Object> for the given sha1.
360
361 =head2 list_revs ($sha1, $count?, $skip?, \%search?, $file?)
362
363 Returns a list of revs for the given head ($sha1).
364
365 =head2 snapshot ($sha1, $format)
366
367 Generate an archived snapshot of the repository.
368 $sha1 should be a commit or tree.
369 Returns a filehandle to read from.
370
371 =head2 diff ($commit, $patch?, $parent?, $file?)
372
373 Generate a diff from a given L<Gitalist::Git::Object>.
374
375 =head2 reflog (@lorgargs)
376
377 Return a list of hashes representing each reflog entry.
378
379 FIXME Should this return objects?
380
381
382 =head1 SEE ALSO
383
384 L<Gitalist::Git::Util> L<Gitalist::Git::Object>
385
386
387 =head1 AUTHORS
388
389 See L<Gitalist> for authors.
390
391 =head1 LICENSE
392
393 See L<Gitalist> for the license.
394
395 =cut