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