From: Joey Hess Date: Thu, 31 Jul 2008 23:35:37 +0000 (-0400) Subject: Merge branch 'master' into autoconfig X-Git-Tag: 2.60~142 X-Git-Url: http://git.vanrenterghem.biz/git.ikiwiki.info.git/commitdiff_plain/041923a89ece8b1ed195cb7b528843c15770ea6f Merge branch 'master' into autoconfig Conflicts: IkiWiki/Plugin/git.pm debian/changelog po/ikiwiki.pot --- 041923a89ece8b1ed195cb7b528843c15770ea6f diff --cc IkiWiki/Plugin/git.pm index 6f0ac56d5,000000000..b683e4ec3 mode 100644,000000..100644 --- a/IkiWiki/Plugin/git.pm +++ b/IkiWiki/Plugin/git.pm @@@ -1,552 -1,0 +1,564 @@@ +#!/usr/bin/perl +package IkiWiki::Plugin::git; + +use warnings; +use strict; +use IkiWiki; +use Encode; +use open qw{:utf8 :std}; + +my $sha1_pattern = qr/[0-9a-fA-F]{40}/; # pattern to validate Git sha1sums +my $dummy_commit_msg = 'dummy commit'; # message to skip in recent changes + +sub import { #{{{ + if (exists $IkiWiki::hooks{rcs}) { + error(gettext("cannot use multiple rcs plugins")); + } + hook(type => "checkconfig", id => "git", call => \&checkconfig); + hook(type => "getsetup", id => "git", call => \&getsetup); + hook(type => "rcs", id => "rcs_update", call => \&rcs_update); + hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit); + hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit); + hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged); + hook(type => "rcs", id => "rcs_add", call => \&rcs_add); + hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove); + hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename); + hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges); + hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff); + hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime); +} #}}} + +sub checkconfig () { #{{{ + if (! defined $config{gitorigin_branch}) { + $config{gitorigin_branch}="origin"; + } + if (! defined $config{gitmaster_branch}) { + $config{gitmaster_branch}="master"; + } + if (defined $config{git_wrapper} && length $config{git_wrapper}) { + push @{$config{wrappers}}, { + wrapper => $config{git_wrapper}, + wrappermode => (defined $config{git_wrappermode} ? $config{git_wrappermode} : "06755"), + }; + } +} #}}} + +sub getsetup () { #{{{ + return + git_wrapper => { + type => "string", + example => "/git/wiki.git/hooks/post-update", + description => "git post-update executable to generate", + safe => 0, # file + rebuild => 0, + }, + git_wrappermode => { + type => "string", + example => '06755', + description => "mode for git_wrapper (can safely be made suid)", + safe => 0, + rebuild => 0, + }, + historyurl => { + type => "string", + example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=history;f=[[file]]", + description => "gitweb url to show file history ([[file]] substituted)", + safe => 1, + rebuild => 1, + }, + diffurl => { + type => "string", + example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=blobdiff;h=[[sha1_to]];hp=[[sha1_from]];hb=[[sha1_parent]];f=[[file]]", + description => "gitweb url to show a diff ([[sha1_to]], [[sha1_from]], [[sha1_parent]], and [[file]] substituted)", + safe => 1, + rebuild => 1, + }, + gitorigin_branch => { + type => "string", + example => "origin", + description => "where to pull and push changes (set to empty string to disable)", + safe => 0, # paranoia + rebuild => 0, + }, + gitmaster_branch => { + type => "string", + example => "master", + description => "branch that the wiki is stored in", + safe => 0, # paranoia + rebuild => 0, + }, +} #}}} + +sub safe_git (&@) { #{{{ + # Start a child process safely without resorting /bin/sh. + # Return command output or success state (in scalar context). + + my ($error_handler, @cmdline) = @_; + + my $pid = open my $OUT, "-|"; + + error("Cannot fork: $!") if !defined $pid; + + if (!$pid) { + # In child. + # Git commands want to be in wc. + chdir $config{srcdir} + or error("Cannot chdir to $config{srcdir}: $!"); + exec @cmdline or error("Cannot exec '@cmdline': $!"); + } + # In parent. + + my @lines; + while (<$OUT>) { + chomp; + push @lines, $_; + } + + close $OUT; + + $error_handler->("'@cmdline' failed: $!") if $? && $error_handler; + + return wantarray ? @lines : ($? == 0); +} +# Convenient wrappers. +sub run_or_die ($@) { safe_git(\&error, @_) } +sub run_or_cry ($@) { safe_git(sub { warn @_ }, @_) } +sub run_or_non ($@) { safe_git(undef, @_) } +#}}} + +sub merge_past ($$$) { #{{{ + # Unlike with Subversion, Git cannot make a 'svn merge -rN:M file'. + # Git merge commands work with the committed changes, except in the + # implicit case of '-m' of git checkout(1). So we should invent a + # kludge here. In principle, we need to create a throw-away branch + # in preparing for the merge itself. Since branches are cheap (and + # branching is fast), this shouldn't cost high. + # + # The main problem is the presence of _uncommitted_ local changes. One + # possible approach to get rid of this situation could be that we first + # make a temporary commit in the master branch and later restore the + # initial state (this is possible since Git has the ability to undo a + # commit, i.e. 'git reset --soft HEAD^'). The method can be summarized + # as follows: + # + # - create a diff of HEAD:current-sha1 + # - dummy commit + # - create a dummy branch and switch to it + # - rewind to past (reset --hard to the current-sha1) + # - apply the diff and commit + # - switch to master and do the merge with the dummy branch + # - make a soft reset (undo the last commit of master) + # + # The above method has some drawbacks: (1) it needs a redundant commit + # just to get rid of local changes, (2) somewhat slow because of the + # required system forks. Until someone points a more straight method + # (which I would be grateful) I have implemented an alternative method. + # In this approach, we hide all the modified files from Git by renaming + # them (using the 'rename' builtin) and later restore those files in + # the throw-away branch (that is, we put the files themselves instead + # of applying a patch). + + my ($sha1, $file, $message) = @_; + + my @undo; # undo stack for cleanup in case of an error + my $conflict; # file content with conflict markers + + eval { + # Hide local changes from Git by renaming the modified file. + # Relative paths must be converted to absolute for renaming. + my ($target, $hidden) = ( + "$config{srcdir}/${file}", "$config{srcdir}/${file}.${sha1}" + ); + rename($target, $hidden) + or error("rename '$target' to '$hidden' failed: $!"); + # Ensure to restore the renamed file on error. + push @undo, sub { + return if ! -e "$hidden"; # already renamed + rename($hidden, $target) + or warn "rename '$hidden' to '$target' failed: $!"; + }; + + my $branch = "throw_away_${sha1}"; # supposed to be unique + + # Create a throw-away branch and rewind backward. + push @undo, sub { run_or_cry('git', 'branch', '-D', $branch) }; + run_or_die('git', 'branch', $branch, $sha1); + + # Switch to throw-away branch for the merge operation. + push @undo, sub { + if (!run_or_cry('git', 'checkout', $config{gitmaster_branch})) { + run_or_cry('git', 'checkout','-f',$config{gitmaster_branch}); + } + }; + run_or_die('git', 'checkout', $branch); + + # Put the modified file in _this_ branch. + rename($hidden, $target) + or error("rename '$hidden' to '$target' failed: $!"); + + # _Silently_ commit all modifications in the current branch. + run_or_non('git', 'commit', '-m', $message, '-a'); + # ... and re-switch to master. + run_or_die('git', 'checkout', $config{gitmaster_branch}); + + # Attempt to merge without complaining. + if (!run_or_non('git', 'pull', '--no-commit', '.', $branch)) { + $conflict = readfile($target); + run_or_die('git', 'reset', '--hard'); + } + }; + my $failure = $@; + + # Process undo stack (in reverse order). By policy cleanup + # actions should normally print a warning on failure. + while (my $handle = pop @undo) { + $handle->(); + } + + error("Git merge failed!\n$failure\n") if $failure; + + return $conflict; +} #}}} + +sub parse_diff_tree ($@) { #{{{ + # Parse the raw diff tree chunk and return the info hash. + # See git-diff-tree(1) for the syntax. + + my ($prefix, $dt_ref) = @_; + + # End of stream? + return if !defined @{ $dt_ref } || + !defined @{ $dt_ref }[0] || !length @{ $dt_ref }[0]; + + my %ci; + # Header line. + while (my $line = shift @{ $dt_ref }) { + return if $line !~ m/^(.+) ($sha1_pattern)/; + + my $sha1 = $2; + $ci{'sha1'} = $sha1; + last; + } + + # Identification lines for the commit. + while (my $line = shift @{ $dt_ref }) { + # Regexps are semi-stolen from gitweb.cgi. + if ($line =~ m/^tree ([0-9a-fA-F]{40})$/) { + $ci{'tree'} = $1; + } + elsif ($line =~ m/^parent ([0-9a-fA-F]{40})$/) { + # XXX: collecting in reverse order + push @{ $ci{'parents'} }, $1; + } + elsif ($line =~ m/^(author|committer) (.*) ([0-9]+) (.*)$/) { + my ($who, $name, $epoch, $tz) = + ($1, $2, $3, $4 ); + + $ci{ $who } = $name; + $ci{ "${who}_epoch" } = $epoch; + $ci{ "${who}_tz" } = $tz; + + if ($name =~ m/^[^<]+\s+<([^@>]+)/) { + $ci{"${who}_username"} = $1; + } + elsif ($name =~ m/^([^<]+)\s+<>$/) { + $ci{"${who}_username"} = $1; + } + else { + $ci{"${who}_username"} = $name; + } + } + elsif ($line =~ m/^$/) { + # Trailing empty line signals next section. + last; + } + } + + debug("No 'tree' seen in diff-tree output") if !defined $ci{'tree'}; + + if (defined $ci{'parents'}) { + $ci{'parent'} = @{ $ci{'parents'} }[0]; + } + else { + $ci{'parent'} = 0 x 40; + } + + # Commit message (optional). + while ($dt_ref->[0] =~ /^ /) { + my $line = shift @{ $dt_ref }; + $line =~ s/^ //; + push @{ $ci{'comment'} }, $line; + } + shift @{ $dt_ref } if $dt_ref->[0] =~ /^$/; + + # Modified files. + while (my $line = shift @{ $dt_ref }) { + if ($line =~ m{^ + (:+) # number of parents + ([^\t]+)\t # modes, sha1, status + (.*) # file names + $}xo) { + my $num_parents = length $1; + my @tmp = split(" ", $2); + my ($file, $file_to) = split("\t", $3); + my @mode_from = splice(@tmp, 0, $num_parents); + my $mode_to = shift(@tmp); + my @sha1_from = splice(@tmp, 0, $num_parents); + my $sha1_to = shift(@tmp); + my $status = shift(@tmp); + + if ($file =~ m/^"(.*)"$/) { + ($file=$1) =~ s/\\([0-7]{1,3})/chr(oct($1))/eg; + } + $file =~ s/^\Q$prefix\E//; + if (length $file) { + push @{ $ci{'details'} }, { + 'file' => decode_utf8($file), + 'sha1_from' => $sha1_from[0], + 'sha1_to' => $sha1_to, + }; + } + next; + }; + last; + } + + return \%ci; +} #}}} + +sub git_commit_info ($;$) { #{{{ + # Return an array of commit info hashes of num commits (default: 1) + # starting from the given sha1sum. + + my ($sha1, $num) = @_; + + $num ||= 1; + + my @raw_lines = run_or_die('git', 'log', "--max-count=$num", + '--pretty=raw', '--raw', '--abbrev=40', '--always', '-c', + '-r', $sha1, '--', '.'); + my ($prefix) = run_or_die('git', 'rev-parse', '--show-prefix'); + + my @ci; + while (my $parsed = parse_diff_tree(($prefix or ""), \@raw_lines)) { + push @ci, $parsed; + } + + warn "Cannot parse commit info for '$sha1' commit" if !@ci; + + return wantarray ? @ci : $ci[0]; +} #}}} + +sub git_sha1 (;$) { #{{{ + # Return head sha1sum (of given file). + + my $file = shift || q{--}; + + # Ignore error since a non-existing file might be given. + my ($sha1) = run_or_non('git', 'rev-list', '--max-count=1', 'HEAD', + '--', $file); + if ($sha1) { + ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now + } else { debug("Empty sha1sum for '$file'.") } + return defined $sha1 ? $sha1 : q{}; +} #}}} + +sub rcs_update () { #{{{ + # Update working directory. + + if (length $config{gitorigin_branch}) { + run_or_cry('git', 'pull', $config{gitorigin_branch}); + } +} #}}} + +sub rcs_prepedit ($) { #{{{ + # Return the commit sha1sum of the file when editing begins. + # This will be later used in rcs_commit if a merge is required. + + my ($file) = @_; + + return git_sha1($file); +} #}}} + +sub rcs_commit ($$$;$$) { #{{{ + # Try to commit the page; returns undef on _success_ and + # a version of the page with the rcs's conflict markers on + # failure. + + my ($file, $message, $rcstoken, $user, $ipaddr) = @_; + + # Check to see if the page has been changed by someone else since + # rcs_prepedit was called. + my $cur = git_sha1($file); + my ($prev) = $rcstoken =~ /^($sha1_pattern)$/; # untaint + + if (defined $cur && defined $prev && $cur ne $prev) { + my $conflict = merge_past($prev, $file, $dummy_commit_msg); + return $conflict if defined $conflict; + } + + rcs_add($file); + return rcs_commit_staged($message, $user, $ipaddr); +} #}}} + +sub rcs_commit_staged ($$$) { + # Commits all staged changes. Changes can be staged using rcs_add, + # rcs_remove, and rcs_rename. + my ($message, $user, $ipaddr)=@_; + + # Set the commit author and email to the web committer. + my %env=%ENV; + if (defined $user || defined $ipaddr) { + my $u=defined $user ? $user : $ipaddr; + $ENV{GIT_AUTHOR_NAME}=$u; + $ENV{GIT_AUTHOR_EMAIL}="$u\@web"; + } + ++ $message = IkiWiki::possibly_foolish_untaint($message); ++ my @opts; ++ if ($message !~ /\S/) { ++ # Force git to allow empty commit messages. ++ # (If this version of git supports it.) ++ my ($version)=`git --version` =~ /git version (.*)/; ++ if ($version ge "1.5.4") { ++ push @opts, '--cleanup=verbatim'; ++ } ++ else { ++ $message.="."; ++ } ++ } ++ push @opts, '-q'; + # git commit returns non-zero if file has not been really changed. + # so we should ignore its exit status (hence run_or_non). - $message = IkiWiki::possibly_foolish_untaint($message); - if (run_or_non('git', 'commit', '--cleanup=verbatim', - '-q', '-m', $message)) { ++ if (run_or_non('git', 'commit', @opts, '-m', $message)) { + if (length $config{gitorigin_branch}) { + run_or_cry('git', 'push', $config{gitorigin_branch}); + } + } + + %ENV=%env; + return undef; # success +} + +sub rcs_add ($) { # {{{ + # Add file to archive. + + my ($file) = @_; + + run_or_cry('git', 'add', $file); +} #}}} + +sub rcs_remove ($) { # {{{ + # Remove file from archive. + + my ($file) = @_; + + run_or_cry('git', 'rm', '-f', $file); +} #}}} + +sub rcs_rename ($$) { # {{{ + my ($src, $dest) = @_; + + run_or_cry('git', 'mv', '-f', $src, $dest); +} #}}} + +sub rcs_recentchanges ($) { #{{{ + # List of recent changes. + + my ($num) = @_; + + eval q{use Date::Parse}; + error($@) if $@; + + my @rets; + foreach my $ci (git_commit_info('HEAD', $num)) { + # Skip redundant commits. + next if ($ci->{'comment'} && @{$ci->{'comment'}}[0] eq $dummy_commit_msg); + + my ($sha1, $when) = ( + $ci->{'sha1'}, + $ci->{'author_epoch'} + ); + + my @pages; + foreach my $detail (@{ $ci->{'details'} }) { + my $file = $detail->{'file'}; + + my $diffurl = defined $config{'diffurl'} ? $config{'diffurl'} : ""; + $diffurl =~ s/\[\[file\]\]/$file/go; + $diffurl =~ s/\[\[sha1_parent\]\]/$ci->{'parent'}/go; + $diffurl =~ s/\[\[sha1_from\]\]/$detail->{'sha1_from'}/go; + $diffurl =~ s/\[\[sha1_to\]\]/$detail->{'sha1_to'}/go; + + push @pages, { + page => pagename($file), + diffurl => $diffurl, + }; + } + + my @messages; + my $pastblank=0; + foreach my $line (@{$ci->{'comment'}}) { + $pastblank=1 if $line eq ''; + next if $pastblank && $line=~m/^ *(signed[ \-]off[ \-]by[ :]|acked[ \-]by[ :]|cc[ :])/i; + push @messages, { line => $line }; + } + + my $user=$ci->{'author_username'}; + my $web_commit = ($ci->{'author'} =~ /\@web>/); + + # compatability code for old web commit messages + if (! $web_commit && + defined $messages[0] && + $messages[0]->{line} =~ m/$config{web_commit_regexp}/) { + $user = defined $2 ? "$2" : "$3"; + $messages[0]->{line} = $4; + $web_commit=1; + } + + push @rets, { + rev => $sha1, + user => $user, + committype => $web_commit ? "web" : "git", + when => $when, + message => [@messages], + pages => [@pages], + } if @pages; + + last if @rets >= $num; + } + + return @rets; +} #}}} + +sub rcs_diff ($) { #{{{ + my $rev=shift; + my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint + my @lines; + foreach my $line (run_or_non("git", "show", $sha1)) { + if (@lines || $line=~/^diff --git/) { + push @lines, $line."\n"; + } + } + if (wantarray) { + return @lines; + } + else { + return join("", @lines); + } +} #}}} + +sub rcs_getctime ($) { #{{{ + my $file=shift; + # Remove srcdir prefix + $file =~ s/^\Q$config{srcdir}\E\/?//; + + my $sha1 = git_sha1($file); + my $ci = git_commit_info($sha1); + my $ctime = $ci->{'author_epoch'}; + debug("ctime for '$file': ". localtime($ctime)); + + return $ctime; +} #}}} + +1 diff --cc debian/changelog index 4954c7737,1290904f8..440910313 --- a/debian/changelog +++ b/debian/changelog @@@ -1,20 -1,4 +1,20 @@@ +ikiwiki (2.60) UNRELEASED; urgency=low + + * Starting with this version, "ikiwiki -setup /etc/ikiwiki/auto.setup" + can be used create a new wiki in seconds. + * Add getsetup hook, all plugins that add fields to %config should use it. + * ikiwiki --dumpsetup can generate a nice setup file snapshotting ikiwiki's + current configuration. + * Large amounts of internal config data reorg. + * The way wrappers are defined in the setup file has changed. Old setup + files will continue to work, for now. + * Version control backends promoted to first-class plugins. + * ikiwiki-update-wikilist: Add -r switch to remove. Default behavior is now + always to add. + + -- Joey Hess Mon, 21 Jul 2008 11:35:46 -0400 + - ikiwiki (2.56) UNRELEASED; urgency=low + ikiwiki (2.56) unstable; urgency=low * autoindex: New plugin that generates missing index pages. (Sponsored by The TOVA Company.)