2 package IkiWiki::Plugin::monotone;
8 use Date::Parse qw(str2time);
9 use Date::Format qw(time2str);
11 my $sha1_pattern = qr/[0-9a-fA-F]{40}/; # pattern to validate sha1sums
14 hook(type => "checkconfig", id => "monotone", call => \&checkconfig);
15 hook(type => "getsetup", id => "monotone", 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);
28 sub checkconfig () { #{{{
29 if (!defined($config{mtnrootdir})) {
30 $config{mtnrootdir} = $config{srcdir};
32 if (! -d "$config{mtnrootdir}/_MTN") {
33 error("Ikiwiki srcdir does not seem to be a Monotone workspace (or set the mtnrootdir)!");
36 my $child = open(MTN, "-|");
38 open STDERR, ">/dev/null";
39 exec("mtn", "version") || error("mtn version failed to run");
44 if (/^monotone (\d+\.\d+) /) {
49 close MTN || debug("mtn version exited $?");
51 if (!defined($version)) {
52 error("Cannot determine monotone version");
54 if ($version < 0.38) {
55 error("Monotone version too old, is $version but required 0.38");
58 if (length $config{mtn_wrapper}) {
59 push @{$config{wrappers}}, {
60 wrapper => $config{mtn_wrapper},
61 wrappermode => (defined $config{mtn_wrappermode} ? $config{mtn_wrappermode} : "06755"),
66 sub getsetup () { #{{{
69 safe => 0, # rcs plugin
74 example => "/srv/mtn/wiki/_MTN/ikiwiki-netsync-hook",
75 description => "monotone netsync hook to generate",
82 description => "mode for mtn_wrapper (can safely be made suid)",
88 example => 'web@example.com',
89 description => "your monotone key",
95 example => "http://viewmtn.example.com/branch/head/filechanges/com.example.branch/[[file]]",
96 description => "viewmtn url to show file history ([[file]] substituted)",
102 example => "http://viewmtn.example.com/revision/diff/[[r1]]/with/[[r2]]/[[file]]",
103 description => "viewmtn url to show a diff ([[r1]], [[r2]], and [[file]] substituted)",
110 description => "sync on update and commit?",
111 safe => 0, # paranoia
116 description => "path to your workspace (defaults to the srcdir; specify if the srcdir is a subdirectory of the workspace)",
122 sub get_rev () { #{{{
123 my $sha1 = `mtn --root=$config{mtnrootdir} automate get_base_revision_id`;
125 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
127 debug("Unable to get base revision for '$config{srcdir}'.")
133 sub get_rev_auto ($) { #{{{
136 my @results = $automator->call("get_base_revision_id");
138 my $sha1 = $results[0];
139 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
141 debug("Unable to get base revision for '$config{srcdir}'.")
147 sub mtn_merge ($$$$) { #{{{
155 my $child = open(MTNMERGE, "-|");
157 open STDERR, ">&STDOUT";
158 exec("mtn", "--root=$config{mtnrootdir}",
159 "explicit_merge", $leftRev, $rightRev,
160 $branch, "--author", $author, "--key",
161 $config{mtnkey}) || error("mtn merge failed to run");
165 if (/^mtn.\s.merged.\s($sha1_pattern)$/) {
170 close MTNMERGE || return undef;
172 debug("merged $leftRev, $rightRev to make $mergeRev");
177 sub commit_file_to_new_rev ($$$$$$$$) { #{{{
179 my $wsfilename=shift;
181 my $newFileContents=shift;
188 my ($out, $err) = $automator->call("put_file", $oldFileID, $newFileContents);
189 my ($newFileID) = ($out =~ m/^($sha1_pattern)$/);
190 error("Failed to store file data for $wsfilename in repository")
191 if (! defined $newFileID || length $newFileID != 40);
193 # get the mtn filename rather than the workspace filename
194 ($out, $err) = $automator->call("get_corresponding_path", $oldrev, $wsfilename, $oldrev);
195 my ($filename) = ($out =~ m/^file "(.*)"$/);
196 error("Couldn't find monotone repository path for file $wsfilename") if (! $filename);
197 debug("Converted ws filename of $wsfilename to repos filename of $filename");
199 # then stick in a new revision for this file
200 my $manifest = "format_version \"1\"\n\n".
201 "new_manifest [0000000000000000000000000000000000000000]\n\n".
202 "old_revision [$oldrev]\n\n".
203 "patch \"$filename\"\n".
204 " from [$oldFileID]\n".
205 " to [$newFileID]\n";
206 ($out, $err) = $automator->call("put_revision", $manifest);
207 my ($newRevID) = ($out =~ m/^($sha1_pattern)$/);
208 error("Unable to make new monotone repository revision")
209 if (! defined $newRevID || length $newRevID != 40);
210 debug("put revision: $newRevID");
212 # now we need to add certs for this revision...
213 # author, branch, changelog, date
214 $automator->call("cert", $newRevID, "author", $author);
215 $automator->call("cert", $newRevID, "branch", $branch);
216 $automator->call("cert", $newRevID, "changelog", $message);
217 $automator->call("cert", $newRevID, "date",
218 time2str("%Y-%m-%dT%T", time, "UTC"));
220 debug("Added certs for rev: $newRevID");
224 sub read_certs ($$) { #{{{
227 my @results = $automator->call("certs", $rev);
230 my $line = $results[0];
231 while ($line =~ m/\s+key\s"(.*?)"\nsignature\s"(ok|bad|unknown)"\n\s+name\s"(.*?)"\n\s+value\s"(.*?)"\n\s+trust\s"(trusted|untrusted)"\n/sg) {
244 sub get_changed_files ($$) { #{{{
248 my @results = $automator->call("get_revision", $rev);
249 my $changes=$results[0];
254 while ($changes =~ m/\s*(add_file|patch|delete|rename)\s"(.*?)(?<!\\)"\n/sg) {
256 # don't add the same file multiple times
257 if (! $seen{$file}) {
266 sub rcs_update () { #{{{
267 chdir $config{srcdir}
268 or error("Cannot chdir to $config{srcdir}: $!");
270 if (defined($config{mtnsync}) && $config{mtnsync}) {
271 if (system("mtn", "--root=$config{mtnrootdir}", "sync",
272 "--quiet", "--ticker=none",
273 "--key", $config{mtnkey}) != 0) {
274 debug("monotone sync failed before update");
278 if (system("mtn", "--root=$config{mtnrootdir}", "update", "--quiet") != 0) {
279 debug("monotone update failed");
283 sub rcs_prepedit ($) { #{{{
286 chdir $config{srcdir}
287 or error("Cannot chdir to $config{srcdir}: $!");
289 # For monotone, return the revision of the file when
294 sub rcs_commit ($$$;$$) { #{{{
295 # Tries to commit the page; returns undef on _success_ and
296 # a version of the page with the rcs's conflict markers on failure.
297 # The file is relative to the srcdir.
306 $author="Web user: " . $user;
308 elsif (defined $ipaddr) {
309 $author="Web IP: " . $ipaddr;
312 $author="Web: Anonymous";
315 chdir $config{srcdir}
316 or error("Cannot chdir to $config{srcdir}: $!");
318 my ($oldrev)= $rcstoken=~ m/^($sha1_pattern)$/; # untaint
320 if (defined $rev && defined $oldrev && $rev ne $oldrev) {
321 my $automator = Monotone->new();
322 $automator->open_args("--root", $config{mtnrootdir}, "--key", $config{mtnkey});
324 # Something has been committed, has this file changed?
326 $automator->setOpts("r", $oldrev, "r", $rev);
327 ($out, $err) = $automator->call("content_diff", $file);
328 debug("Problem committing $file") if ($err ne "");
332 # Commit a revision with just this file changed off
335 # first get the contents
336 debug("File changed: forming branch");
337 my $newfile=readfile("$config{srcdir}/$file");
339 # then get the old content ID from the diff
340 if ($diff !~ m/^---\s$file\s+($sha1_pattern)$/m) {
341 error("Unable to find previous file ID for $file");
345 # get the branch we're working in
346 ($out, $err) = $automator->call("get_option", "branch");
348 error("Illegal branch name in monotone workspace") if ($out !~ m/^([-\@\w\.]+)$/);
351 # then put the new content into the DB (and record the new content ID)
352 my $newRevID = commit_file_to_new_rev($automator, $file, $oldFileID, $newfile, $oldrev, $branch, $author, $message);
356 # if we made it to here then the file has been committed... revert the local copy
357 if (system("mtn", "--root=$config{mtnrootdir}", "revert", $file) != 0) {
358 debug("Unable to revert $file after merge on conflicted commit!");
360 debug("Divergence created! Attempting auto-merge.");
362 # see if it will merge cleanly
363 $ENV{MTN_MERGE}="fail";
364 my $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
367 # push any changes so far
368 if (defined($config{mtnsync}) && $config{mtnsync}) {
369 if (system("mtn", "--root=$config{mtnrootdir}", "push", "--quiet", "--ticker=none", "--key", $config{mtnkey}) != 0) {
370 debug("monotone push failed");
374 if (defined($mergeResult)) {
375 # everything is merged - bring outselves up to date
376 if (system("mtn", "--root=$config{mtnrootdir}",
377 "update", "-r", $mergeResult) != 0) {
378 debug("Unable to update to rev $mergeResult after merge on conflicted commit!");
382 debug("Auto-merge failed. Using diff-merge to add conflict markers.");
384 $ENV{MTN_MERGE}="diffutils";
385 $ENV{MTN_MERGE_DIFFUTILS}="partial=true";
386 $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
388 $ENV{MTN_MERGE_DIFFUTILS}="";
390 if (!defined($mergeResult)) {
391 debug("Unable to insert conflict markers!");
392 error("Your commit succeeded. Unfortunately, someone else committed something to the same ".
393 "part of the wiki at the same time. Both versions are stored in the monotone repository, ".
394 "but at present the different versions cannot be reconciled through the web interface. ".
395 "Please use the non-web interface to resolve the conflicts.");
398 if (system("mtn", "--root=$config{mtnrootdir}",
399 "update", "-r", $mergeResult) != 0) {
400 debug("Unable to update to rev $mergeResult after conflict-enhanced merge on conflicted commit!");
403 # return "conflict enhanced" file to the user
404 # for cleanup note, this relies on the fact
405 # that ikiwiki seems to call rcs_prepedit()
406 # again after we return
407 return readfile("$config{srcdir}/$file");
414 # If we reached here then the file we're looking at hasn't changed
415 # since $oldrev. Commit it.
417 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
418 "--author", $author, "--key", $config{mtnkey}, "-m",
419 IkiWiki::possibly_foolish_untaint($message), $file) != 0) {
420 debug("Traditional commit failed! Returning data as conflict.");
421 my $conflict=readfile("$config{srcdir}/$file");
422 if (system("mtn", "--root=$config{mtnrootdir}", "revert",
423 "--quiet", $file) != 0) {
424 debug("monotone revert failed");
428 if (defined($config{mtnsync}) && $config{mtnsync}) {
429 if (system("mtn", "--root=$config{mtnrootdir}", "push",
430 "--quiet", "--ticker=none", "--key",
431 $config{mtnkey}) != 0) {
432 debug("monotone push failed");
436 return undef # success
439 sub rcs_commit_staged ($$$) {
440 # Commits all staged changes. Changes can be staged using rcs_add,
441 # rcs_remove, and rcs_rename.
442 my ($message, $user, $ipaddr)=@_;
444 # Note - this will also commit any spurious changes that happen to be
445 # lying around in the working copy. There shouldn't be any, but...
447 chdir $config{srcdir}
448 or error("Cannot chdir to $config{srcdir}: $!");
453 $author="Web user: " . $user;
455 elsif (defined $ipaddr) {
456 $author="Web IP: " . $ipaddr;
459 $author="Web: Anonymous";
462 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
463 "--author", $author, "--key", $config{mtnkey}, "-m",
464 IkiWiki::possibly_foolish_untaint($message)) != 0) {
465 error("Monotone commit failed");
469 sub rcs_add ($) { #{{{
472 chdir $config{srcdir}
473 or error("Cannot chdir to $config{srcdir}: $!");
475 if (system("mtn", "--root=$config{mtnrootdir}", "add", "--quiet",
477 error("Monotone add failed");
481 sub rcs_remove ($) { # {{{
484 chdir $config{srcdir}
485 or error("Cannot chdir to $config{srcdir}: $!");
487 # Note: it is difficult to undo a remove in Monotone at the moment.
488 # Until this is fixed, it might be better to make 'rm' move things
489 # into an attic, rather than actually remove them.
490 # To resurrect a file, you currently add a new file with the contents
491 # you want it to have. This loses all connectivity and automated
492 # merging with the 'pre-delete' versions of the file.
494 if (system("mtn", "--root=$config{mtnrootdir}", "rm", "--quiet",
496 error("Monotone remove failed");
500 sub rcs_rename ($$) { # {{{
501 my ($src, $dest) = @_;
503 chdir $config{srcdir}
504 or error("Cannot chdir to $config{srcdir}: $!");
506 if (system("mtn", "--root=$config{mtnrootdir}", "rename", "--quiet",
508 error("Monotone rename failed");
512 sub rcs_recentchanges ($) { #{{{
516 chdir $config{srcdir}
517 or error("Cannot chdir to $config{srcdir}: $!");
519 # use log --brief to get a list of revs, as this
520 # gives the results in a nice order
521 # (otherwise we'd have to do our own date sorting)
525 my $child = open(MTNLOG, "-|");
527 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
528 "--brief") || error("mtn log failed to run");
531 while (($num >= 0) and (my $line = <MTNLOG>)) {
532 if ($line =~ m/^($sha1_pattern)/) {
537 close MTNLOG || debug("mtn log exited $?");
539 my $automator = Monotone->new();
540 $automator->open(undef, $config{mtnrootdir});
543 my $rev = shift @revs;
544 # first go through and figure out the messages, etc
546 my $certs = [read_certs($automator, $rev)];
551 my (@pages, @message);
553 foreach my $cert (@$certs) {
554 if ($cert->{signature} eq "ok" &&
555 $cert->{trust} eq "trusted") {
556 if ($cert->{name} eq "author") {
557 $user = $cert->{value};
558 # detect the source of the commit
560 if ($cert->{key} eq $config{mtnkey}) {
563 $committype = "monotone";
565 } elsif ($cert->{name} eq "date") {
566 $when = str2time($cert->{value}, 'UTC');
567 } elsif ($cert->{name} eq "changelog") {
568 my $messageText = $cert->{value};
569 # split the changelog into multiple
571 foreach my $msgline (split(/\n/, $messageText)) {
572 push @message, { line => $msgline };
578 my @changed_files = get_changed_files($automator, $rev);
581 my ($out, $err) = $automator->call("parents", $rev);
582 my @parents = ($out =~ m/^($sha1_pattern)$/);
583 my $parent = $parents[0];
585 foreach $file (@changed_files) {
586 next unless length $file;
588 if (defined $config{diffurl} and (@parents == 1)) {
589 my $diffurl=$config{diffurl};
590 $diffurl=~s/\[\[r1\]\]/$parent/g;
591 $diffurl=~s/\[\[r2\]\]/$rev/g;
592 $diffurl=~s/\[\[file\]\]/$file/g;
594 page => pagename($file),
600 page => pagename($file),
608 committype => $committype,
610 message => [@message],
620 sub rcs_diff ($) { #{{{
622 my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
624 chdir $config{srcdir}
625 or error("Cannot chdir to $config{srcdir}: $!");
627 my $child = open(MTNDIFF, "-|");
629 exec("mtn", "diff", "--root=$config{mtnrootdir}", "-r", "p:".$sha1, "-r", $sha1) || error("mtn diff $sha1 failed to run");
632 my (@lines) = <MTNDIFF>;
634 close MTNDIFF || debug("mtn diff $sha1 exited $?");
640 return join("", @lines);
644 sub rcs_getctime ($) { #{{{
647 chdir $config{srcdir}
648 or error("Cannot chdir to $config{srcdir}: $!");
650 my $child = open(MTNLOG, "-|");
652 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
653 "--brief", $file) || error("mtn log $file failed to run");
658 if (/^($sha1_pattern)/) {
662 close MTNLOG || debug("mtn log $file exited $?");
664 if (! defined $firstRev) {
665 debug "failed to parse mtn log for $file";
669 my $automator = Monotone->new();
670 $automator->open(undef, $config{mtnrootdir});
672 my $certs = [read_certs($automator, $firstRev)];
678 foreach my $cert (@$certs) {
679 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
680 if ($cert->{name} eq "date") {
681 $date = $cert->{value};
686 if (! defined $date) {
687 debug "failed to find date cert for revision $firstRev when looking for creation time of $file";
691 $date=str2time($date, 'UTC');
692 debug("found ctime ".localtime($date)." for $file");