Unfuck things
[catagits/Gitalist.git] / lib / Gitalist / Model / Git.pm
1 package Gitalist::Model::Git;
2
3 use Moose;
4 use namespace::autoclean;
5 use Moose::Autobox;
6
7 extends 'Catalyst::Model';
8 with 'Catalyst::Component::InstancePerContext';
9
10 =head1 NAME
11
12 Gitalist::Model::Git - the model for git interactions
13
14 =head1 DESCRIPTION
15
16 [enter your description here]
17
18 =head1 METHODS
19
20 =cut
21
22 use Git::PurePerl;
23
24 sub build_per_context_instance {
25   my ( $self, $c ) = @_;
26
27   my $app = blessed($c) || $c;
28   my $model = Git::Repos->new(
29     project => ([$c->req->parameters->{p} || '/']->flatten)[0],
30     repo_dir => $app->config->{repo_dir}, # FIXME - Move to model config
31   );
32
33   # This is fugly as fuck. Move Git::PurePerl construction into attribute builders..
34   (my $pd = $self->project_dir( $self->project )) =~ s{/\.git$}();
35   $model->gpp( Git::PurePerl->new(directory => $pd) );
36
37   return $model;
38 }
39
40 package Git::Repos; # Better name? Split out into own file once we have a sane name.
41 use Moose;
42 use namespace::autoclean;
43 use DateTime;
44 use Path::Class;
45 use File::Which;
46 use Carp qw/croak/;
47 use File::Find::Rule;
48 use DateTime::Format::Mail;
49 use File::Stat::ModeString;
50 use List::MoreUtils qw/any zip/;
51 use MooseX::Types::Common::String qw/NonEmptySimpleStr/; # FIXME, use Types::Path::Class and coerce
52
53 use Git::PurePerl;
54
55 # Should these live in a separate module? Or perhaps extended Regexp::Common?
56 # No, should be a MooseX::Types module!!
57 our $SHA1RE = qr/[0-9a-fA-F]{40}/;
58
59 # These are static and only need to be setup on app start.
60 has repo_dir => ( isa => NonEmptySimpleStr, is => 'ro', required => 1 ); # Fixme - path::class
61 has git      => ( isa => NonEmptySimpleStr, is => 'ro', lazy_build => 1 );
62 # These are dynamic and can be different from one request to the next.
63 has project  => ( isa => NonEmptySimpleStr, is => 'rw');
64 has gpp      => ( isa => 'Git::PurePerl',   is => 'rw', lazy_build => 1 );
65
66
67
68 =head2 BUILD
69
70 =cut
71
72 sub BUILD {
73     my ($self) = @_;
74     $self->git; # Cause lazy value build.
75     $self->repo_dir;
76 }
77
78 sub _build_git {
79     my $git = File::Which::which('git');
80
81     if (!$git) {
82         die <<EOR;
83 Could not find a git executable.
84 Please specify the which git executable to use in gitweb.yml
85 EOR
86     }
87
88     return $git;
89 }
90
91 =head2 get_object
92
93 A wrapper for the equivalent L<Git::PurePerl> method.
94
95 =cut
96
97 sub get_object {
98   my($self, $sha1) = @_;
99
100   # We either want an object or undef, *not* an empty list.
101   return $self->gpp->get_object($sha1) || undef;
102 }
103
104 =head2 is_git_repo
105
106 Determine whether a given directory (as a L<Path::Class::Dir> object) is a
107 C<git> repo.
108
109 =cut
110
111 sub is_git_repo {
112   my ($self, $dir) = @_;
113
114   return -f $dir->file('HEAD') || -f $dir->file('.git/HEAD');
115 }
116
117 =head2 run_cmd
118
119 Call out to the C<git> binary and return a string consisting of the output.
120
121 =cut
122
123 sub run_cmd {
124   my ($self, @args) = @_;
125
126   print STDERR 'RUNNING: ', $self->git, qq[ @args], $/;
127
128   open my $fh, '-|', $self->git, @args
129     or die "failed to run git command";
130   binmode $fh, ':encoding(UTF-8)';
131
132   my $output = do { local $/ = undef; <$fh> };
133   close $fh;
134
135   return $output;
136 }
137
138 =head2 project_dir
139
140 The directory under which the given project will reside i.e C<.git/..>
141
142 =cut
143
144 sub project_dir {
145   my($self, $project) = @_;
146
147   my $dir = blessed($project) && $project->isa('Path::Class::Dir')
148        ? $project->stringify
149        : $self->dir_from_project_name($project);
150
151   $dir .= '/.git'
152         if -f dir($dir)->file('.git/HEAD');
153
154   return $dir;
155 }
156
157 =head2 run_cmd_in
158
159 Run a C<git> command in a given project and return the output as a string.
160
161 =cut
162
163 sub run_cmd_in {
164   my ($self, $project, @args) = @_;
165
166   return $self->run_cmd('--git-dir' => $self->project_dir($project), @args);
167 }
168
169 =head2 command
170
171 Run a C<git> command for the project specified in the C<p> parameter and
172 return the output as a list of strings corresponding to the lines of output.
173
174 =cut
175
176 sub command {
177   my($self, @args) = @_;
178
179   my $output = $self->run_cmd('--git-dir' => $self->project_dir($self->project), @args);
180
181   return $output ? split(/\n/, $output) : ();
182 }
183
184 =head2 project_info
185
186 Returns a hash corresponding to a given project's properties. The keys will
187 be:
188
189         name
190         description (empty if .git/description is empty/unnamed)
191         owner
192         last_change
193
194 =cut
195
196 sub project_info {
197   my ($self, $project) = @_;
198
199   return {
200     name => $project,
201     $self->get_project_properties(
202       $self->dir_from_project_name($project),
203     ),
204   };
205 }
206
207 =head2 get_project_properties
208
209 Called by C<project_info> to get a project's properties.
210
211 =cut
212
213 sub get_project_properties {
214   my ($self, $dir) = @_;
215   my %props;
216
217   eval {
218     $props{description} = $dir->file('description')->slurp;
219     chomp $props{description};
220     };
221
222   if ($props{description} && $props{description} =~ /^Unnamed repository;/) {
223     delete $props{description};
224   }
225
226   ($props{owner} = (getpwuid $dir->stat->uid)[6]) =~ s/,+$//;
227
228   my $output = $self->run_cmd_in($dir, qw{
229       for-each-ref --format=%(committer)
230       --sort=-committerdate --count=1 refs/heads
231       });
232
233   if (my ($epoch, $tz) = $output =~ /\s(\d+)\s+([+-]\d+)$/) {
234     my $dt = DateTime->from_epoch(epoch => $epoch);
235     $dt->set_time_zone($tz);
236     $props{last_change} = $dt;
237   }
238
239   return %props;
240 }
241
242 =head2 list_projects
243
244 For the C<repo_dir> specified in the config return an array of projects where
245 each item will contain the contents of L</project_info>.
246
247 =cut
248
249 sub list_projects {
250     my ($self, $dir) = @_;
251
252     my $base = dir($dir || $self->repo_dir);
253
254     my @ret;
255     my $dh = $base->open or die("Cannot open dir $base");
256     while (my $file = $dh->read) {
257         next if $file =~ /^.{1,2}$/;
258
259         my $obj = $base->subdir($file);
260         next unless -d $obj;
261         next unless $self->is_git_repo($obj);
262                 # XXX Leaky abstraction alert!
263                 my $is_bare = !-d $obj->subdir('.git');
264
265                 my $name = (File::Spec->splitdir($obj))[-1];
266         push @ret, {
267             name => ($name . ( $is_bare ? '' : '/.git' )),
268             $self->get_project_properties(
269                                 $is_bare ? $obj : $obj->subdir('.git')
270                         ),
271         };
272   }
273
274   return [sort { $a->{name} cmp $b->{name} } @ret];
275 }
276
277 =head2 dir_from_project_name
278
279 Get the corresponding directory of a given project.
280
281 =cut
282
283 sub dir_from_project_name {
284   my ($self, $project) = @_;
285
286   return dir($self->repo_dir)->subdir($project);
287 }
288
289 =head2 head_hash
290
291 Find the hash of a given head (defaults to HEAD) of given (or current) project.
292
293 =cut
294
295 sub head_hash {
296   my ($self, $head, $project) = @_;
297
298   my $output = $self->run_cmd_in($project || $self->project, qw/rev-parse --verify/, $head || 'HEAD' );
299   return unless defined $output;
300
301   my($sha1) = $output =~ /^($SHA1RE)$/;
302   return $sha1;
303 }
304
305 =head2 list_tree
306
307 For a given tree sha1 return an array describing the tree's contents. Where
308 the keys for each item will be:
309
310         mode
311         type
312         object
313         file
314
315 =cut
316
317 sub list_tree {
318   my ($self, $rev, $project) = @_;
319
320   $project ||= $self->project;
321   $rev ||= $self->head_hash($project);
322
323   my $output = $self->run_cmd_in($project, qw/ls-tree -z/, $rev);
324   return unless defined $output;
325
326   my @ret;
327   for my $line (split /\0/, $output) {
328     my ($mode, $type, $object, $file) = split /\s+/, $line, 4;
329
330     push @ret, {
331       mode    => oct $mode,
332           # XXX I wonder why directories always turn up as 040000 ...
333       modestr => $self->get_object_mode_string({mode=>oct $mode}),
334       type    => $type,
335       object  => $object,
336       file    => $file,
337     };
338   }
339
340   return @ret;
341 }
342
343 =head2 get_object_mode_string
344
345 Provide a string equivalent of an octal mode e.g 0644 eq '-rw-r--r--'.
346
347 =cut
348
349 sub get_object_mode_string {
350   my ($self, $object) = @_;
351
352   return unless $object && $object->{mode};
353   return mode_to_string($object->{mode});
354 }
355
356 =head2 get_object_type
357
358 =cut
359
360 sub get_object_type {
361   my ($self, $object, $project) = @_;
362
363   chomp(my $output = $self->run_cmd_in($project || $self->project, qw/cat-file -t/, $object));
364   return unless $output;
365
366   return $output;
367 }
368
369 =head2 cat_file
370
371 Return the contents of a given file.
372
373 =cut
374
375 sub cat_file {
376   my ($self, $object, $project) = @_;
377
378   my $type = $self->get_object_type($object, $project);
379   die "object `$object' is not a file\n"
380     if (!defined $type || $type ne 'blob');
381
382   my $output = $self->run_cmd_in($project || $self->project, qw/cat-file -p/, $object);
383   return unless $output;
384
385   return $output;
386 }
387
388 =head2 hash_by_path
389
390 For a given sha1 and path find the corresponding hash. Useful for find blobs.
391
392 =cut
393
394 sub hash_by_path {
395   my($self, $base, $path, $type) = @_;
396
397   $path =~ s{/+$}();
398
399   my($line) = $self->command('ls-tree', $base, '--', $path)
400     or return;
401
402   #'100644 blob 0fa3f3a66fb6a137f6ec2c19351ed4d807070ffa        panic.c'
403   $line =~ m/^([0-9]+) (.+) ($SHA1RE)\t/;
404   return defined $type && $type ne $2
405     ? ()
406     : $3;
407 }
408
409 =head2 valid_rev
410
411 Check whether a given rev is valid i.e looks like a sha1.
412
413 =cut
414
415 sub valid_rev {
416   my ($self, $rev) = @_;
417
418   return unless $rev;
419   return ($rev =~ /^($SHA1RE)$/);
420 }
421
422 =head2 raw_diff
423
424 Provides the raw output of a diff.
425
426 =cut
427
428 # gitweb uses the following sort of command for diffing merges:
429 # /home/dbrook/apps/bin/git --git-dir=/home/dbrook/dev/app/.git diff-tree -r -M --no-commit-id --patch-with-raw --full-index --cc 316cf158df3f6207afbae7270bcc5ba0 --
430 # and for regular diffs
431 # /home/dbrook/apps/bin/git --git-dir=/home/dbrook/dev/app/.git diff-tree -r -M --no-commit-id --patch-with-raw --full-index 2e3454ca0749641b42f063730b0090e1 316cf158df3f6207afbae7270bcc5ba0 --
432
433 sub raw_diff {
434   my ($self, @args) = @_;
435
436   return $self->command(
437           qw(diff-tree -r -M --no-commit-id --full-index),
438           @args
439   );
440 }
441
442 =pod
443 diff --git a/TODO b/TODO
444 index 6a05e77..2071fd0 100644
445 --- a/TODO
446 +++ b/TODO
447 @@ -2,4 +2,3 @@
448  * An action to find what branches have been merged, either as a list or through a search mechanism.
449  * An action to find which branches a given commit is on.
450  * Fix any not text/html bits e.g the patch action.
451 -* Simplify the creation of links.
452 diff --git a/lib/Gitalist/Controller/Root.pm b/lib/Gitalist/Controller/Root.pm
453 index 706d024..7fac165 100644
454 --- a/lib/Gitalist/Controller/Root.pm
455 +++ b/lib/Gitalist/Controller/Root.pm
456 @@ -157,23 +157,6 @@ sub shortlog : Local {
457    );
458  }
459  
460 -=head2 tree
461 -
462 -The tree of a given commit.
463 =cut
464
465 =head2 diff
466
467 Returns a list of diff chunks corresponding to the files contained in the diff
468 and some associated metadata.
469
470 =cut
471
472 # XXX Ideally this would return a wee object instead of ad hoc structures.
473 sub diff {
474   my($self, %args) = @_;
475
476   # So either a parent is specifed, or we use the commit's parent if there's
477   # only one, otherwise it was a merge commit.
478   my $parent = $args{parent}
479                          ? $args{parent}
480                          : @{$args{commit}->parents} <= 1
481                            ? $args{commit}->parent_sha1
482                            : '-c';
483   my @etc = (
484     ( $args{file}  ? ('--', $args{file}) : () ),
485   );
486
487   my @out = $self->raw_diff(
488         ( $args{patch} ? '--patch-with-raw' : () ),
489           $parent, $args{commit}->sha1, @etc
490   );
491
492   # XXX Yes, there is much wrongness having parse_diff_tree be destructive.
493   my @difftree = $self->parse_diff_tree(\@out);
494
495   return \@difftree
496         unless $args{patch};
497
498   # The blank line between the tree and the patch.
499   shift @out;
500
501   # XXX And no I'm not happy about having diff return tree + patch.
502   return \@difftree, [$self->parse_diff(@out)];
503 }
504
505 sub parse_diff {
506   my($self, @diff) = @_;
507
508   my @ret;
509   for (@diff) {
510     # This regex is a little pathological.
511     if(m{^diff --git (a/(.*?)) (b/\2)}) {
512       push @ret, {
513         head => $_,
514         a    => $1,
515         b    => $3,
516         file => $2,
517         diff => '',
518       };
519       next;
520     }
521   
522     if(/^index (\w+)\.\.(\w+) (\d+)$/) {
523       @{$ret[-1]}{qw(index src dst mode)} = ($_, $1, $2, $3);
524       next
525     }
526   
527     # XXX Somewhat hacky. Ahem.
528     $ret[@ret ? -1 : 0]{diff} .= "$_\n";
529   }
530
531   return @ret;
532 }
533
534 # $ git diff-tree -r --no-commit-id -M b222ff0a7260cc1777c7e455dfcaf22551a512fc 7e54e579e196c6c545fee1030175f65a111039d4
535 # :100644 100644 6a85d6c6315b55a99071974eb6ce643aeb2799d6 44c03ed6c328fa6de4b1d9b3f19a3de96b250370 M      templates/blob.tt2
536
537 =head2 parse_diff_tree
538
539 Given a L<Git::PurePerl> commit object return a list of hashes corresponding
540 to the C<diff-tree> output.
541
542 =cut
543
544 sub parse_diff_tree {
545   my($self, $diff) = @_;
546
547   my @keys = qw(modesrc modedst sha1src sha1dst status src dst);
548   my @ret;
549   while(@$diff and $diff->[0] =~ /^:\d+/) {
550         my $line = shift @$diff;
551     # see. man git-diff-tree for more info
552     # mode src, mode dst, sha1 src, sha1 dst, status, src[, dst]
553     my @vals = $line =~ /^:(\d+) (\d+) ($SHA1RE) ($SHA1RE) ([ACDMRTUX]\d*)\t([^\t]+)(?:\t([^\n]+))?$/;
554     my %line = zip @keys, @vals;
555     # Some convenience keys
556     $line{file}   = $line{src};
557     $line{sha1}   = $line{sha1dst};
558     $line{is_new} = $line{sha1src} =~ /^0+$/
559                 if $line{sha1src};
560         @line{qw/status sim/} = $line{status} =~ /(R)(\d+)/
561       if $line{status} =~ /^R/;
562     push @ret, \%line;
563   }
564
565   return @ret;
566 }
567
568 =head2 parse_rev_list
569
570 Given the output of the C<rev-list> command return a list of hashes.
571
572 =cut
573
574 sub parse_rev_list {
575   my ($self, $output) = @_;
576   my @ret;
577
578   my @revs = split /\0/, $output;
579
580   for my $rev (split /\0/, $output) {
581     for my $line (split /\n/, $rev, 6) {
582       chomp $line;
583       next unless $line;
584
585       if ($self->valid_rev($line)) {
586         push @ret, $self->get_object($line);
587       }
588         }
589   }
590
591   return @ret;
592 }
593
594 =head2 list_revs
595
596 Calls the C<rev-list> command (a low-level from of C<log>) and returns an
597 array of hashes.
598
599 =cut
600
601 sub list_revs {
602   my ($self, %args) = @_;
603
604   $args{sha1} ||= $self->head_hash($args{project});
605
606   my $output = $self->run_cmd_in($args{project} || $self->project, 'rev-list',
607     '--header',
608     (defined $args{ count } ? "--max-count=$args{count}" : ()),
609     (defined $args{ skip  } ? "--skip=$args{skip}"       : ()),
610     $args{sha1},
611     '--',
612     ($args{file} ? $args{file} : ()),
613   );
614   return unless $output;
615
616   my @revs = $self->parse_rev_list($output);
617
618   return @revs;
619 }
620
621 =head2 rev_info
622
623 Get a single piece of revision information for a given sha1.
624
625 =cut
626
627 sub rev_info {
628   my($self, $rev, $project) = @_;
629
630   return unless $self->valid_rev($rev);
631
632   return $self->list_revs(
633           rev => $rev, count => 1,
634           ( $project ? (project => $project) : () )
635   );
636 }
637
638 =head2 reflog
639
640 Calls the C<reflog> command and returns a list of hashes.
641
642 =cut
643
644 sub reflog {
645   my ($self, @logargs) = @_;
646
647   my @entries
648     =  $self->run_cmd_in($self->project, qw(log -g), @logargs)
649     =~ /(^commit.+?(?:(?=^commit)|(?=\z)))/msg;
650
651 =pod
652   commit 02526fc15beddf2c64798a947fecdd8d11bf993d
653   Reflog: HEAD@{14} (The Git Server <git@git.dev.venda.com>)
654   Reflog message: push
655   Author: Foo Barsby <fbarsby@example.com>
656   Date:   Thu Sep 17 12:26:05 2009 +0100
657
658       Merge branch 'abc123'
659 =cut
660
661   return map {
662
663     # XXX Stuff like this makes me want to switch to Git::PurePerl
664     my($sha1, $type, $author, $date)
665       = m{
666           ^ commit \s+ ($SHA1RE)$
667           .*?
668           Reflog[ ]message: \s+ (.+?)$ \s+
669           Author: \s+ ([^<]+) <.*?$ \s+
670           Date: \s+ (.+?)$
671         }xms;
672
673     pos($_) = index($_, $date) + length $date;
674
675     # Yeah, I just did that.
676
677     my($msg) = /\G\s+(\S.*)/sg;
678
679     {
680       hash    => $sha1,
681       type    => $type,
682       author  => $author,
683
684       # XXX Add DateTime goodness.
685       date    => $date,
686       message => $msg,
687     };
688   } @entries;
689 }
690
691 =head2 heads
692
693 Returns an array of hashes representing the heads (aka branches) for the
694 given, or current, project.
695
696 =cut
697
698 sub heads {
699   my ($self, $project) = @_;
700
701   my @output = $self->command(qw/for-each-ref --sort=-committerdate /, '--format=%(objectname)%00%(refname)%00%(committer)', 'refs/heads');
702
703   my @ret;
704   for my $line (@output) {
705     my ($rev, $head, $commiter) = split /\0/, $line, 3;
706     $head =~ s!^refs/heads/!!;
707
708     push @ret, { sha1 => $rev, name => $head };
709
710     #FIXME: That isn't the time I'm looking for..
711     if (my ($epoch, $tz) = $line =~ /\s(\d+)\s+([+-]\d+)$/) {
712       my $dt = DateTime->from_epoch(epoch => $epoch);
713       $dt->set_time_zone($tz);
714       $ret[-1]->{last_change} = $dt;
715     }
716   }
717
718   return @ret;
719 }
720
721 =head2 refs_for
722
723 For a given sha1 check which branches currently point at it.
724
725 =cut
726
727 sub refs_for {
728         my($self, $sha1) = @_;
729
730         my $refs = $self->references->{$sha1};
731
732         return $refs ? @$refs : ();
733 }
734
735 =head2 references
736
737 A wrapper for C<git show-ref --dereference>. Based on gitweb's
738 C<git_get_references>.
739
740 =cut
741
742 sub references {
743         my($self) = @_;
744
745         return $self->{references}
746                 if $self->{references};
747
748         # 5dc01c595e6c6ec9ccda4f6f69c131c0dd945f8c refs/tags/v2.6.11
749         # c39ae07f393806ccf406ef966e9a15afc43cc36a refs/tags/v2.6.11^{}
750         my @reflist = $self->command(qw(show-ref --dereference))
751                 or return;
752
753         my %refs;
754         for(@reflist) {
755                 push @{$refs{$1}}, $2
756                         if m!^($SHA1RE)\srefs/(.*)$!;
757         }
758
759         return $self->{references} = \%refs;
760 }
761
762 1;
763
764 __PACKAGE__->meta->make_immutable;