cd09a42410beab89b62d0c7c388145a4656f53c6
[catagits/Gitalist.git] / lib / Gitalist / Controller / Root.pm
1 package Gitalist::Controller::Root;
2 use Moose;
3 use namespace::autoclean;
4
5 BEGIN { extends 'Catalyst::Controller' }
6
7 __PACKAGE__->config->{namespace} = '';
8
9 use Sys::Hostname ();
10 use XML::Atom::Feed;
11 use XML::Atom::Entry;
12 use XML::RSS;
13
14 =head1 NAME
15
16 Gitalist::Controller::Root - Root Controller for Gitalist
17
18 =head1 DESCRIPTION
19
20 [enter your description here]
21
22 =head1 METHODS
23
24 =cut
25
26 sub _get_object {
27   my($self, $c, $haveh) = @_;
28
29   my $h = $haveh || $c->req->param('h') || '';
30   my $f = $c->req->param('f');
31
32   my $m = $c->stash->{Project};
33   my $pd = $m->path;
34
35   # Either use the provided h(ash) parameter, the f(ile) parameter or just use HEAD.
36   my $hash = ($h =~ /[^a-f0-9]/ ? $m->head_hash($h) : $h)
37           || ($f && $m->hash_by_path($f))
38           || $m->head_hash
39           # XXX This could definitely use more context.
40           || Carp::croak("Couldn't find a hash for the commit object!");
41
42   my $obj = $m->get_object($hash)
43     or Carp::croak("Couldn't find a object for '$hash' in '$pd'!");
44
45   return $obj;
46 }
47
48 =head2 index
49
50 Provides the project listing.
51
52 =cut
53
54 sub index :Path :Args(0) {
55   my ( $self, $c ) = @_;
56
57   $c->detach($c->req->param('a'))
58     if $c->req->param('a');
59
60   my @list = @{ $c->model()->projects };
61   die 'No projects found in '. $c->model->repo_dir
62     unless @list;
63
64   my $search = $c->req->param('s') || '';
65   if($search) {
66     @list = grep {
67          index($_->name, $search) > -1
68       or ( $_->description !~ /^Unnamed repository/ and index($_->description, $search) > -1 )
69     } @list
70   }
71
72   $c->stash(
73     search_text => $search,
74     projects    => \@list,
75     action      => 'index',
76   );
77 }
78
79 sub project_index : Local {
80   my ( $self, $c ) = @_;
81
82   my @list = @{ $c->model()->projects };
83   die 'No projects found in '. $c->model->repo_dir
84     unless @list;
85
86   $c->response->content_type('text/plain');
87   $c->response->body(
88     join "\n", map $_->name, @list
89   );
90   $c->response->status(200);
91 }
92
93 =head2 summary
94
95 A summary of what's happening in the repo.
96
97 =cut
98
99 sub summary : Local {
100   my ( $self, $c ) = @_;
101   my $project = $c->stash->{Project};
102   $c->detach('error_404') unless $project;
103   my $commit = $self->_get_object($c);
104   my @heads  = @{$project->heads};
105   my $maxitems = Gitalist->config->{paging}{summary} || 10;
106   $c->stash(
107     commit    => $commit,
108     log_lines => [$project->list_revs(
109         sha1 => $commit->sha1,
110         count => $maxitems,
111     )],
112     refs      => $project->references,
113     heads     => [ @heads[0 .. ($#heads < $maxitems ? $#heads : $maxitems)] ],
114     action    => 'summary',
115   );
116 }
117
118 =head2 heads
119
120 The current list of heads (aka branches) in the repo.
121
122 =cut
123
124 sub heads : Local {
125   my ( $self, $c ) = @_;
126   my $project = $c->stash->{Project};
127   $c->stash(
128     commit => $self->_get_object($c),
129     heads  => $project->heads,
130     action => 'heads',
131   );
132 }
133
134 =head2 tags
135
136 The current list of tags in the repo.
137
138 =cut
139
140 sub tags : Local {
141   my ( $self, $c ) = @_;
142   my $project = $c->stash->{Project};
143   $c->stash(
144     commit => $self->_get_object($c),
145     tags   => $project->tags,
146     action => 'tags',
147   );
148 }
149
150 sub blame : Local {
151   my($self, $c) = @_;
152
153   my $project = $c->stash->{Project};
154   my $h  = $c->req->param('h')
155        || $project->hash_by_path($c->req->param('hb'), $c->req->param('f'))
156        || die "No file or sha1 provided.";
157   my $hb = $c->req->param('hb')
158        || $project->head_hash
159        || die "Couldn't discern the corresponding head.";
160   my $filename = $c->req->param('f') || '';
161
162   $c->stash(
163     blame    => $project->get_object($hb)->blame($filename),
164     head     => $project->get_object($hb),
165     filename => $filename,
166   );
167   
168 }
169
170 sub _blob_objs {
171   my ( $self, $c ) = @_;
172   my $project = $c->stash->{Project};
173   my $h  = $c->req->param('h')
174        || $project->hash_by_path($c->req->param('hb'), $c->req->param('f'))
175        || die "No file or sha1 provided.";
176   my $hb = $c->req->param('hb')
177        || $project->head_hash
178        || die "Couldn't discern the corresponding head.";
179
180   my $filename = $c->req->param('f') || '';
181
182   my $blob = $project->get_object($h);
183   $blob = $project->get_object(
184     $project->hash_by_path($h || $hb, $filename)
185   ) if $blob->type ne 'blob';
186
187   return $blob, $project->get_object($hb), $filename;
188 }
189
190 =head2 blob
191
192 The blob action i.e the contents of a file.
193
194 =cut
195
196 sub blob : Local {
197   my ( $self, $c ) = @_;
198
199   my($blob, $head, $filename) = $self->_blob_objs($c);
200   $c->stash(
201     blob     => $blob->content,
202     head     => $head,
203     filename => $filename,
204     # XXX Hack hack hack, see View::SyntaxHighlight
205     language => ($filename =~ /\.p[lm]$/ ? 'Perl' : ''),
206     action   => 'blob',
207   );
208
209   $c->forward('View::SyntaxHighlight')
210     unless $c->stash->{no_wrapper};
211 }
212
213 =head2 blob_plain
214
215 The plain text version of blob, where file is rendered as is.
216
217 =cut
218
219 sub blob_plain : Local {
220   my($self, $c) = @_;
221
222   my($blob) = $self->_blob_objs($c);
223   $c->response->content_type('text/plain; charset=utf-8');
224   $c->response->body($blob->content);
225   $c->response->status(200);
226 }
227
228 =head2 blobdiff_plain
229
230 The plain text version of blobdiff.
231
232 =cut
233
234 sub blobdiff_plain : Local {
235   my($self, $c) = @_;
236
237   $c->stash(no_wrapper => 1);
238   $c->response->content_type('text/plain; charset=utf-8');
239
240   $c->forward('blobdiff');
241 }
242
243 =head2 blobdiff
244
245 Exposes a given diff of a blob.
246
247 =cut
248
249 sub blobdiff : Local {
250   my ( $self, $c ) = @_;
251   my $commit = $self->_get_object($c, $c->req->param('hb'));
252   my $filename = $c->req->param('f')
253               || croak("No file specified!");
254   my($tree, $patch) = $c->stash->{Project}->diff(
255     commit => $commit,
256     patch  => 1,
257     parent => $c->req->param('hpb') || undef,
258     file   => $filename,
259   );
260   $c->stash(
261     commit    => $commit,
262     diff      => $patch,
263     filename  => $filename,
264     # XXX Hack hack hack, see View::SyntaxHighlight
265     blobs     => [$patch->[0]->{diff}],
266     language  => 'Diff',
267     action    => 'blobdiff',
268   );
269
270   $c->forward('View::SyntaxHighlight')
271     unless $c->stash->{no_wrapper};
272 }
273
274 =head2 commit
275
276 Exposes a given commit.
277
278 =cut
279
280 sub commit : Local {
281   my ( $self, $c ) = @_;
282   my $project = $c->stash->{Project};
283   my $commit = $self->_get_object($c);
284   $c->stash(
285       commit      => $commit,
286       diff_tree   => ($project->diff(commit => $commit))[0],
287       refs      => $project->references,
288       action      => 'commit',
289   );
290 }
291
292 =head2 commitdiff
293
294 Exposes a given diff of a commit.
295
296 =cut
297
298 sub commitdiff : Local {
299   my ( $self, $c ) = @_;
300   my $commit = $self->_get_object($c);
301   my($tree, $patch) = $c->stash->{Project}->diff(
302       commit => $commit,
303       parent => $c->req->param('hp') || undef,
304       patch  => 1,
305   );
306   $c->stash(
307     commit    => $commit,
308     diff_tree => $tree,
309     diff      => $patch,
310     # XXX Hack hack hack, see View::SyntaxHighlight
311     blobs     => [map $_->{diff}, @$patch],
312     language  => 'Diff',
313     action    => 'commitdiff',
314   );
315
316   $c->forward('View::SyntaxHighlight')
317     unless $c->stash->{no_wrapper};
318 }
319
320 sub commitdiff_plain : Local {
321   my($self, $c) = @_;
322
323   $c->stash(no_wrapper => 1);
324   $c->response->content_type('text/plain; charset=utf-8');
325
326   $c->forward('commitdiff');
327 }
328
329 =head2 shortlog
330
331 Expose an abbreviated log of a given sha1.
332
333 =cut
334
335 sub shortlog : Local {
336   my ( $self, $c ) = @_;
337
338   my $project  = $c->stash->{Project};
339   my $commit   = $self->_get_object($c, $c->req->param('hb'));
340   my $filename = $c->req->param('f') || '';
341
342   my %logargs = (
343       sha1   => $commit->sha1,
344       count  => Gitalist->config->{paging}{log} || 25,
345       ($filename ? (file => $filename) : ())
346   );
347
348   my $page = $c->req->param('pg') || 0;
349   $logargs{skip} = $c->req->param('pg') * $logargs{count}
350     if $c->req->param('pg');
351
352   $c->stash(
353       commit    => $commit,
354       log_lines => [$project->list_revs(%logargs)],
355       refs      => $project->references,
356       page      => $page,
357       filename  => $filename,
358       action    => 'shortlog',
359   );
360 }
361
362 =head2 log
363
364 Calls shortlog internally. Perhaps that should be reversed ...
365
366 =cut
367 sub log : Local {
368     $_[0]->shortlog($_[1]);
369     $_[1]->stash->{action} = 'log';
370 }
371
372 # For legacy support.
373 sub history : Local {
374   $_[1]->forward('shortlog');
375 }
376
377 =head2 tree
378
379 The tree of a given commit.
380
381 =cut
382
383 sub tree : Local {
384   my ( $self, $c ) = @_;
385   my $project = $c->stash->{Project};
386   my $commit  = $self->_get_object($c, $c->req->param('hb'));
387   my $filename = $c->req->param('f') || '';
388   my $tree    = $filename
389     ? $project->get_object($project->hash_by_path($commit->sha1, $filename))
390     : $project->get_object($commit->tree_sha1)
391   ;
392   $c->stash(
393       commit    => $commit,
394       tree      => $tree,
395       tree_list => [$project->list_tree($tree->sha1)],
396       path      => $c->req->param('f') || '',
397       action    => 'tree',
398   );
399 }
400
401 =head2 reflog
402
403 Expose the local reflog. This may go away.
404
405 =cut
406
407 sub reflog : Local {
408   my ( $self, $c ) = @_;
409   my @log = $c->stash->{Project}->reflog(
410       '--since=yesterday'
411   );
412
413   $c->stash(
414       log    => \@log,
415       action => 'reflog',
416   );
417 }
418
419 =head2 search
420
421 The action for the search form.
422
423 =cut
424
425 sub search : Local {
426   my($self, $c) = @_;
427   $c->stash(current_action => 'GitRepos');
428   my $project = $c->stash->{Project};
429   my $commit  = $self->_get_object($c);
430   # Lifted from /shortlog.
431   my %logargs = (
432     sha1   => $commit->sha1,
433     count  => Gitalist->config->{paging}{log},
434     ($c->req->param('f') ? (file => $c->req->param('f')) : ()),
435     search => {
436       type   => $c->req->param('type'),
437       text   => $c->req->param('text'),
438       regexp => $c->req->param('regexp') || 0,
439     },
440   );
441
442   $c->stash(
443       commit  => $commit,
444       results => [$project->list_revs(%logargs)],
445       action  => 'search',
446           # This could be added - page      => $page,
447   );
448 }
449
450 =head2 search_help
451
452 Provides some help for the search form.
453
454 =cut
455
456 sub search_help : Local {
457     my ($self, $c) = @_;
458     $c->stash(template => 'search_help.tt2');
459 }
460
461 =head2 atom
462
463 Provides an atom feed for a given project.
464
465 =cut
466
467 sub atom : Local {
468   my($self, $c) = @_;
469
470   my $feed = XML::Atom::Feed->new;
471
472   my $host = lc Sys::Hostname::hostname();
473   $feed->title($host . ' - ' . Gitalist->config->{name});
474   $feed->updated(~~DateTime->now);
475
476   my $project = $c->stash->{Project};
477   my %logargs = (
478       sha1   => $project->head_hash,
479       count  => Gitalist->config->{paging}{log} || 25,
480       ($c->req->param('f') ? (file => $c->req->param('f')) : ())
481   );
482
483   my $mk_title = $c->stash->{short_cmt};
484   for my $commit ($project->list_revs(%logargs)) {
485     my $entry = XML::Atom::Entry->new;
486     $entry->title( $mk_title->($commit->comment) );
487     $entry->id($c->uri_for('commit', {h=>$commit->sha1}));
488     # XXX Needs work ...
489     $entry->content($commit->comment);
490     $feed->add_entry($entry);
491   }
492
493   $c->response->body($feed->as_xml);
494   $c->response->content_type('application/atom+xml');
495   $c->response->status(200);
496 }
497
498 =head2 rss
499
500 Provides an RSS feed for a given project.
501
502 =cut
503
504 sub rss : Local {
505   my ($self, $c) = @_;
506
507   my $project = $c->stash->{Project};
508
509   my $rss = XML::RSS->new(version => '2.0');
510   $rss->channel(
511     title          => lc(Sys::Hostname::hostname()) . ' - ' . Gitalist->config->{name},
512     link           => $c->uri_for('summary', {p=>$project->name}),
513     language       => 'en',
514     description    => $project->description,
515     pubDate        => DateTime->now,
516     lastBuildDate  => DateTime->now,
517   );
518
519   my %logargs = (
520       sha1   => $project->head_hash,
521       count  => Gitalist->config->{paging}{log} || 25,
522       ($c->req->param('f') ? (file => $c->req->param('f')) : ())
523   );
524   my $mk_title = $c->stash->{short_cmt};
525   for my $commit ($project->list_revs(%logargs)) {
526     # XXX Needs work ....
527     $rss->add_item(
528         title       => $mk_title->($commit->comment),
529         permaLink   => $c->uri_for(commit => {h=>$commit->sha1}),
530         description => $commit->comment,
531     );
532   }
533
534   $c->response->body($rss->as_string);
535   $c->response->content_type('application/rss+xml');
536   $c->response->status(200);
537 }
538
539 =head2 patch
540
541 A raw patch for a given commit.
542
543 =cut
544
545 sub patch : Local {
546     my ($self, $c) = @_;
547     $c->detach('patches', [1]);
548 }
549
550 =head2 patches
551
552 The patcheset for a given commit ???
553
554 =cut
555
556 sub patches : Local {
557     my ($self, $c, $count) = @_;
558     $count ||= Gitalist->config->{patches}{max};
559     my $commit = $self->_get_object($c);
560     my $parent = $c->req->param('hp') || undef;
561     my $patch = $commit->get_patch( $parent, $count );
562     $c->response->body($patch);
563     $c->response->content_type('text/plain');
564     $c->response->status(200);
565 }
566
567 =head2 snapshot
568
569 Provides a snapshot of a given commit.
570
571 =cut
572
573 sub snapshot : Local {
574     my ($self, $c) = @_;
575     my $format = $c->req->param('sf') || 'tgz';
576     die unless $format;
577     my $sha1 = $c->req->param('h') || $self->_get_object($c)->sha1;
578     my @snap = $c->stash->{Project}->snapshot(
579         sha1 => $sha1,
580         format => $format
581     );
582     $c->response->status(200);
583     $c->response->headers->header( 'Content-Disposition' =>
584                                        "attachment; filename=$snap[0]");
585     $c->response->body($snap[1]);
586 }
587
588 =head2 auto
589
590 Populate the header and footer. Perhaps not the best location.
591
592 =cut
593
594 sub auto : Private {
595   my($self, $c) = @_;
596
597   my $project = $c->req->param('p');
598   if (defined $project) {
599     eval {
600       $c->stash(Project => $c->model('GitRepos')->project($project));
601     };
602     if ($@) {
603       $c->detach('error_404');
604     }
605   }
606
607   my $a_project = $c->stash->{Project} || $c->model()->projects->[0];
608   $c->stash(
609     git_version => $a_project->run_cmd('--version'),
610     version     => $Gitalist::VERSION,
611
612     # XXX Move these to a plugin!
613     time_since => sub {
614       return 'never' unless $_[0];
615       return age_string(time - $_[0]->epoch);
616     },
617     short_cmt => sub {
618       my $cmt = shift;
619       my($line) = split /\n/, $cmt;
620       $line =~ s/^(.{70,80}\b).*/$1 \x{2026}/;
621       return $line;
622     },
623     abridged_description => sub {
624         join(' ', grep { defined } (split / /, shift)[0..10]);
625     },
626   );
627 }
628
629 sub opml : Local {
630     # FIXME - implement snapshot
631     Carp::croak "Not implemented.";
632 }
633
634 =head2 end
635
636 Attempt to render a view, if needed.
637
638 =cut
639
640 sub end : ActionClass('RenderView') {
641     my ($self, $c) = @_;
642     # Give project views the current HEAD.
643     if ($c->stash->{Project}) {
644         $c->stash->{HEAD} = $c->stash->{Project}->head_hash;
645     }
646 }
647
648 sub error_404 :Private {
649     my ($self, $c) = @_;
650     $c->response->status(404);
651     $c->stash(
652         title => 'Page not found',
653         content => 'Page not found',
654     );
655 }
656
657 sub age_string {
658   my $age = shift;
659   my $age_str;
660
661   if ( $age > 60 * 60 * 24 * 365 * 2 ) {
662     $age_str  = ( int $age / 60 / 60 / 24 / 365 );
663     $age_str .= " years ago";
664   }
665   elsif ( $age > 60 * 60 * 24 * ( 365 / 12 ) * 2 ) {
666     $age_str  = int $age / 60 / 60 / 24 / ( 365 / 12 );
667     $age_str .= " months ago";
668   }
669   elsif ( $age > 60 * 60 * 24 * 7 * 2 ) {
670     $age_str  = int $age / 60 / 60 / 24 / 7;
671     $age_str .= " weeks ago";
672   }
673   elsif ( $age > 60 * 60 * 24 * 2 ) {
674     $age_str  = int $age / 60 / 60 / 24;
675     $age_str .= " days ago";
676   }
677   elsif ( $age > 60 * 60 * 2 ) {
678     $age_str  = int $age / 60 / 60;
679     $age_str .= " hours ago";
680   }
681   elsif ( $age > 60 * 2 ) {
682     $age_str  = int $age / 60;
683     $age_str .= " min ago";
684   }
685   elsif ( $age > 2 ) {
686     $age_str  = int $age;
687     $age_str .= " sec ago";
688   }
689   else {
690     $age_str .= " right now";
691   }
692   return $age_str;
693 }
694
695
696 =head1 AUTHOR
697
698 Dan Brook
699
700 =head1 LICENSE
701
702 This library is free software. You can redistribute it and/or modify
703 it under the same terms as Perl itself.
704
705 =cut
706
707 __PACKAGE__->meta->make_immutable;