]> sipb.mit.edu Git - ikiwiki.git/blob - IkiWiki/Plugin/git.pm
1facb14c0076f7c17eb5734727f8cfb9930cf02c
[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 Encode;
8 use open qw{:utf8 :std};
9
10 my $sha1_pattern     = qr/[0-9a-fA-F]{40}/; # pattern to validate Git sha1sums
11 my $dummy_commit_msg = 'dummy commit';      # message to skip in recent changes
12
13 sub import { #{{{
14         hook(type => "checkconfig", id => "git", call => \&checkconfig);
15         hook(type => "getsetup", id => "git", call => \&getsetup);
16         hook(type => "rcs", id => "rcs_update", call => \&rcs_update);
17         hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit);
18         hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit);
19         hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged);
20         hook(type => "rcs", id => "rcs_add", call => \&rcs_add);
21         hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove);
22         hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename);
23         hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges);
24         hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff);
25         hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime);
26         hook(type => "rcs", id => "rcs_test_receive", call => \&rcs_test_receive);
27 } #}}}
28
29 sub checkconfig () { #{{{
30         if (! defined $config{gitorigin_branch}) {
31                 $config{gitorigin_branch}="origin";
32         }
33         if (! defined $config{gitmaster_branch}) {
34                 $config{gitmaster_branch}="master";
35         }
36         if (defined $config{git_wrapper} &&
37             length $config{git_wrapper}) {
38                 push @{$config{wrappers}}, {
39                         wrapper => $config{git_wrapper},
40                         wrappermode => (defined $config{git_wrappermode} ? $config{git_wrappermode} : "06755"),
41                 };
42         }
43         if (defined $config{git_test_receive_wrapper} &&
44             length $config{git_test_receive_wrapper}) {
45                 push @{$config{wrappers}}, {
46                         test_receive => 1,
47                         wrapper => $config{git_test_receive_wrapper},
48                         wrappermode => "0755",
49                 };
50         }
51 } #}}}
52
53 sub getsetup () { #{{{
54         return
55                 plugin => {
56                         safe => 0, # rcs plugin
57                         rebuild => undef,
58                 },
59                 git_wrapper => {
60                         type => "string",
61                         example => "/git/wiki.git/hooks/post-update",
62                         description => "git hook to generate",
63                         safe => 0, # file
64                         rebuild => 0,
65                 },
66                 git_wrappermode => {
67                         type => "string",
68                         example => '06755',
69                         description => "mode for git_wrapper (can safely be made suid)",
70                         safe => 0,
71                         rebuild => 0,
72                 },
73                 git_test_receive_wrapper => {
74                         type => "string",
75                         example => "/git/wiki.git/hooks/pre-receive",
76                         description => "git pre-receive hook to generate",
77                         safe => 0, # file
78                         rebuild => 0,
79                 },
80                 git_untrusted_committers => {
81                         type => "string",
82                         example => [],
83                         description => "unix users whose commits should be checked by the pre-receive hook",
84                         safe => 0,
85                         rebuild => 0,
86                 },
87                 historyurl => {
88                         type => "string",
89                         example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=history;f=[[file]]",
90                         description => "gitweb url to show file history ([[file]] substituted)",
91                         safe => 1,
92                         rebuild => 1,
93                 },
94                 diffurl => {
95                         type => "string",
96                         example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=blobdiff;h=[[sha1_to]];hp=[[sha1_from]];hb=[[sha1_parent]];f=[[file]]",
97                         description => "gitweb url to show a diff ([[sha1_to]], [[sha1_from]], [[sha1_parent]], and [[file]] substituted)",
98                         safe => 1,
99                         rebuild => 1,
100                 },
101                 gitorigin_branch => {
102                         type => "string",
103                         example => "origin",
104                         description => "where to pull and push changes (set to empty string to disable)",
105                         safe => 0, # paranoia
106                         rebuild => 0,
107                 },
108                 gitmaster_branch => {
109                         type => "string",
110                         example => "master",
111                         description => "branch that the wiki is stored in",
112                         safe => 0, # paranoia
113                         rebuild => 0,
114                 },
115 } #}}}
116
117 sub safe_git (&@) { #{{{
118         # Start a child process safely without resorting /bin/sh.
119         # Return command output or success state (in scalar context).
120
121         my ($error_handler, @cmdline) = @_;
122
123         my $pid = open my $OUT, "-|";
124
125         error("Cannot fork: $!") if !defined $pid;
126
127         if (!$pid) {
128                 # In child.
129                 # Git commands want to be in wc.
130                 chdir $config{srcdir}
131                     or error("Cannot chdir to $config{srcdir}: $!");
132                 exec @cmdline or error("Cannot exec '@cmdline': $!");
133         }
134         # In parent.
135
136         my @lines;
137         while (<$OUT>) {
138                 chomp;
139                 push @lines, $_;
140         }
141
142         close $OUT;
143
144         $error_handler->("'@cmdline' failed: $!") if $? && $error_handler;
145
146         return wantarray ? @lines : ($? == 0);
147 }
148 # Convenient wrappers.
149 sub run_or_die ($@) { safe_git(\&error, @_) }
150 sub run_or_cry ($@) { safe_git(sub { warn @_ },  @_) }
151 sub run_or_non ($@) { safe_git(undef,            @_) }
152 #}}}
153
154 sub merge_past ($$$) { #{{{
155         # Unlike with Subversion, Git cannot make a 'svn merge -rN:M file'.
156         # Git merge commands work with the committed changes, except in the
157         # implicit case of '-m' of git checkout(1).  So we should invent a
158         # kludge here.  In principle, we need to create a throw-away branch
159         # in preparing for the merge itself.  Since branches are cheap (and
160         # branching is fast), this shouldn't cost high.
161         #
162         # The main problem is the presence of _uncommitted_ local changes.  One
163         # possible approach to get rid of this situation could be that we first
164         # make a temporary commit in the master branch and later restore the
165         # initial state (this is possible since Git has the ability to undo a
166         # commit, i.e. 'git reset --soft HEAD^').  The method can be summarized
167         # as follows:
168         #
169         #       - create a diff of HEAD:current-sha1
170         #       - dummy commit
171         #       - create a dummy branch and switch to it
172         #       - rewind to past (reset --hard to the current-sha1)
173         #       - apply the diff and commit
174         #       - switch to master and do the merge with the dummy branch
175         #       - make a soft reset (undo the last commit of master)
176         #
177         # The above method has some drawbacks: (1) it needs a redundant commit
178         # just to get rid of local changes, (2) somewhat slow because of the
179         # required system forks.  Until someone points a more straight method
180         # (which I would be grateful) I have implemented an alternative method.
181         # In this approach, we hide all the modified files from Git by renaming
182         # them (using the 'rename' builtin) and later restore those files in
183         # the throw-away branch (that is, we put the files themselves instead
184         # of applying a patch).
185
186         my ($sha1, $file, $message) = @_;
187
188         my @undo;      # undo stack for cleanup in case of an error
189         my $conflict;  # file content with conflict markers
190
191         eval {
192                 # Hide local changes from Git by renaming the modified file.
193                 # Relative paths must be converted to absolute for renaming.
194                 my ($target, $hidden) = (
195                     "$config{srcdir}/${file}", "$config{srcdir}/${file}.${sha1}"
196                 );
197                 rename($target, $hidden)
198                     or error("rename '$target' to '$hidden' failed: $!");
199                 # Ensure to restore the renamed file on error.
200                 push @undo, sub {
201                         return if ! -e "$hidden"; # already renamed
202                         rename($hidden, $target)
203                             or warn "rename '$hidden' to '$target' failed: $!";
204                 };
205
206                 my $branch = "throw_away_${sha1}"; # supposed to be unique
207
208                 # Create a throw-away branch and rewind backward.
209                 push @undo, sub { run_or_cry('git', 'branch', '-D', $branch) };
210                 run_or_die('git', 'branch', $branch, $sha1);
211
212                 # Switch to throw-away branch for the merge operation.
213                 push @undo, sub {
214                         if (!run_or_cry('git', 'checkout', $config{gitmaster_branch})) {
215                                 run_or_cry('git', 'checkout','-f',$config{gitmaster_branch});
216                         }
217                 };
218                 run_or_die('git', 'checkout', $branch);
219
220                 # Put the modified file in _this_ branch.
221                 rename($hidden, $target)
222                     or error("rename '$hidden' to '$target' failed: $!");
223
224                 # _Silently_ commit all modifications in the current branch.
225                 run_or_non('git', 'commit', '-m', $message, '-a');
226                 # ... and re-switch to master.
227                 run_or_die('git', 'checkout', $config{gitmaster_branch});
228
229                 # Attempt to merge without complaining.
230                 if (!run_or_non('git', 'pull', '--no-commit', '.', $branch)) {
231                         $conflict = readfile($target);
232                         run_or_die('git', 'reset', '--hard');
233                 }
234         };
235         my $failure = $@;
236
237         # Process undo stack (in reverse order).  By policy cleanup
238         # actions should normally print a warning on failure.
239         while (my $handle = pop @undo) {
240                 $handle->();
241         }
242
243         error("Git merge failed!\n$failure\n") if $failure;
244
245         return $conflict;
246 } #}}}
247
248 sub parse_diff_tree ($@) { #{{{
249         # Parse the raw diff tree chunk and return the info hash.
250         # See git-diff-tree(1) for the syntax.
251
252         my ($prefix, $dt_ref) = @_;
253
254         # End of stream?
255         return if !defined @{ $dt_ref } ||
256                   !defined @{ $dt_ref }[0] || !length @{ $dt_ref }[0];
257
258         my %ci;
259         # Header line.
260         while (my $line = shift @{ $dt_ref }) {
261                 return if $line !~ m/^(.+) ($sha1_pattern)/;
262
263                 my $sha1 = $2;
264                 $ci{'sha1'} = $sha1;
265                 last;
266         }
267
268         # Identification lines for the commit.
269         while (my $line = shift @{ $dt_ref }) {
270                 # Regexps are semi-stolen from gitweb.cgi.
271                 if ($line =~ m/^tree ([0-9a-fA-F]{40})$/) {
272                         $ci{'tree'} = $1;
273                 }
274                 elsif ($line =~ m/^parent ([0-9a-fA-F]{40})$/) {
275                         # XXX: collecting in reverse order
276                         push @{ $ci{'parents'} }, $1;
277                 }
278                 elsif ($line =~ m/^(author|committer) (.*) ([0-9]+) (.*)$/) {
279                         my ($who, $name, $epoch, $tz) =
280                            ($1,   $2,    $3,     $4 );
281
282                         $ci{  $who          } = $name;
283                         $ci{ "${who}_epoch" } = $epoch;
284                         $ci{ "${who}_tz"    } = $tz;
285
286                         if ($name =~ m/^[^<]+\s+<([^@>]+)/) {
287                                 $ci{"${who}_username"} = $1;
288                         }
289                         elsif ($name =~ m/^([^<]+)\s+<>$/) {
290                                 $ci{"${who}_username"} = $1;
291                         }
292                         else {
293                                 $ci{"${who}_username"} = $name;
294                         }
295                 }
296                 elsif ($line =~ m/^$/) {
297                         # Trailing empty line signals next section.
298                         last;
299                 }
300         }
301
302         debug("No 'tree' seen in diff-tree output") if !defined $ci{'tree'};
303         
304         if (defined $ci{'parents'}) {
305                 $ci{'parent'} = @{ $ci{'parents'} }[0];
306         }
307         else {
308                 $ci{'parent'} = 0 x 40;
309         }
310
311         # Commit message (optional).
312         while ($dt_ref->[0] =~ /^    /) {
313                 my $line = shift @{ $dt_ref };
314                 $line =~ s/^    //;
315                 push @{ $ci{'comment'} }, $line;
316         }
317         shift @{ $dt_ref } if $dt_ref->[0] =~ /^$/;
318
319         # Modified files.
320         while (my $line = shift @{ $dt_ref }) {
321                 if ($line =~ m{^
322                         (:+)       # number of parents
323                         ([^\t]+)\t # modes, sha1, status
324                         (.*)       # file names
325                 $}xo) {
326                         my $num_parents = length $1;
327                         my @tmp = split(" ", $2);
328                         my ($file, $file_to) = split("\t", $3);
329                         my @mode_from = splice(@tmp, 0, $num_parents);
330                         my $mode_to = shift(@tmp);
331                         my @sha1_from = splice(@tmp, 0, $num_parents);
332                         my $sha1_to = shift(@tmp);
333                         my $status = shift(@tmp);
334
335                         # git does not output utf-8 filenames, but instead
336                         # double-quotes them with the utf-8 characters
337                         # escaped as \nnn\nnn.
338                         if ($file =~ m/^"(.*)"$/) {
339                                 ($file=$1) =~ s/\\([0-7]{1,3})/chr(oct($1))/eg;
340                         }
341                         $file =~ s/^\Q$prefix\E//;
342                         if (length $file) {
343                                 push @{ $ci{'details'} }, {
344                                         'file'      => decode("utf8", $file),
345                                         'sha1_from' => $sha1_from[0],
346                                         'sha1_to'   => $sha1_to,
347                                         'mode_from' => $mode_from[0],
348                                         'mode_to'   => $mode_to,
349                                         'status'    => $status,
350                                 };
351                         }
352                         next;
353                 };
354                 last;
355         }
356
357         return \%ci;
358 } #}}}
359
360 sub git_commit_info ($;$) { #{{{
361         # Return an array of commit info hashes of num commits
362         # starting from the given sha1sum.
363         my ($sha1, $num) = @_;
364
365         my @raw_lines = run_or_die('git', 'log',
366                 (defined $num ? "--max-count=$num" : ""), 
367                 '--pretty=raw', '--raw', '--abbrev=40', '--always', '-c',
368                 '-r', $sha1, '--', '.');
369         my ($prefix) = run_or_die('git', 'rev-parse', '--show-prefix');
370
371         my @ci;
372         while (my $parsed = parse_diff_tree(($prefix or ""), \@raw_lines)) {
373                 push @ci, $parsed;
374         }
375
376         warn "Cannot parse commit info for '$sha1' commit" if !@ci;
377
378         return wantarray ? @ci : $ci[0];
379 } #}}}
380
381 sub git_sha1 (;$) { #{{{
382         # Return head sha1sum (of given file).
383         my $file = shift || q{--};
384
385         # Ignore error since a non-existing file might be given.
386         my ($sha1) = run_or_non('git', 'rev-list', '--max-count=1', 'HEAD',
387                 '--', $file);
388         if ($sha1) {
389                 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
390         } else { debug("Empty sha1sum for '$file'.") }
391         return defined $sha1 ? $sha1 : q{};
392 } #}}}
393
394 sub rcs_update () { #{{{
395         # Update working directory.
396
397         if (length $config{gitorigin_branch}) {
398                 run_or_cry('git', 'pull', $config{gitorigin_branch});
399         }
400 } #}}}
401
402 sub rcs_prepedit ($) { #{{{
403         # Return the commit sha1sum of the file when editing begins.
404         # This will be later used in rcs_commit if a merge is required.
405         my ($file) = @_;
406
407         return git_sha1($file);
408 } #}}}
409
410 sub rcs_commit ($$$;$$) { #{{{
411         # Try to commit the page; returns undef on _success_ and
412         # a version of the page with the rcs's conflict markers on
413         # failure.
414
415         my ($file, $message, $rcstoken, $user, $ipaddr) = @_;
416
417         # Check to see if the page has been changed by someone else since
418         # rcs_prepedit was called.
419         my $cur    = git_sha1($file);
420         my ($prev) = $rcstoken =~ /^($sha1_pattern)$/; # untaint
421
422         if (defined $cur && defined $prev && $cur ne $prev) {
423                 my $conflict = merge_past($prev, $file, $dummy_commit_msg);
424                 return $conflict if defined $conflict;
425         }
426
427         rcs_add($file); 
428         return rcs_commit_staged($message, $user, $ipaddr);
429 } #}}}
430
431 sub rcs_commit_staged ($$$) {
432         # Commits all staged changes. Changes can be staged using rcs_add,
433         # rcs_remove, and rcs_rename.
434         my ($message, $user, $ipaddr)=@_;
435
436         # Set the commit author and email to the web committer.
437         my %env=%ENV;
438         if (defined $user || defined $ipaddr) {
439                 my $u=defined $user ? $user : $ipaddr;
440                 $ENV{GIT_AUTHOR_NAME}=$u;
441                 $ENV{GIT_AUTHOR_EMAIL}="$u\@web";
442         }
443
444         $message = IkiWiki::possibly_foolish_untaint($message);
445         my @opts;
446         if ($message !~ /\S/) {
447                 # Force git to allow empty commit messages.
448                 # (If this version of git supports it.)
449                 my ($version)=`git --version` =~ /git version (.*)/;
450                 if ($version ge "1.5.4") {
451                         push @opts, '--cleanup=verbatim';
452                 }
453                 else {
454                         $message.=".";
455                 }
456         }
457         push @opts, '-q';
458         # git commit returns non-zero if file has not been really changed.
459         # so we should ignore its exit status (hence run_or_non).
460         if (run_or_non('git', 'commit', @opts, '-m', $message)) {
461                 if (length $config{gitorigin_branch}) {
462                         run_or_cry('git', 'push', $config{gitorigin_branch});
463                 }
464         }
465         
466         %ENV=%env;
467         return undef; # success
468 }
469
470 sub rcs_add ($) { # {{{
471         # Add file to archive.
472
473         my ($file) = @_;
474
475         run_or_cry('git', 'add', $file);
476 } #}}}
477
478 sub rcs_remove ($) { # {{{
479         # Remove file from archive.
480
481         my ($file) = @_;
482
483         run_or_cry('git', 'rm', '-f', $file);
484 } #}}}
485
486 sub rcs_rename ($$) { # {{{
487         my ($src, $dest) = @_;
488
489         run_or_cry('git', 'mv', '-f', $src, $dest);
490 } #}}}
491
492 sub rcs_recentchanges ($) { #{{{
493         # List of recent changes.
494
495         my ($num) = @_;
496
497         eval q{use Date::Parse};
498         error($@) if $@;
499
500         my @rets;
501         foreach my $ci (git_commit_info('HEAD', $num || 1)) {
502                 # Skip redundant commits.
503                 next if ($ci->{'comment'} && @{$ci->{'comment'}}[0] eq $dummy_commit_msg);
504
505                 my ($sha1, $when) = (
506                         $ci->{'sha1'},
507                         $ci->{'author_epoch'}
508                 );
509
510                 my @pages;
511                 foreach my $detail (@{ $ci->{'details'} }) {
512                         my $file = $detail->{'file'};
513
514                         my $diffurl = defined $config{'diffurl'} ? $config{'diffurl'} : "";
515                         $diffurl =~ s/\[\[file\]\]/$file/go;
516                         $diffurl =~ s/\[\[sha1_parent\]\]/$ci->{'parent'}/go;
517                         $diffurl =~ s/\[\[sha1_from\]\]/$detail->{'sha1_from'}/go;
518                         $diffurl =~ s/\[\[sha1_to\]\]/$detail->{'sha1_to'}/go;
519
520                         push @pages, {
521                                 page => pagename($file),
522                                 diffurl => $diffurl,
523                         };
524                 }
525
526                 my @messages;
527                 my $pastblank=0;
528                 foreach my $line (@{$ci->{'comment'}}) {
529                         $pastblank=1 if $line eq '';
530                         next if $pastblank && $line=~m/^ *(signed[ \-]off[ \-]by[ :]|acked[ \-]by[ :]|cc[ :])/i;
531                         push @messages, { line => $line };
532                 }
533
534                 my $user=$ci->{'author_username'};
535                 my $web_commit = ($ci->{'author'} =~ /\@web>/);
536                 
537                 # compatability code for old web commit messages
538                 if (! $web_commit &&
539                       defined $messages[0] &&
540                       $messages[0]->{line} =~ m/$config{web_commit_regexp}/) {
541                         $user = defined $2 ? "$2" : "$3";
542                         $messages[0]->{line} = $4;
543                         $web_commit=1;
544                 }
545
546                 push @rets, {
547                         rev        => $sha1,
548                         user       => $user,
549                         committype => $web_commit ? "web" : "git",
550                         when       => $when,
551                         message    => [@messages],
552                         pages      => [@pages],
553                 } if @pages;
554
555                 last if @rets >= $num;
556         }
557
558         return @rets;
559 } #}}}
560
561 sub rcs_diff ($) { #{{{
562         my $rev=shift;
563         my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
564         my @lines;
565         foreach my $line (run_or_non("git", "show", $sha1)) {
566                 if (@lines || $line=~/^diff --git/) {
567                         push @lines, $line."\n";
568                 }
569         }
570         if (wantarray) {
571                 return @lines;
572         }
573         else {
574                 return join("", @lines);
575         }
576 } #}}}
577
578 sub rcs_getctime ($) { #{{{
579         my $file=shift;
580         # Remove srcdir prefix
581         $file =~ s/^\Q$config{srcdir}\E\/?//;
582
583         my $sha1  = git_sha1($file);
584         my $ci    = git_commit_info($sha1, 1);
585         my $ctime = $ci->{'author_epoch'};
586         debug("ctime for '$file': ". localtime($ctime));
587
588         return $ctime;
589 } #}}}
590
591 sub rcs_test_receive () { #{{{
592         # quick success if the user is trusted
593         my $committer=(getpwuid($<))[0];
594         if (! defined $committer) {
595                 error("cannot determine username for $<");
596         }
597         exit 0 if ! ref $config{git_untrusted_committers} ||
598                   ! grep { $_ eq $committer } @{$config{git_untrusted_committers}};
599
600         # The wiki may not be the only thing in the git repo.
601         # Determine if it is in a subdirectory by examining the srcdir,
602         # and its parents, looking for the .git directory.
603         my $subdir="";
604         my $dir=$config{srcdir};
605         while (! -d "$dir/.git") {
606                 $subdir=IkiWiki::basename($dir)."/".$subdir;
607                 $dir=IkiWiki::dirname($dir);
608                 if (! length $dir) {
609                         error("cannot determine root of git repo");
610                 }
611         }
612
613         my @errors;
614         while (<>) {
615                 chomp;
616                 my ($oldrev, $newrev, $refname) = split(' ', $_, 3);
617
618                 # only allow changes to gitmaster_branch
619                 if ($refname !~ /^refs\/heads\/\Q$config{gitmaster_branch}\E$/) {
620                         push @errors, sprintf(gettext("you are not allowed to change %s"), $refname);
621                 }
622
623                 foreach my $ci (git_commit_info($oldrev."..".$newrev)) {
624                         foreach my $detail (@{ $ci->{'details'} }) {
625                                 my $file = $detail->{'file'};
626
627                                 # check that all changed files are in the subdir
628                                 if (length $subdir &&
629                                     ! ($file =~ s/^\Q$subdir\E//)) {
630                                         push @errors, sprintf(gettext("you are not allowed to change %s"), $file);
631                                         next;
632                                 }
633
634                                 if ($detail->{'mode_from'} ne $detail->{'mode_to'}) {
635                                         push @errors, gettext("you are not allowed to change file modes");
636                                 }
637
638                                 if ($detail->{'status'} =~ /^D+\d*/) {
639                                         # TODO check_canremove
640                                 }
641                                 elsif ($detail->{'status'} !~ /^[MA]+\d*$/) {
642                                         push @errors, "unknown status ".$detail->{'status'};
643                                 }
644                                 else {
645                                         # TODO check_canedit
646                                         # TODO check_canattach
647                                 }
648                         }
649                 }
650         }
651
652         if (@errors) {
653                 # TODO clean up objects from failed push
654
655                 print STDERR "$_\n" foreach @errors;
656                 exit 1;
657         }
658         else {
659                 exit 0;
660         }
661 } #}}}
662
663 1