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