Merge in master changes.
[catagits/Gitalist.git] / lib / Gitalist / Model / Git.pm
CommitLineData
fbf3eb7e 1package Gitalist::Model::Git;
2
3use Moose;
8c032474 4use MooseX::Types::Common::String qw/NonEmptySimpleStr/; # FIXME, use Types::Path::Class and coerce
fbf3eb7e 5use namespace::autoclean;
6
86382b95 7BEGIN { extends 'Catalyst::Model' }
8
fbf3eb7e 9use DateTime;
10use Path::Class;
11use Carp qw/croak/;
12use File::Find::Rule;
13use DateTime::Format::Mail;
14use File::Stat::ModeString;
15use List::MoreUtils qw/any/;
8c032474 16use File::Which;
fbf3eb7e 17
8c032474 18has repo_dir => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 ); # Fixme - path::class
19has git => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 );
d7c9a32f 20
8c032474 21sub BUILD {
22 my ($self) = @_;
23 $self->git; # Cause lazy value build.
24 $self->repo_dir;
25}
26
27sub _build_git {
28 my $git = File::Which::which('git');
d7c9a32f 29
8c032474 30 if (!$git) {
31 die <<EOR
fbf3eb7e 32Could not find a git executable.
33Please specify the which git executable to use in gitweb.yml
34EOR
8c032474 35 }
fbf3eb7e 36
8c032474 37 return $git;
04d1d917 38}
fbf3eb7e 39
8c032474 40sub _build_repo_dir {
41 return Gitalist->config->{repo_dir};
42}
d7c9a32f 43
fbf3eb7e 44sub is_git_repo {
8c032474 45 my ($self, $dir) = @_;
fbf3eb7e 46
8c032474 47 return -f $dir->file('HEAD') || -f $dir->file('.git/HEAD');
fbf3eb7e 48}
49
50sub project_info {
8c032474 51 my ($self, $project) = @_;
fbf3eb7e 52
8c032474 53 return {
54 name => $project,
55 $self->get_project_properties(
56 $self->git_dir_from_project_name($project),
57 ),
fbf3eb7e 58 };
59}
60
61sub get_project_properties {
8c032474 62 my ($self, $dir) = @_;
63 my %props;
fbf3eb7e 64
8c032474 65 eval {
66 $props{description} = $dir->file('description')->slurp;
67 chomp $props{description};
fbf3eb7e 68 };
69
8c032474 70 if ($props{description} && $props{description} =~ /^Unnamed repository;/) {
71 delete $props{description};
72 }
fbf3eb7e 73
8c032474 74 $props{owner} = (getpwuid $dir->stat->uid)[6];
fbf3eb7e 75
8c032474 76 my $output = $self->run_cmd_in($dir, qw{
77 for-each-ref --format=%(committer)
78 --sort=-committerdate --count=1 refs/heads
79 });
fbf3eb7e 80
8c032474 81 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
82 my $dt = DateTime->from_epoch(epoch => $epoch);
83 $dt->set_time_zone($tz);
84 $props{last_change} = $dt;
85 }
fbf3eb7e 86
8c032474 87 return %props;
fbf3eb7e 88}
89
90sub list_projects {
8c032474 91 my ($self) = @_;
fbf3eb7e 92
8c032474 93 my $base = dir($self->repo_dir);
fbf3eb7e 94
8c032474 95 my @ret;
96 my $dh = $base->open;
97 while (my $file = $dh->read) {
98 next if $file =~ /^.{1,2}$/;
99
100 my $obj = $base->subdir($file);
101 next unless -d $obj;
102 next unless $self->is_git_repo($obj);
103 # XXX Leaky abstraction alert!
104 my $is_bare = !-d $obj->subdir('.git');
105
106 my $name = (File::Spec->splitdir($obj))[-1];
107 push @ret, {
108 name => ($name . ( $is_bare ? '' : '/.git' )),
109 $self->get_project_properties(
110 $is_bare ? $obj : $obj->subdir('.git')
111 ),
112 };
113 }
d7c9a32f 114
8c032474 115 return [sort { $a->{name} cmp $b->{name} } @ret];
fbf3eb7e 116}
117
118sub run_cmd {
8c032474 119 my ($self, @args) = @_;
d7c9a32f 120
8c032474 121 open my $fh, '-|', $self->git, @args
122 or die "failed to run git command";
123 binmode $fh, ':encoding(UTF-8)';
d7c9a32f 124
8c032474 125 my $output = do { local $/ = undef; <$fh> };
126 close $fh;
d7c9a32f 127
8c032474 128 return $output;
fbf3eb7e 129}
130
131sub run_cmd_in {
8c032474 132 my ($self, $project, @args) = @_;
fbf3eb7e 133
8c032474 134 my $path;
135 if (blessed($project) && $project->isa('Path::Class::Dir')) {
136 $path = $project->stringify;
137 }
138 else {
139 $path = $self->git_dir_from_project_name($project);
140 }
141 return $self->run_cmd('--git-dir' => $path, @args);
fbf3eb7e 142}
143
144sub git_dir_from_project_name {
8c032474 145 my ($self, $project) = @_;
fbf3eb7e 146
8c032474 147 return dir($self->repo_dir)->subdir($project);
fbf3eb7e 148}
149
150sub get_head_hash {
8c032474 151 my ($self, $project) = @_;
fbf3eb7e 152
8c032474 153 my $output = $self->run_cmd_in($project, qw/rev-parse --verify HEAD/ );
154 return unless defined $output;
fbf3eb7e 155
8c032474 156 my ($head) = $output =~ /^([0-9a-fA-F]{40})$/;
157 return $head;
fbf3eb7e 158}
159
160sub list_tree {
8c032474 161 my ($self, $project, $rev) = @_;
fbf3eb7e 162
8c032474 163 $rev ||= $self->get_head_hash($project);
fbf3eb7e 164
8c032474 165 my $output = $self->run_cmd_in($project, qw/ls-tree -z/, $rev);
166 return unless defined $output;
fbf3eb7e 167
8c032474 168 my @ret;
169 for my $line (split /\0/, $output) {
170 my ($mode, $type, $object, $file) = split /\s+/, $line, 4;
171
172 push @ret, {
173 mode => oct $mode,
174 type => $type,
175 object => $object,
176 file => $file,
177 };
178 }
d7c9a32f 179
8c032474 180 return @ret;
fbf3eb7e 181}
182
183sub get_object_mode_string {
8c032474 184 my ($self, $object) = @_;
fbf3eb7e 185
8c032474 186 return unless $object && $object->{mode};
187 return mode_to_string($object->{mode});
fbf3eb7e 188}
189
190sub get_object_type {
8c032474 191 my ($self, $project, $object) = @_;
fbf3eb7e 192
8c032474 193 my $output = $self->run_cmd_in($project, qw/cat-file -t/, $object);
194 return unless $output;
fbf3eb7e 195
8c032474 196 chomp $output;
197 return $output;
295c9703 198}
199
fbf3eb7e 200sub cat_file {
8c032474 201 my ($self, $project, $object) = @_;
fbf3eb7e 202
8c032474 203 my $type = $self->get_object_type($project, $object);
204 die "object `$object' is not a file\n"
205 if (!defined $type || $type ne 'blob');
fbf3eb7e 206
8c032474 207 my $output = $self->run_cmd_in($project, qw/cat-file -p/, $object);
208 return unless $output;
fbf3eb7e 209
8c032474 210 return $output;
fbf3eb7e 211}
212
213sub valid_rev {
8c032474 214 my ($self, $rev) = @_;
fbf3eb7e 215
8c032474 216 return unless $rev;
217 return ($rev =~ /^([0-9a-fA-F]{40})$/);
fbf3eb7e 218}
219
220sub diff {
8c032474 221 my ($self, $project, @revs) = @_;
fbf3eb7e 222
8c032474 223 croak("Gitalist::Model::Git::diff needs a project and either one or two revisions")
224 if scalar @revs < 1
225 || scalar @revs > 2
226 || any { !$self->valid_rev($_) } @revs;
fbf3eb7e 227
8c032474 228 my $output = $self->run_cmd_in($project, 'diff', @revs);
229 return unless $output;
fbf3eb7e 230
8c032474 231 return $output;
fbf3eb7e 232}
233
234{
8c032474 235 my $formatter = DateTime::Format::Mail->new;
236
237 sub parse_rev_list {
238 my ($self, $output) = @_;
239 my @ret;
240
241 my @revs = split /\0/, $output;
242
243 for my $rev (split /\0/, $output) {
244 for my $line (split /\n/, $rev, 6) {
245 chomp $line;
246 next unless $line;
247
248 if ($self->valid_rev($line)) {
249 push @ret, {rev => $line};
250 next;
251 }
252
253 if (my ($key, $value) = $line =~ /^(tree|parent)\s+(.*)$/) {
254 $ret[-1]->{$key} = $value;
255 next;
256 }
257
258 if (my ($key, $value, $epoch, $tz) = $line =~ /^(author|committer)\s+(.*)\s+(\d+)\s+([+-]\d+)$/) {
259 $ret[-1]->{$key} = $value;
260 eval {
261 $ret[-1]->{ $key . "_datetime" } = DateTime->from_epoch(epoch => $epoch);
262 $ret[-1]->{ $key . "_datetime" }->set_time_zone($tz);
263 $ret[-1]->{ $key . "_datetime" }->set_formatter($formatter);
264 };
265
266 if ($@) {
267 $ret[-1]->{ $key . "_datetime" } = "$epoch $tz";
268 }
269
270 if (my ($name, $email) = $value =~ /^([^<]+)\s+<([^>]+)>$/) {
271 $ret[-1]->{ $key . "_name" } = $name;
272 $ret[-1]->{ $key . "_email" } = $email;
273 }
274 }
275
276 $line =~ s/^\n?\s{4}//;
277 $ret[-1]->{longmessage} = $line;
278 $ret[-1]->{message} = (split /\n/, $line, 2)[0];
279 }
d7c9a32f 280 }
281
8c032474 282 return @ret;
fbf3eb7e 283 }
284}
285
286sub list_revs {
8c032474 287 my ($self, $project, %args) = @_;
fbf3eb7e 288
8c032474 289 $args{rev} ||= $self->get_head_hash($project);
fbf3eb7e 290
8c032474 291 my $output = $self->run_cmd_in($project, 'rev-list',
292 '--header',
293 (defined $args{ count } ? "--max-count=$args{count}" : ()),
294 (defined $args{ skip } ? "--skip=$args{skip}" : ()),
295 $args{rev},
296 '--',
297 ($args{file} || ()),
fbf3eb7e 298 );
8c032474 299 return unless $output;
fbf3eb7e 300
8c032474 301 my @revs = $self->parse_rev_list($output);
fbf3eb7e 302
8c032474 303 return \@revs;
fbf3eb7e 304}
305
306sub rev_info {
8c032474 307 my ($self, $project, $rev) = @_;
fbf3eb7e 308
8c032474 309 return unless $self->valid_rev($rev);
c5065c66 310
8c032474 311 return $self->list_revs($project, rev => $rev, count => 1);
c5065c66 312}
313
fbf3eb7e 314sub get_heads {
8c032474 315 my ($self, $project) = @_;
fbf3eb7e 316
8c032474 317 my $output = $self->run_cmd_in($project, qw/for-each-ref --sort=-committerdate /, '--format=%(objectname)%00%(refname)%00%(committer)', 'refs/heads');
318 return unless $output;
fbf3eb7e 319
8c032474 320 my @ret;
321 for my $line (split /\n/, $output) {
322 my ($rev, $head, $commiter) = split /\0/, $line, 3;
323 $head =~ s!^refs/heads/!!;
fbf3eb7e 324
8c032474 325 push @ret, { rev => $rev, name => $head };
fbf3eb7e 326
8c032474 327 #FIXME: That isn't the time I'm looking for..
328 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
329 my $dt = DateTime->from_epoch(epoch => $epoch);
330 $dt->set_time_zone($tz);
331 $ret[-1]->{last_change} = $dt;
332 }
fbf3eb7e 333 }
334
8c032474 335 return \@ret;
fbf3eb7e 336}
337
338sub archive {
8c032474 339 my ($self, $project, $rev) = @_;
fbf3eb7e 340
8c032474 341 #FIXME: huge memory consuption
342 #TODO: compression
343 return $self->run_cmd_in($project, qw/archive --format=tar/, "--prefix=${project}/", $rev);
fbf3eb7e 344}
345
3461;
347
348__PACKAGE__->meta->make_immutable;