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