2 package IkiWiki::Plugin::git;
8 use open qw{:utf8 :std};
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
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);
29 sub checkconfig () { #{{{
30 if (! defined $config{gitorigin_branch}) {
31 $config{gitorigin_branch}="origin";
33 if (! defined $config{gitmaster_branch}) {
34 $config{gitmaster_branch}="master";
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"),
43 if (defined $config{git_test_receive_wrapper} &&
44 length $config{git_test_receive_wrapper}) {
45 push @{$config{wrappers}}, {
47 wrapper => $config{git_test_receive_wrapper},
48 wrappermode => "0755",
53 sub getsetup () { #{{{
56 safe => 0, # rcs plugin
61 example => "/git/wiki.git/hooks/post-update",
62 description => "git hook to generate",
69 description => "mode for git_wrapper (can safely be made suid)",
73 git_test_receive_wrapper => {
75 example => "/git/wiki.git/hooks/pre-receive",
76 description => "git pre-receive hook to generate",
80 git_untrusted_committers => {
83 description => "unix users whose commits should be checked by the pre-receive hook",
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)",
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)",
101 gitorigin_branch => {
104 description => "where to pull and push changes (set to empty string to disable)",
105 safe => 0, # paranoia
108 gitmaster_branch => {
111 description => "branch that the wiki is stored in",
112 safe => 0, # paranoia
117 sub safe_git (&@) { #{{{
118 # Start a child process safely without resorting /bin/sh.
119 # Return command output or success state (in scalar context).
121 my ($error_handler, @cmdline) = @_;
123 my $pid = open my $OUT, "-|";
125 error("Cannot fork: $!") if !defined $pid;
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': $!");
144 $error_handler->("'@cmdline' failed: $!") if $? && $error_handler;
146 return wantarray ? @lines : ($? == 0);
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, @_) }
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.
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
169 # - create a diff of HEAD:current-sha1
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)
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).
186 my ($sha1, $file, $message) = @_;
188 my @undo; # undo stack for cleanup in case of an error
189 my $conflict; # file content with conflict markers
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}"
197 rename($target, $hidden)
198 or error("rename '$target' to '$hidden' failed: $!");
199 # Ensure to restore the renamed file on error.
201 return if ! -e "$hidden"; # already renamed
202 rename($hidden, $target)
203 or warn "rename '$hidden' to '$target' failed: $!";
206 my $branch = "throw_away_${sha1}"; # supposed to be unique
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);
212 # Switch to throw-away branch for the merge operation.
214 if (!run_or_cry('git', 'checkout', $config{gitmaster_branch})) {
215 run_or_cry('git', 'checkout','-f',$config{gitmaster_branch});
218 run_or_die('git', 'checkout', $branch);
220 # Put the modified file in _this_ branch.
221 rename($hidden, $target)
222 or error("rename '$hidden' to '$target' failed: $!");
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});
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');
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) {
243 error("Git merge failed!\n$failure\n") if $failure;
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.
252 my ($prefix, $dt_ref) = @_;
255 return if !defined @{ $dt_ref } ||
256 !defined @{ $dt_ref }[0] || !length @{ $dt_ref }[0];
260 while (my $line = shift @{ $dt_ref }) {
261 return if $line !~ m/^(.+) ($sha1_pattern)/;
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})$/) {
274 elsif ($line =~ m/^parent ([0-9a-fA-F]{40})$/) {
275 # XXX: collecting in reverse order
276 push @{ $ci{'parents'} }, $1;
278 elsif ($line =~ m/^(author|committer) (.*) ([0-9]+) (.*)$/) {
279 my ($who, $name, $epoch, $tz) =
283 $ci{ "${who}_epoch" } = $epoch;
284 $ci{ "${who}_tz" } = $tz;
286 if ($name =~ m/^[^<]+\s+<([^@>]+)/) {
287 $ci{"${who}_username"} = $1;
289 elsif ($name =~ m/^([^<]+)\s+<>$/) {
290 $ci{"${who}_username"} = $1;
293 $ci{"${who}_username"} = $name;
296 elsif ($line =~ m/^$/) {
297 # Trailing empty line signals next section.
302 debug("No 'tree' seen in diff-tree output") if !defined $ci{'tree'};
304 if (defined $ci{'parents'}) {
305 $ci{'parent'} = @{ $ci{'parents'} }[0];
308 $ci{'parent'} = 0 x 40;
311 # Commit message (optional).
312 while ($dt_ref->[0] =~ /^ /) {
313 my $line = shift @{ $dt_ref };
315 push @{ $ci{'comment'} }, $line;
317 shift @{ $dt_ref } if $dt_ref->[0] =~ /^$/;
320 while (my $line = shift @{ $dt_ref }) {
322 (:+) # number of parents
323 ([^\t]+)\t # modes, sha1, status
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);
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;
341 $file =~ s/^\Q$prefix\E//;
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,
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) = @_;
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');
372 while (my $parsed = parse_diff_tree(($prefix or ""), \@raw_lines)) {
376 warn "Cannot parse commit info for '$sha1' commit" if !@ci;
378 return wantarray ? @ci : $ci[0];
381 sub git_sha1 (;$) { #{{{
382 # Return head sha1sum (of given file).
383 my $file = shift || q{--};
385 # Ignore error since a non-existing file might be given.
386 my ($sha1) = run_or_non('git', 'rev-list', '--max-count=1', 'HEAD',
389 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
390 } else { debug("Empty sha1sum for '$file'.") }
391 return defined $sha1 ? $sha1 : q{};
394 sub rcs_update () { #{{{
395 # Update working directory.
397 if (length $config{gitorigin_branch}) {
398 run_or_cry('git', 'pull', $config{gitorigin_branch});
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.
407 return git_sha1($file);
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
415 my ($file, $message, $rcstoken, $user, $ipaddr) = @_;
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
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;
428 return rcs_commit_staged($message, $user, $ipaddr);
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)=@_;
436 # Set the commit author and email to the web committer.
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";
444 $message = IkiWiki::possibly_foolish_untaint($message);
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';
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});
467 return undef; # success
470 sub rcs_add ($) { # {{{
471 # Add file to archive.
475 run_or_cry('git', 'add', $file);
478 sub rcs_remove ($) { # {{{
479 # Remove file from archive.
483 run_or_cry('git', 'rm', '-f', $file);
486 sub rcs_rename ($$) { # {{{
487 my ($src, $dest) = @_;
489 run_or_cry('git', 'mv', '-f', $src, $dest);
492 sub rcs_recentchanges ($) { #{{{
493 # List of recent changes.
497 eval q{use Date::Parse};
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);
505 my ($sha1, $when) = (
507 $ci->{'author_epoch'}
511 foreach my $detail (@{ $ci->{'details'} }) {
512 my $file = $detail->{'file'};
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;
521 page => pagename($file),
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 };
534 my $user=$ci->{'author_username'};
535 my $web_commit = ($ci->{'author'} =~ /\@web>/);
537 # compatability code for old web commit messages
539 defined $messages[0] &&
540 $messages[0]->{line} =~ m/$config{web_commit_regexp}/) {
541 $user = defined $2 ? "$2" : "$3";
542 $messages[0]->{line} = $4;
549 committype => $web_commit ? "web" : "git",
551 message => [@messages],
555 last if @rets >= $num;
561 sub rcs_diff ($) { #{{{
563 my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
565 foreach my $line (run_or_non("git", "show", $sha1)) {
566 if (@lines || $line=~/^diff --git/) {
567 push @lines, $line."\n";
574 return join("", @lines);
578 sub rcs_getctime ($) { #{{{
580 # Remove srcdir prefix
581 $file =~ s/^\Q$config{srcdir}\E\/?//;
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));
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 $<");
597 exit 0 if ! ref $config{git_untrusted_committers} ||
598 ! grep { $_ eq $committer } @{$config{git_untrusted_committers}};
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.
604 my $dir=$config{srcdir};
605 while (! -d "$dir/.git") {
606 $subdir=IkiWiki::basename($dir)."/".$subdir;
607 $dir=IkiWiki::dirname($dir);
609 error("cannot determine root of git repo");
616 my ($oldrev, $newrev, $refname) = split(' ', $_, 3);
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);
623 foreach my $ci (git_commit_info($oldrev."..".$newrev)) {
624 foreach my $detail (@{ $ci->{'details'} }) {
625 my $file = $detail->{'file'};
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);
634 if ($detail->{'mode_from'} ne $detail->{'mode_to'}) {
635 push @errors, gettext("you are not allowed to change file modes");
638 if ($detail->{'status'} =~ /^D+\d*/) {
639 # TODO check_canremove
641 elsif ($detail->{'status'} !~ /^[MA]+\d*$/) {
642 push @errors, "unknown status ".$detail->{'status'};
646 # TODO check_canattach
653 # TODO clean up objects from failed push
655 print STDERR "$_\n" foreach @errors;