]> sipb.mit.edu Git - ikiwiki.git/blob - IkiWiki/Plugin/git.pm
Merge commit 'fae59b07b02dbcaba892e96ff86f3f800e6ef54a' into sipb
[ikiwiki.git] / IkiWiki / Plugin / git.pm
1 #!/usr/bin/perl
2 package IkiWiki::Plugin::git;
3
4 use warnings;
5 use strict;
6 use IkiWiki;
7 use IkiWiki::UserInfo;
8 use Encode;
9 use URI::Escape q{uri_escape_utf8};
10 use open qw{:utf8 :std};
11
12 my $sha1_pattern     = qr/[0-9a-fA-F]{40}/; # pattern to validate Git sha1sums
13 my $dummy_commit_msg = 'dummy commit';      # message to skip in recent changes
14
15 sub import {
16         hook(type => "checkconfig", id => "git", call => \&checkconfig);
17         hook(type => "getsetup", id => "git", call => \&getsetup);
18         hook(type => "genwrapper", id => "git", call => \&genwrapper);
19         hook(type => "rcs", id => "rcs_update", call => \&rcs_update);
20         hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit);
21         hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit);
22         hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged);
23         hook(type => "rcs", id => "rcs_add", call => \&rcs_add);
24         hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove);
25         hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename);
26         hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges);
27         hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff);
28         hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime);
29         hook(type => "rcs", id => "rcs_getmtime", call => \&rcs_getmtime);
30         hook(type => "rcs", id => "rcs_receive", call => \&rcs_receive);
31         hook(type => "rcs", id => "rcs_preprevert", call => \&rcs_preprevert);
32         hook(type => "rcs", id => "rcs_revert", call => \&rcs_revert);
33 }
34
35 sub checkconfig () {
36         if (! defined $config{gitorigin_branch}) {
37                 $config{gitorigin_branch}="origin";
38         }
39         if (! defined $config{gitmaster_branch}) {
40                 $config{gitmaster_branch}="master";
41         }
42         if (defined $config{git_wrapper} &&
43             length $config{git_wrapper}) {
44                 push @{$config{wrappers}}, {
45                         wrapper => $config{git_wrapper},
46                         wrappermode => (defined $config{git_wrappermode} ? $config{git_wrappermode} : "06755"),
47                         wrapper_background_command => $config{git_wrapper_background_command},
48                 };
49         }
50
51         if (defined $config{git_test_receive_wrapper} &&
52             length $config{git_test_receive_wrapper} &&
53             defined $config{untrusted_committers} &&
54             @{$config{untrusted_committers}}) {
55                 push @{$config{wrappers}}, {
56                         test_receive => 1,
57                         wrapper => $config{git_test_receive_wrapper},
58                         wrappermode => (defined $config{git_wrappermode} ? $config{git_wrappermode} : "06755"),
59                 };
60         }
61
62         # Avoid notes, parser does not handle and they only slow things down.
63         $ENV{GIT_NOTES_REF}="";
64         
65         # Run receive test only if being called by the wrapper, and not
66         # when generating same.
67         if ($config{test_receive} && ! exists $config{wrapper}) {
68                 require IkiWiki::Receive;
69                 IkiWiki::Receive::test();
70         }
71 }
72
73 sub getsetup () {
74         return
75                 plugin => {
76                         safe => 0, # rcs plugin
77                         rebuild => undef,
78                         section => "rcs",
79                 },
80                 git_wrapper => {
81                         type => "string",
82                         example => "/git/wiki.git/hooks/post-update",
83                         description => "git hook to generate",
84                         safe => 0, # file
85                         rebuild => 0,
86                 },
87                 git_wrapper_background_command => {
88                         type => "string",
89                         example => "git push github",
90                         description => "shell command for git_wrapper to run, in the background",
91                         safe => 0, # command
92                         rebuild => 0,
93                 },
94                 git_wrappermode => {
95                         type => "string",
96                         example => '06755',
97                         description => "mode for git_wrapper (can safely be made suid)",
98                         safe => 0,
99                         rebuild => 0,
100                 },
101                 git_test_receive_wrapper => {
102                         type => "string",
103                         example => "/git/wiki.git/hooks/pre-receive",
104                         description => "git pre-receive hook to generate",
105                         safe => 0, # file
106                         rebuild => 0,
107                 },
108                 untrusted_committers => {
109                         type => "string",
110                         example => [],
111                         description => "unix users whose commits should be checked by the pre-receive hook",
112                         safe => 0,
113                         rebuild => 0,
114                 },
115                 historyurl => {
116                         type => "string",
117                         example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=history;f=[[file]];hb=HEAD",
118                         description => "gitweb url to show file history ([[file]] substituted)",
119                         safe => 1,
120                         rebuild => 1,
121                 },
122                 diffurl => {
123                         type => "string",
124                         example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=blobdiff;f=[[file]];h=[[sha1_to]];hp=[[sha1_from]];hb=[[sha1_commit]];hpb=[[sha1_parent]]",
125                         description => "gitweb url to show a diff ([[file]], [[sha1_to]], [[sha1_from]], [[sha1_commit]], and [[sha1_parent]] substituted)",
126                         safe => 1,
127                         rebuild => 1,
128                 },
129                 gitorigin_branch => {
130                         type => "string",
131                         example => "origin",
132                         description => "where to pull and push changes (set to empty string to disable)",
133                         safe => 0, # paranoia
134                         rebuild => 0,
135                 },
136                 gitmaster_branch => {
137                         type => "string",
138                         example => "master",
139                         description => "branch that the wiki is stored in",
140                         safe => 0, # paranoia
141                         rebuild => 0,
142                 },
143 }
144
145 sub genwrapper {
146         if ($config{test_receive}) {
147                 require IkiWiki::Receive;
148                 return IkiWiki::Receive::genwrapper();
149         }
150         else {
151                 return "";
152         }
153 }
154
155 my $git_dir=undef;
156 my $prefix=undef;
157
158 sub in_git_dir ($$) {
159         $git_dir=shift;
160         my @ret=shift->();
161         $git_dir=undef;
162         $prefix=undef;
163         return @ret;
164 }
165
166 sub safe_git (&@) {
167         # Start a child process safely without resorting to /bin/sh.
168         # Returns command output (in list content) or success state
169         # (in scalar context), or runs the specified data handler.
170
171         my ($error_handler, $data_handler, @cmdline) = @_;
172
173         my $pid = open my $OUT, "-|";
174
175         error("Cannot fork: $!") if !defined $pid;
176
177         if (!$pid) {
178                 # In child.
179                 # Git commands want to be in wc.
180                 if (! defined $git_dir) {
181                         chdir $config{srcdir}
182                             or error("cannot chdir to $config{srcdir}: $!");
183                 }
184                 else {
185                         chdir $git_dir
186                             or error("cannot chdir to $git_dir: $!");
187                 }
188                 exec @cmdline or error("Cannot exec '@cmdline': $!");
189         }
190         # In parent.
191
192         # git output is probably utf-8 encoded, but may contain
193         # other encodings or invalidly encoded stuff. So do not rely
194         # on the normal utf-8 IO layer, decode it by hand.
195         binmode($OUT);
196
197         my @lines;
198         while (<$OUT>) {
199                 $_=decode_utf8($_, 0);
200
201                 chomp;
202
203                 if (! defined $data_handler) {
204                         push @lines, $_;
205                 }
206                 else {
207                         last unless $data_handler->($_);
208                 }
209         }
210
211         close $OUT;
212
213         $error_handler->("'@cmdline' failed: $!") if $? && $error_handler;
214
215         return wantarray ? @lines : ($? == 0);
216 }
217 # Convenient wrappers.
218 sub run_or_die ($@) { safe_git(\&error, undef, @_) }
219 sub run_or_cry ($@) { safe_git(sub { warn @_ }, undef, @_) }
220 sub run_or_non ($@) { safe_git(undef, undef, @_) }
221
222
223 sub merge_past ($$$) {
224         # Unlike with Subversion, Git cannot make a 'svn merge -rN:M file'.
225         # Git merge commands work with the committed changes, except in the
226         # implicit case of '-m' of git checkout(1).  So we should invent a
227         # kludge here.  In principle, we need to create a throw-away branch
228         # in preparing for the merge itself.  Since branches are cheap (and
229         # branching is fast), this shouldn't cost high.
230         #
231         # The main problem is the presence of _uncommitted_ local changes.  One
232         # possible approach to get rid of this situation could be that we first
233         # make a temporary commit in the master branch and later restore the
234         # initial state (this is possible since Git has the ability to undo a
235         # commit, i.e. 'git reset --soft HEAD^').  The method can be summarized
236         # as follows:
237         #
238         #       - create a diff of HEAD:current-sha1
239         #       - dummy commit
240         #       - create a dummy branch and switch to it
241         #       - rewind to past (reset --hard to the current-sha1)
242         #       - apply the diff and commit
243         #       - switch to master and do the merge with the dummy branch
244         #       - make a soft reset (undo the last commit of master)
245         #
246         # The above method has some drawbacks: (1) it needs a redundant commit
247         # just to get rid of local changes, (2) somewhat slow because of the
248         # required system forks.  Until someone points a more straight method
249         # (which I would be grateful) I have implemented an alternative method.
250         # In this approach, we hide all the modified files from Git by renaming
251         # them (using the 'rename' builtin) and later restore those files in
252         # the throw-away branch (that is, we put the files themselves instead
253         # of applying a patch).
254
255         my ($sha1, $file, $message) = @_;
256
257         my @undo;      # undo stack for cleanup in case of an error
258         my $conflict;  # file content with conflict markers
259
260         eval {
261                 # Hide local changes from Git by renaming the modified file.
262                 # Relative paths must be converted to absolute for renaming.
263                 my ($target, $hidden) = (
264                     "$config{srcdir}/${file}", "$config{srcdir}/${file}.${sha1}"
265                 );
266                 rename($target, $hidden)
267                     or error("rename '$target' to '$hidden' failed: $!");
268                 # Ensure to restore the renamed file on error.
269                 push @undo, sub {
270                         return if ! -e "$hidden"; # already renamed
271                         rename($hidden, $target)
272                             or warn "rename '$hidden' to '$target' failed: $!";
273                 };
274
275                 my $branch = "throw_away_${sha1}"; # supposed to be unique
276
277                 # Create a throw-away branch and rewind backward.
278                 push @undo, sub { run_or_cry('git', 'branch', '-D', $branch) };
279                 run_or_die('git', 'branch', $branch, $sha1);
280
281                 # Switch to throw-away branch for the merge operation.
282                 push @undo, sub {
283                         if (!run_or_cry('git', 'checkout', $config{gitmaster_branch})) {
284                                 run_or_cry('git', 'checkout','-f',$config{gitmaster_branch});
285                         }
286                 };
287                 run_or_die('git', 'checkout', $branch);
288
289                 # Put the modified file in _this_ branch.
290                 rename($hidden, $target)
291                     or error("rename '$hidden' to '$target' failed: $!");
292
293                 # _Silently_ commit all modifications in the current branch.
294                 run_or_non('git', 'commit', '-m', $message, '-a');
295                 # ... and re-switch to master.
296                 run_or_die('git', 'checkout', $config{gitmaster_branch});
297
298                 # Attempt to merge without complaining.
299                 if (!run_or_non('git', 'pull', '--no-commit', '.', $branch)) {
300                         $conflict = readfile($target);
301                         run_or_die('git', 'reset', '--hard');
302                 }
303         };
304         my $failure = $@;
305
306         # Process undo stack (in reverse order).  By policy cleanup
307         # actions should normally print a warning on failure.
308         while (my $handle = pop @undo) {
309                 $handle->();
310         }
311
312         error("Git merge failed!\n$failure\n") if $failure;
313
314         return $conflict;
315 }
316
317 sub decode_git_file ($) {
318         my $file=shift;
319
320         # git does not output utf-8 filenames, but instead
321         # double-quotes them with the utf-8 characters
322         # escaped as \nnn\nnn.
323         if ($file =~ m/^"(.*)"$/) {
324                 ($file=$1) =~ s/\\([0-7]{1,3})/chr(oct($1))/eg;
325         }
326
327         # strip prefix if in a subdir
328         if (! defined $prefix) {
329                 ($prefix) = run_or_die('git', 'rev-parse', '--show-prefix');
330                 if (! defined $prefix) {
331                         $prefix="";
332                 }
333         }
334         $file =~ s/^\Q$prefix\E//;
335
336         return decode("utf8", $file);
337 }
338
339 sub parse_diff_tree ($) {
340         # Parse the raw diff tree chunk and return the info hash.
341         # See git-diff-tree(1) for the syntax.
342         my $dt_ref = shift;
343
344         # End of stream?
345         return if ! @{ $dt_ref } ||
346                   !defined $dt_ref->[0] || !length $dt_ref->[0];
347
348         my %ci;
349         # Header line.
350         while (my $line = shift @{ $dt_ref }) {
351                 return if $line !~ m/^(.+) ($sha1_pattern)/;
352
353                 my $sha1 = $2;
354                 $ci{'sha1'} = $sha1;
355                 last;
356         }
357
358         # Identification lines for the commit.
359         while (my $line = shift @{ $dt_ref }) {
360                 # Regexps are semi-stolen from gitweb.cgi.
361                 if ($line =~ m/^tree ([0-9a-fA-F]{40})$/) {
362                         $ci{'tree'} = $1;
363                 }
364                 elsif ($line =~ m/^parent ([0-9a-fA-F]{40})$/) {
365                         # XXX: collecting in reverse order
366                         push @{ $ci{'parents'} }, $1;
367                 }
368                 elsif ($line =~ m/^(author|committer) (.*) ([0-9]+) (.*)$/) {
369                         my ($who, $name, $epoch, $tz) =
370                            ($1,   $2,    $3,     $4 );
371
372                         $ci{  $who          } = $name;
373                         $ci{ "${who}_epoch" } = $epoch;
374                         $ci{ "${who}_tz"    } = $tz;
375
376                         if ($name =~ m/^([^<]+)\s+<([^@>]+)/) {
377                                 $ci{"${who}_name"} = $1;
378                                 $ci{"${who}_username"} = $2;
379                         }
380                         elsif ($name =~ m/^([^<]+)\s+<>$/) {
381                                 $ci{"${who}_username"} = $1;
382                         }
383                         else {
384                                 $ci{"${who}_username"} = $name;
385                         }
386                 }
387                 elsif ($line =~ m/^$/) {
388                         # Trailing empty line signals next section.
389                         last;
390                 }
391         }
392
393         debug("No 'tree' seen in diff-tree output") if !defined $ci{'tree'};
394         
395         if (defined $ci{'parents'}) {
396                 $ci{'parent'} = @{ $ci{'parents'} }[0];
397         }
398         else {
399                 $ci{'parent'} = 0 x 40;
400         }
401
402         # Commit message (optional).
403         while ($dt_ref->[0] =~ /^    /) {
404                 my $line = shift @{ $dt_ref };
405                 $line =~ s/^    //;
406                 push @{ $ci{'comment'} }, $line;
407         }
408         shift @{ $dt_ref } if $dt_ref->[0] =~ /^$/;
409
410         # Modified files.
411         while (my $line = shift @{ $dt_ref }) {
412                 if ($line =~ m{^
413                         (:+)       # number of parents
414                         ([^\t]+)\t # modes, sha1, status
415                         (.*)       # file names
416                 $}xo) {
417                         my $num_parents = length $1;
418                         my @tmp = split(" ", $2);
419                         my ($file, $file_to) = split("\t", $3);
420                         my @mode_from = splice(@tmp, 0, $num_parents);
421                         my $mode_to = shift(@tmp);
422                         my @sha1_from = splice(@tmp, 0, $num_parents);
423                         my $sha1_to = shift(@tmp);
424                         my $status = shift(@tmp);
425
426                         if (length $file) {
427                                 push @{ $ci{'details'} }, {
428                                         'file'      => decode_git_file($file),
429                                         'sha1_from' => $sha1_from[0],
430                                         'sha1_to'   => $sha1_to,
431                                         'mode_from' => $mode_from[0],
432                                         'mode_to'   => $mode_to,
433                                         'status'    => $status,
434                                 };
435                         }
436                         next;
437                 };
438                 last;
439         }
440
441         return \%ci;
442 }
443
444 sub git_commit_info ($;$) {
445         # Return an array of commit info hashes of num commits
446         # starting from the given sha1sum.
447         my ($sha1, $num) = @_;
448
449         my @opts;
450         push @opts, "--max-count=$num" if defined $num;
451
452         my @raw_lines = run_or_die('git', 'log', @opts,
453                 '--pretty=raw', '--raw', '--abbrev=40', '--always', '-c',
454                 '-r', $sha1, '--', '.');
455
456         my @ci;
457         while (my $parsed = parse_diff_tree(\@raw_lines)) {
458                 push @ci, $parsed;
459         }
460
461         warn "Cannot parse commit info for '$sha1' commit" if !@ci;
462
463         return wantarray ? @ci : $ci[0];
464 }
465
466 sub git_sha1 (;$) {
467         # Return head sha1sum (of given file).
468         my $file = shift || q{--};
469
470         # Ignore error since a non-existing file might be given.
471         my ($sha1) = run_or_non('git', 'rev-list', '--max-count=1', 'HEAD',
472                 '--', $file);
473         if (defined $sha1) {
474                 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
475         }
476         return defined $sha1 ? $sha1 : '';
477 }
478
479 sub rcs_update () {
480         # Update working directory.
481
482         if (length $config{gitorigin_branch}) {
483                 run_or_cry('git', 'pull', '--prune', $config{gitorigin_branch});
484         }
485 }
486
487 sub rcs_prepedit ($) {
488         # Return the commit sha1sum of the file when editing begins.
489         # This will be later used in rcs_commit if a merge is required.
490         my ($file) = @_;
491
492         return git_sha1($file);
493 }
494
495 sub rcs_commit (@) {
496         # Try to commit the page; returns undef on _success_ and
497         # a version of the page with the rcs's conflict markers on
498         # failure.
499         my %params=@_;
500
501         # Check to see if the page has been changed by someone else since
502         # rcs_prepedit was called.
503         my $cur    = git_sha1($params{file});
504         my ($prev) = $params{token} =~ /^($sha1_pattern)$/; # untaint
505
506         if (defined $cur && defined $prev && $cur ne $prev) {
507                 my $conflict = merge_past($prev, $params{file}, $dummy_commit_msg);
508                 return $conflict if defined $conflict;
509         }
510
511         return rcs_commit_helper(@_);
512 }
513
514 sub rcs_commit_staged (@) {
515         # Commits all staged changes. Changes can be staged using rcs_add,
516         # rcs_remove, and rcs_rename.
517         return rcs_commit_helper(@_);
518 }
519
520 sub rcs_commit_helper (@) {
521         my %params=@_;
522         
523         my %env=%ENV;
524
525         if (defined $params{session}) {
526                 # Set the commit author and email based on web session info.
527                 my $u;
528                 if (defined $params{session}->param("name")) {
529                         $u=$params{session}->param("name");
530                 }
531                 elsif (defined $params{session}->remote_addr()) {
532                         $u=$params{session}->remote_addr();
533                 }
534                 if (defined $u) {
535                         $u=encode_utf8($u);
536                         # MITLOGIN This algorithm could be improved
537                         $ENV{GIT_AUTHOR_NAME}=IkiWiki::userinfo_get($u, "realname");
538                 }
539                 if (defined $params{session}->param("nickname")) {
540                         $u=encode_utf8($params{session}->param("nickname"));
541                         $u=~s/\s+/_/g;
542                         $u=~s/[^-_0-9[:alnum:]]+//g;
543                 }
544                 if (defined $u) {
545                         $ENV{GIT_AUTHOR_EMAIL}="$u\@mit.edu";
546                 }
547         }
548
549         $params{message} = IkiWiki::possibly_foolish_untaint($params{message});
550         my @opts;
551         if ($params{message} !~ /\S/) {
552                 # Force git to allow empty commit messages.
553                 # (If this version of git supports it.)
554                 my ($version)=`git --version` =~ /git version (.*)/;
555                 if ($version ge "1.5.4") {
556                         push @opts, '--cleanup=verbatim';
557                 }
558                 else {
559                         $params{message}.=".";
560                 }
561         }
562         if (exists $params{file}) {
563                 push @opts, '--', $params{file};
564         }
565         # git commit returns non-zero if nothing really changed.
566         # So we should ignore its exit status (hence run_or_non).
567         if (run_or_non('git', 'commit', '-m', $params{message}, '-q', @opts)) {
568                 if (length $config{gitorigin_branch}) {
569                         run_or_cry('git', 'push', $config{gitorigin_branch});
570                 }
571         }
572         
573         %ENV=%env;
574         return undef; # success
575 }
576
577 sub rcs_add ($) {
578         # Add file to archive.
579
580         my ($file) = @_;
581
582         run_or_cry('git', 'add', $file);
583 }
584
585 sub rcs_remove ($) {
586         # Remove file from archive.
587
588         my ($file) = @_;
589
590         run_or_cry('git', 'rm', '-f', $file);
591 }
592
593 sub rcs_rename ($$) {
594         my ($src, $dest) = @_;
595
596         run_or_cry('git', 'mv', '-f', $src, $dest);
597 }
598
599 sub rcs_recentchanges ($) {
600         # List of recent changes.
601
602         my ($num) = @_;
603
604         eval q{use Date::Parse};
605         error($@) if $@;
606
607         my @rets;
608         foreach my $ci (git_commit_info('HEAD', $num || 1)) {
609                 # Skip redundant commits.
610                 next if ($ci->{'comment'} && @{$ci->{'comment'}}[0] eq $dummy_commit_msg);
611
612                 my ($sha1, $when) = (
613                         $ci->{'sha1'},
614                         $ci->{'author_epoch'}
615                 );
616
617                 my @pages;
618                 foreach my $detail (@{ $ci->{'details'} }) {
619                         my $file = $detail->{'file'};
620                         my $efile = uri_escape_utf8($file);
621
622                         my $diffurl = defined $config{'diffurl'} ? $config{'diffurl'} : "";
623                         $diffurl =~ s/\[\[file\]\]/$efile/go;
624                         $diffurl =~ s/\[\[sha1_parent\]\]/$ci->{'parent'}/go;
625                         $diffurl =~ s/\[\[sha1_from\]\]/$detail->{'sha1_from'}/go;
626                         $diffurl =~ s/\[\[sha1_to\]\]/$detail->{'sha1_to'}/go;
627                         $diffurl =~ s/\[\[sha1_commit\]\]/$sha1/go;
628
629                         push @pages, {
630                                 page => pagename($file),
631                                 diffurl => $diffurl,
632                         };
633                 }
634
635                 my @messages;
636                 my $pastblank=0;
637                 foreach my $line (@{$ci->{'comment'}}) {
638                         $pastblank=1 if $line eq '';
639                         next if $pastblank && $line=~m/^ *(signed[ \-]off[ \-]by[ :]|acked[ \-]by[ :]|cc[ :])/i;
640                         push @messages, { line => $line };
641                 }
642
643                 my $user=$ci->{'author_username'};
644                 my $web_commit = ($ci->{'author'} =~ /\@web>/);
645                 my $nickname;
646
647                 # Set nickname only if a non-url author_username is available,
648                 # and author_name is an url.
649                 if ($user !~ /:\/\// && defined $ci->{'author_name'} &&
650                     $ci->{'author_name'} =~ /:\/\//) {
651                         $nickname=$user;
652                         $user=$ci->{'author_name'};
653                 }
654
655                 # compatability code for old web commit messages
656                 if (! $web_commit &&
657                       defined $messages[0] &&
658                       $messages[0]->{line} =~ m/$config{web_commit_regexp}/) {
659                         $user = defined $2 ? "$2" : "$3";
660                         $messages[0]->{line} = $4;
661                         $web_commit=1;
662                 }
663
664                 push @rets, {
665                         rev        => $sha1,
666                         user       => $user,
667                         nickname   => $nickname,
668                         committype => $web_commit ? "web" : "git",
669                         when       => $when,
670                         message    => [@messages],
671                         pages      => [@pages],
672                 } if @pages;
673
674                 last if @rets >= $num;
675         }
676
677         return @rets;
678 }
679
680 sub rcs_diff ($;$) {
681         my $rev=shift;
682         my $maxlines=shift;
683         my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
684         my @lines;
685         my $addlines=sub {
686                 my $line=shift;
687                 return if defined $maxlines && @lines == $maxlines;
688                 push @lines, $line."\n"
689                         if (@lines || $line=~/^diff --git/);
690                 return 1;
691         };
692         safe_git(undef, $addlines, "git", "show", $sha1);
693         if (wantarray) {
694                 return @lines;
695         }
696         else {
697                 return join("", @lines);
698         }
699 }
700
701 {
702 my %time_cache;
703
704 sub findtimes ($$) {
705         my $file=shift;
706         my $id=shift; # 0 = mtime ; 1 = ctime
707
708         if (! keys %time_cache) {
709                 my $date;
710                 foreach my $line (run_or_die('git', 'log',
711                                 '--pretty=format:%at',
712                                 '--name-only', '--relative')) {
713                         if (! defined $date && $line =~ /^(\d+)$/) {
714                                 $date=$line;
715                         }
716                         elsif (! length $line) {
717                                 $date=undef;
718                         }
719                         else {
720                                 my $f=decode_git_file($line);
721
722                                 if (! $time_cache{$f}) {
723                                         $time_cache{$f}[0]=$date; # mtime
724                                 }
725                                 $time_cache{$f}[1]=$date; # ctime
726                         }
727                 }
728         }
729
730         return exists $time_cache{$file} ? $time_cache{$file}[$id] : 0;
731 }
732
733 }
734
735 sub rcs_getctime ($) {
736         my $file=shift;
737
738         return findtimes($file, 1);
739 }
740
741 sub rcs_getmtime ($) {
742         my $file=shift;
743
744         return findtimes($file, 0);
745 }
746
747 {
748 my $ret;
749 sub git_find_root {
750         # The wiki may not be the only thing in the git repo.
751         # Determine if it is in a subdirectory by examining the srcdir,
752         # and its parents, looking for the .git directory.
753
754         return @$ret if defined $ret;
755         
756         my $subdir="";
757         my $dir=$config{srcdir};
758         while (! -d "$dir/.git") {
759                 $subdir=IkiWiki::basename($dir)."/".$subdir;
760                 $dir=IkiWiki::dirname($dir);
761                 if (! length $dir) {
762                         error("cannot determine root of git repo");
763                 }
764         }
765
766         $ret=[$subdir, $dir];
767         return @$ret;
768 }
769
770 }
771
772 sub git_parse_changes {
773         my $reverted = shift;
774         my @changes = @_;
775
776         my ($subdir, $rootdir) = git_find_root();
777         my @rets;
778         foreach my $ci (@changes) {
779                 foreach my $detail (@{ $ci->{'details'} }) {
780                         my $file = $detail->{'file'};
781
782                         # check that all changed files are in the subdir
783                         if (length $subdir &&
784                             ! ($file =~ s/^\Q$subdir\E//)) {
785                                 error sprintf(gettext("you are not allowed to change %s"), $file);
786                         }
787
788                         my ($action, $mode, $path);
789                         if ($detail->{'status'} =~ /^[M]+\d*$/) {
790                                 $action="change";
791                                 $mode=$detail->{'mode_to'};
792                         }
793                         elsif ($detail->{'status'} =~ /^[AM]+\d*$/) {
794                                 $action= $reverted ? "remove" : "add";
795                                 $mode=$detail->{'mode_to'};
796                         }
797                         elsif ($detail->{'status'} =~ /^[DAM]+\d*/) {
798                                 $action= $reverted ? "add" : "remove";
799                                 $mode=$detail->{'mode_from'};
800                         }
801                         else {
802                                 error "unknown status ".$detail->{'status'};
803                         }
804
805                         # test that the file mode is ok
806                         if ($mode !~ /^100[64][64][64]$/) {
807                                 error sprintf(gettext("you cannot act on a file with mode %s"), $mode);
808                         }
809                         if ($action eq "change") {
810                                 if ($detail->{'mode_from'} ne $detail->{'mode_to'}) {
811                                         error gettext("you are not allowed to change file modes");
812                                 }
813                         }
814
815                         # extract attachment to temp file
816                         if (($action eq 'add' || $action eq 'change') &&
817                             ! pagetype($file)) {
818                                 eval q{use File::Temp};
819                                 die $@ if $@;
820                                 my $fh;
821                                 ($fh, $path)=File::Temp::tempfile(undef, UNLINK => 1);
822                                 my $cmd = "cd $git_dir && ".
823                                           "git show $detail->{sha1_to} > '$path'";
824                                 if (system($cmd) != 0) {
825                                         error("failed writing temp file '$path'.");
826                                 }
827                         }
828
829                         push @rets, {
830                                 file => $file,
831                                 action => $action,
832                                 path => $path,
833                         };
834                 }
835         }
836
837         return @rets;
838 }
839
840 sub rcs_receive () {
841         my @rets;
842         while (<>) {
843                 chomp;
844                 my ($oldrev, $newrev, $refname) = split(' ', $_, 3);
845
846                 # only allow changes to gitmaster_branch
847                 if ($refname !~ /^refs\/heads\/\Q$config{gitmaster_branch}\E$/) {
848                         error sprintf(gettext("you are not allowed to change %s"), $refname);
849                 }
850
851                 # Avoid chdir when running git here, because the changes
852                 # are in the master git repo, not the srcdir repo.
853                 # (Also, if a subdir is involved, we don't want to chdir to
854                 # it and only see changes in it.)
855                 # The pre-receive hook already puts us in the right place.
856                 in_git_dir(".", sub {
857                         push @rets, git_parse_changes(0, git_commit_info($oldrev."..".$newrev));
858                 });
859         }
860
861         return reverse @rets;
862 }
863
864 sub rcs_preprevert ($) {
865         my $rev=shift;
866         my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
867
868         # Examine changes from root of git repo, not from any subdir,
869         # in order to see all changes.
870         my ($subdir, $rootdir) = git_find_root();
871         in_git_dir($rootdir, sub {
872                 my @commits=git_commit_info($sha1, 1);
873         
874                 if (! @commits) {
875                         error "unknown commit"; # just in case
876                 }
877
878                 # git revert will fail on merge commits. Add a nice message.
879                 if (exists $commits[0]->{parents} &&
880                     @{$commits[0]->{parents}} > 1) {
881                         error gettext("you are not allowed to revert a merge");
882                 }
883
884                 git_parse_changes(1, @commits);
885         });
886 }
887
888 sub rcs_revert ($) {
889         # Try to revert the given rev; returns undef on _success_.
890         my $rev = shift;
891         my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
892
893         if (run_or_non('git', 'revert', '--no-commit', $sha1)) {
894                 return undef;
895         }
896         else {
897                 run_or_die('git', 'reset', '--hard');
898                 return sprintf(gettext("Failed to revert commit %s"), $sha1);
899         }
900 }
901
902 1