WIP of moving the project list to an action.
[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;
10use Carp qw/croak/;
11use File::Find::Rule;
12use DateTime::Format::Mail;
13use File::Stat::ModeString;
14use List::MoreUtils qw/any/;
15use Scalar::Util qw/blessed/;
16
86382b95 17use Gitalist::Util qw(to_utf8);
18
19# from gitweb.pm
20use CGI::Util qw(unescape);
fbf3eb7e 21
22has git => (
23 is => 'ro',
24 isa => 'Str',
25 lazy => 1,
26 default => sub {
27 my $git;
28
29 if (my $config_git = Gitalist->config->{git}) {
30 $git = $config_git if -x $config_git;
31 }
32 else {
33 require File::Which;
34 $git = File::Which::which('git');
35 }
36
37 if (!$git) {
38 die <<EOR
39Could not find a git executable.
40Please specify the which git executable to use in gitweb.yml
41EOR
42 }
43
44 return $git;
45 },
46);
47
48sub is_git_repo {
49 my ($self, $dir) = @_;
50
51 #FIXME: Only handles bare repos. Is that enough?
52 return -f $dir->file('HEAD');
53}
54
55sub project_info {
56 my ($self, $project) = @_;
57
58 return {
59 name => $project,
60 $self->get_project_properties(
61 $self->git_dir_from_project_name($project),
62 ),
63 };
64}
65
66sub get_project_properties {
67 my ($self, $dir) = @_;
68 my %props;
69
70 eval {
71 $props{description} = $dir->file('description')->slurp;
72 chomp $props{description};
73 };
74
75 if ($props{description} =~ /^Unnamed repository;/) {
76 delete $props{description};
77 }
78
79 $props{owner} = (getpwuid $dir->stat->uid)[6];
80
81 my $output = $self->run_cmd_in($dir, qw{
82 for-each-ref --format=%(committer)
83 --sort=-committerdate --count=1 refs/heads
84 });
85
86 if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
87 my $dt = DateTime->from_epoch(epoch => $epoch);
88 $dt->set_time_zone($tz);
89 $props{last_change} = $dt;
90 }
91
92 return %props;
93}
94
95sub list_projects {
96 my ($self) = @_;
97
98 my $base = dir(Gitalist->config->{repo_dir});
99
100 my @ret;
101 my $dh = $base->open;
102 while (my $file = $dh->read) {
103 next if $file =~ /^.{1,2}$/;
104
105 my $obj = $base->subdir($file);
106 next unless -d $obj;
107 next unless $self->is_git_repo($obj);
108
109 push @ret, {
110 name => ($obj->dir_list)[-1],
111 $self->get_project_properties($obj),
112 };
113 }
114
115 return \@ret;
116}
117
118sub run_cmd {
119 my ($self, @args) = @_;
120
121 open my $fh, '-|', __PACKAGE__->git, @args
122 or die "failed to run git command";
123 binmode $fh, ':encoding(UTF-8)';
124
125 my $output = do { local $/ = undef; <$fh> };
126 close $fh;
127
128 return $output;
129}
130
131sub run_cmd_in {
132 my ($self, $project, @args) = @_;
133
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);
142}
143
144sub git_dir_from_project_name {
145 my ($self, $project) = @_;
146
147 return dir(Gitalist->config->{repo_dir})->subdir($project);
148}
149
150sub get_head_hash {
151 my ($self, $project) = @_;
152
153 my $output = $self->run_cmd_in($project, qw/rev-parse --verify HEAD/ );
154 return unless defined $output;
155
156 my ($head) = $output =~ /^([0-9a-fA-F]{40})$/;
157 return $head;
158}
159
160sub list_tree {
161 my ($self, $project, $rev) = @_;
162
163 $rev ||= $self->get_head_hash($project);
164
165 my $output = $self->run_cmd_in($project, qw/ls-tree -z/, $rev);
166 return unless defined $output;
167
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 }
179
180 return @ret;
181}
182
183sub get_object_mode_string {
184 my ($self, $object) = @_;
185
186 return unless $object && $object->{mode};
187 return mode_to_string($object->{mode});
188}
189
190sub get_object_type {
191 my ($self, $project, $object) = @_;
192
193 my $output = $self->run_cmd_in($project, qw/cat-file -t/, $object);
194 return unless $output;
195
196 chomp $output;
197 return $output;
198}
199
200sub cat_file {
201 my ($self, $project, $object) = @_;
202
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');
206
207 my $output = $self->run_cmd_in($project, qw/cat-file -p/, $object);
208 return unless $output;
209
210 return $output;
211}
212
213sub valid_rev {
214 my ($self, $rev) = @_;
215
216 return unless $rev;
217 return ($rev =~ /^([0-9a-fA-F]{40})$/);
218}
219
220sub diff {
221 my ($self, $project, @revs) = @_;
222
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;
227
228 my $output = $self->run_cmd_in($project, 'diff', @revs);
229 return unless $output;
230
231 return $output;
232}
233
234{
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 }
280 }
281
282 return @ret;
283 }
284}
285
286sub list_revs {
287 my ($self, $project, %args) = @_;
288
289 $args{rev} ||= $self->get_head_hash($project);
290
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} || ()),
298 );
299 return unless $output;
300
301 my @revs = $self->parse_rev_list($output);
302
303 return \@revs;
304}
305
306sub rev_info {
307 my ($self, $project, $rev) = @_;
308
309 return unless $self->valid_rev($rev);
310
311 return $self->list_revs($project, rev => $rev, count => 1);
312}
313
314sub get_heads {
315 my ($self, $project) = @_;
316
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;
319
320 my @ret;
321 for my $line (split /\n/, $output) {
322 my ($rev, $head, $commiter) = split /\0/, $line, 3;
323 $head =~ s!^refs/heads/!!;
324
325 push @ret, { rev => $rev, name => $head };
326
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 }
333 }
334
335 return \@ret;
336}
337
338sub archive {
339 my ($self, $project, $rev) = @_;
340
341 #FIXME: huge memory consuption
342 #TODO: compression
343 return $self->run_cmd_in($project, qw/archive --format=tar/, "--prefix=${project}/", $rev);
344}
345
86382b95 346## from gitweb.pm
347
348# checking HEAD file with -e is fragile if the repository was
349# initialized long time ago (i.e. symlink HEAD) and was pack-ref'ed
350# and then pruned.
351sub check_head_link {
352 my ($dir) = @_;
353 my $headfile = "$dir/HEAD";
354 return ((-e $headfile) ||
355 (-l $headfile && readlink($headfile) =~ /^refs\/heads\//));
356}
357
358sub check_export_ok {
359 my ($dir) = @_;
360 my($export_ok, $export_auth_hook) = @{Gitalist->config}{qw(export_ok export_auth_hook)};
361 return (check_head_link($dir) &&
362 (!$export_ok || -e "$dir/$export_ok") &&
363 (!$export_auth_hook || $export_auth_hook->($dir)));
364}
365
366sub projects {
367 my($self, $filter) = @_;
368 my @list;
369
370 $filter ||= '';
371 $filter =~ s/\.git$//;
372
373 my $projects_list = Gitalist->config->{projectroot};
374 if (-d $projects_list) {
375 # search in directory
376 my $dir = $projects_list . ($filter ? "/$filter" : '');
377 # remove the trailing "/"
378 $dir =~ s!/+$!!;
379 my $pfxlen = length("$dir");
380 my $pfxdepth = ($dir =~ tr!/!!);
381
382 File::Find::find({
383 follow_fast => 1, # follow symbolic links
384 follow_skip => 2, # ignore duplicates
385 dangling_symlinks => 0, # ignore dangling symlinks, silently
386 wanted => sub {
387 # skip project-list toplevel, if we get it.
388 return if (m!^[/.]$!);
389 # only directories can be git repositories
390 return unless (-d $_);
391 # don't traverse too deep (Find is super slow on os x)
392 if (($File::Find::name =~ tr!/!!) - $pfxdepth > Gitalist->config->{project_maxdepth}) {
393 $File::Find::prune = 1;
394 return;
395 }
396
397 my $subdir = substr($File::Find::name, $pfxlen + 1);
398 # we check related file in $projectroot
399 my $path = ($filter ? "$filter/" : '') . $subdir;
400 if (check_export_ok("$projects_list/$path")) {
401 push @list, { path => $path };
402 $File::Find::prune = 1;
403 }
404 },
405 }, "$dir");
406
407 } elsif (-f $projects_list) {
408 # read from file(url-encoded):
409 # 'git%2Fgit.git Linus+Torvalds'
410 # 'libs%2Fklibc%2Fklibc.git H.+Peter+Anvin'
411 # 'linux%2Fhotplug%2Fudev.git Greg+Kroah-Hartman'
412 my %paths;
413 open my ($fd), $projects_list or return;
414 PROJECT:
415 while (my $line = <$fd>) {
416 chomp $line;
417 my ($path, $owner) = split ' ', $line;
418 $path = unescape($path);
419 $owner = unescape($owner);
420 if (!defined $path) {
421 next;
422 }
423 if ($filter ne '') {
424 # looking for forks;
425 my $pfx = substr($path, 0, length($filter));
426 if ($pfx ne $filter) {
427 next PROJECT;
428 }
429 my $sfx = substr($path, length($filter));
430 if ($sfx !~ /^\/.*\.git$/) {
431 next PROJECT;
432 }
433 }
434 if (check_export_ok("$projects_list/$path")) {
435 my $pr = {
436 path => $path,
437 owner => to_utf8($owner),
438 };
439 push @list, $pr;
440 (my $forks_path = $path) =~ s/\.git$//;
441 $paths{$forks_path}++;
442 }
443 }
444 close $fd;
445 }
446 return @list;
447}
448
fbf3eb7e 4491;
450
451__PACKAGE__->meta->make_immutable;