2 package IkiWiki::Plugin::monotone;
8 use Date::Parse qw(str2time);
9 use Date::Format qw(time2str);
10 use URI::Escape q{uri_escape_utf8};
12 my $sha1_pattern = qr/[0-9a-fA-F]{40}/; # pattern to validate sha1sums
13 my $mtn_version = undef;
16 hook(type => "checkconfig", id => "monotone", call => \&checkconfig);
17 hook(type => "getsetup", id => "monotone", call => \&getsetup);
18 hook(type => "rcs", id => "rcs_update", call => \&rcs_update);
19 hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit);
20 hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit);
21 hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged);
22 hook(type => "rcs", id => "rcs_add", call => \&rcs_add);
23 hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove);
24 hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename);
25 hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges);
26 hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff);
27 hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime);
28 hook(type => "rcs", id => "rcs_getmtime", call => \&rcs_getmtime);
32 if (!defined($config{mtnrootdir})) {
33 $config{mtnrootdir} = $config{srcdir};
35 if (! -d "$config{mtnrootdir}/_MTN") {
36 error("Ikiwiki srcdir does not seem to be a Monotone workspace (or set the mtnrootdir)!");
39 my $child = open(MTN, "-|");
41 open STDERR, ">/dev/null";
42 exec("mtn", "version") || error("mtn version failed to run");
46 if (/^monotone (\d+\.\d+)(?:(?:\.\d+){0,2}|dev)? /) {
51 close MTN || debug("mtn version exited $?");
53 if (!defined($mtn_version)) {
54 error("Cannot determine monotone version");
56 if ($mtn_version < 0.38) {
57 error("Monotone version too old, is $mtn_version but required 0.38");
60 if (defined $config{mtn_wrapper} && length $config{mtn_wrapper}) {
61 push @{$config{wrappers}}, {
62 wrapper => $config{mtn_wrapper},
63 wrappermode => (defined $config{mtn_wrappermode} ? $config{mtn_wrappermode} : "06755"),
71 safe => 0, # rcs plugin
77 example => "/srv/mtn/wiki/_MTN/ikiwiki-netsync-hook",
78 description => "monotone netsync hook to generate",
85 description => "mode for mtn_wrapper (can safely be made suid)",
91 example => 'web@example.com',
92 description => "your monotone key",
98 example => "http://viewmtn.example.com/branch/head/filechanges/com.example.branch/[[file]]",
99 description => "viewmtn url to show file history ([[file]] substituted)",
105 example => "http://viewmtn.example.com/revision/diff/[[r1]]/with/[[r2]]/[[file]]",
106 description => "viewmtn url to show a diff ([[r1]], [[r2]], and [[file]] substituted)",
113 description => "sync on update and commit?",
114 safe => 0, # paranoia
119 description => "path to your workspace (defaults to the srcdir; specify if the srcdir is a subdirectory of the workspace)",
126 my $sha1 = `mtn --root=$config{mtnrootdir} automate get_base_revision_id`;
128 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
130 debug("Unable to get base revision for '$config{srcdir}'.")
136 sub get_rev_auto ($) {
139 my @results = $automator->call("get_base_revision_id");
141 my $sha1 = $results[0];
142 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
144 debug("Unable to get base revision for '$config{srcdir}'.")
150 sub mtn_merge ($$$$) {
158 my $child = open(MTNMERGE, "-|");
160 open STDERR, ">&STDOUT";
161 exec("mtn", "--root=$config{mtnrootdir}",
162 "explicit_merge", $leftRev, $rightRev,
163 $branch, "--author", $author, "--key",
164 $config{mtnkey}) || error("mtn merge failed to run");
168 if (/^mtn.\s.merged.\s($sha1_pattern)$/) {
173 close MTNMERGE || return undef;
175 debug("merged $leftRev, $rightRev to make $mergeRev");
180 sub commit_file_to_new_rev ($$$$$$$$) {
182 my $wsfilename=shift;
184 my $newFileContents=shift;
191 my ($out, $err) = $automator->call("put_file", $oldFileID, $newFileContents);
192 my ($newFileID) = ($out =~ m/^($sha1_pattern)$/);
193 error("Failed to store file data for $wsfilename in repository")
194 if (! defined $newFileID || length $newFileID != 40);
196 # get the mtn filename rather than the workspace filename
197 ($out, $err) = $automator->call("get_corresponding_path", $oldrev, $wsfilename, $oldrev);
198 my ($filename) = ($out =~ m/^file "(.*)"$/);
199 error("Couldn't find monotone repository path for file $wsfilename") if (! $filename);
200 debug("Converted ws filename of $wsfilename to repos filename of $filename");
202 # then stick in a new revision for this file
203 my $manifest = "format_version \"1\"\n\n".
204 "new_manifest [0000000000000000000000000000000000000000]\n\n".
205 "old_revision [$oldrev]\n\n".
206 "patch \"$filename\"\n".
207 " from [$oldFileID]\n".
208 " to [$newFileID]\n";
209 ($out, $err) = $automator->call("put_revision", $manifest);
210 my ($newRevID) = ($out =~ m/^($sha1_pattern)$/);
211 error("Unable to make new monotone repository revision")
212 if (! defined $newRevID || length $newRevID != 40);
213 debug("put revision: $newRevID");
215 # now we need to add certs for this revision...
216 # author, branch, changelog, date
217 $automator->call("cert", $newRevID, "author", $author);
218 $automator->call("cert", $newRevID, "branch", $branch);
219 $automator->call("cert", $newRevID, "changelog", $message);
220 $automator->call("cert", $newRevID, "date",
221 time2str("%Y-%m-%dT%T", time, "UTC"));
223 debug("Added certs for rev: $newRevID");
227 sub read_certs ($$) {
230 my @results = $automator->call("certs", $rev);
233 my $line = $results[0];
234 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) {
247 sub get_changed_files ($$) {
251 my @results = $automator->call("get_revision", $rev);
252 my $changes=$results[0];
257 # we need to strip off the relative path to the source dir
258 # because monotone outputs all file paths absolute according
259 # to the workspace root
260 my $rel_src_dir = $config{'srcdir'};
261 $rel_src_dir =~ s/^\Q$config{'mtnrootdir'}\E\/?//;
262 $rel_src_dir .= "/" if length $rel_src_dir;
264 while ($changes =~ m/\s*(add_file|patch|delete|rename)\s"(.*?)(?<!\\)"\n/sg) {
266 # ignore all file changes outside the source dir
267 next unless $file =~ m/^\Q$rel_src_dir\E/;
268 $file =~ s/^\Q$rel_src_dir\E//;
270 # don't add the same file multiple times
271 if (! $seen{$file}) {
281 chdir $config{srcdir}
282 or error("Cannot chdir to $config{srcdir}: $!");
284 if (defined($config{mtnsync}) && $config{mtnsync}) {
285 if (system("mtn", "--root=$config{mtnrootdir}", "sync",
286 "--quiet", "--ticker=none",
287 "--key", $config{mtnkey}) != 0) {
288 debug("monotone sync failed before update");
292 if (system("mtn", "--root=$config{mtnrootdir}", "update", "--quiet") != 0) {
293 debug("monotone update failed");
297 sub rcs_prepedit ($) {
300 chdir $config{srcdir}
301 or error("Cannot chdir to $config{srcdir}: $!");
303 # For monotone, return the revision of the file when
308 sub commitauthor (@) {
311 if (defined $params{session}) {
312 if (defined $params{session}->param("name")) {
313 return "Web user: " . $params{session}->param("name");
315 elsif (defined $params{session}->remote_addr()) {
316 return "Web IP: " . $params{session}->remote_addr();
319 return "Web: Anonymous";
324 # Tries to commit the page; returns undef on _success_ and
325 # a version of the page with the rcs's conflict markers on failure.
326 # The file is relative to the srcdir.
329 my $author=IkiWiki::possibly_foolish_untaint(commitauthor(%params)),
331 chdir $config{srcdir}
332 or error("Cannot chdir to $config{srcdir}: $!");
334 my ($oldrev) = $params{token} =~ m/^($sha1_pattern)$/; # untaint
336 if (defined $rev && defined $oldrev && $rev ne $oldrev) {
337 my $automator = Monotone->new();
338 $automator->open_args("--root", $config{mtnrootdir}, "--key", $config{mtnkey});
340 # Something has been committed, has this file changed?
342 $automator->setOpts("r", $oldrev, "r", $rev);
343 ($out, $err) = $automator->call("content_diff", $params{file});
344 debug("Problem committing $params{file}") if ($err ne "");
348 # Commit a revision with just this file changed off
351 # first get the contents
352 debug("File changed: forming branch");
353 my $newfile=readfile("$config{srcdir}/$params{file}");
355 # then get the old content ID from the diff
356 if ($diff !~ m/^---\s$params{file}\s+($sha1_pattern)$/m) {
357 error("Unable to find previous file ID for $params{file}");
361 # get the branch we're working in
362 ($out, $err) = $automator->call("get_option", "branch");
364 error("Illegal branch name in monotone workspace") if ($out !~ m/^([-\@\w\.]+)$/);
367 # then put the new content into the DB (and record the new content ID)
368 my $newRevID = commit_file_to_new_rev($automator, $params{file}, $oldFileID, $newfile, $oldrev, $branch, $author, $params{message});
372 # if we made it to here then the file has been committed... revert the local copy
373 if (system("mtn", "--root=$config{mtnrootdir}", "revert", $params{file}) != 0) {
374 debug("Unable to revert $params{file} after merge on conflicted commit!");
376 debug("Divergence created! Attempting auto-merge.");
378 # see if it will merge cleanly
379 $ENV{MTN_MERGE}="fail";
380 my $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
383 # push any changes so far
384 if (defined($config{mtnsync}) && $config{mtnsync}) {
385 if (system("mtn", "--root=$config{mtnrootdir}", "push", "--quiet", "--ticker=none", "--key", $config{mtnkey}) != 0) {
386 debug("monotone push failed");
390 if (defined($mergeResult)) {
391 # everything is merged - bring outselves up to date
392 if (system("mtn", "--root=$config{mtnrootdir}",
393 "update", "-r", $mergeResult) != 0) {
394 debug("Unable to update to rev $mergeResult after merge on conflicted commit!");
398 debug("Auto-merge failed. Using diff-merge to add conflict markers.");
400 $ENV{MTN_MERGE}="diffutils";
401 $ENV{MTN_MERGE_DIFFUTILS}="partial=true";
402 $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
404 $ENV{MTN_MERGE_DIFFUTILS}="";
406 if (!defined($mergeResult)) {
407 debug("Unable to insert conflict markers!");
408 error("Your commit succeeded. Unfortunately, someone else committed something to the same ".
409 "part of the wiki at the same time. Both versions are stored in the monotone repository, ".
410 "but at present the different versions cannot be reconciled through the web interface. ".
411 "Please use the non-web interface to resolve the conflicts.");
414 if (system("mtn", "--root=$config{mtnrootdir}",
415 "update", "-r", $mergeResult) != 0) {
416 debug("Unable to update to rev $mergeResult after conflict-enhanced merge on conflicted commit!");
419 # return "conflict enhanced" file to the user
420 # for cleanup note, this relies on the fact
421 # that ikiwiki seems to call rcs_prepedit()
422 # again after we return
423 return readfile("$config{srcdir}/$params{file}");
430 # If we reached here then the file we're looking at hasn't changed
431 # since $oldrev. Commit it.
433 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
434 "--author", $author, "--key", $config{mtnkey}, "-m",
435 IkiWiki::possibly_foolish_untaint($params{message}),
436 $params{file}) != 0) {
437 debug("Traditional commit failed! Returning data as conflict.");
438 my $conflict=readfile("$config{srcdir}/$params{file}");
439 if (system("mtn", "--root=$config{mtnrootdir}", "revert",
440 "--quiet", $params{file}) != 0) {
441 debug("monotone revert failed");
445 if (defined($config{mtnsync}) && $config{mtnsync}) {
446 if (system("mtn", "--root=$config{mtnrootdir}", "push",
447 "--quiet", "--ticker=none", "--key",
448 $config{mtnkey}) != 0) {
449 debug("monotone push failed");
453 return undef # success
456 sub rcs_commit_staged (@) {
457 # Commits all staged changes. Changes can be staged using rcs_add,
458 # rcs_remove, and rcs_rename.
461 # Note - this will also commit any spurious changes that happen to be
462 # lying around in the working copy. There shouldn't be any, but...
464 chdir $config{srcdir}
465 or error("Cannot chdir to $config{srcdir}: $!");
467 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
468 "--author", IkiWiki::possibly_foolish_untaint(commitauthor(%params)),
469 "--key", $config{mtnkey}, "-m",
470 IkiWiki::possibly_foolish_untaint($params{message})) != 0) {
471 error("Monotone commit failed");
478 chdir $config{srcdir}
479 or error("Cannot chdir to $config{srcdir}: $!");
481 if (system("mtn", "--root=$config{mtnrootdir}", "add", "--quiet",
483 error("Monotone add failed");
490 chdir $config{srcdir}
491 or error("Cannot chdir to $config{srcdir}: $!");
493 # Note: it is difficult to undo a remove in Monotone at the moment.
494 # Until this is fixed, it might be better to make 'rm' move things
495 # into an attic, rather than actually remove them.
496 # To resurrect a file, you currently add a new file with the contents
497 # you want it to have. This loses all connectivity and automated
498 # merging with the 'pre-delete' versions of the file.
500 if (system("mtn", "--root=$config{mtnrootdir}", "rm", "--quiet",
502 error("Monotone remove failed");
506 sub rcs_rename ($$) {
507 my ($src, $dest) = @_;
509 chdir $config{srcdir}
510 or error("Cannot chdir to $config{srcdir}: $!");
512 if (system("mtn", "--root=$config{mtnrootdir}", "rename", "--quiet",
514 error("Monotone rename failed");
518 sub rcs_recentchanges ($) {
522 chdir $config{srcdir}
523 or error("Cannot chdir to $config{srcdir}: $!");
525 # use log --brief to get a list of revs, as this
526 # gives the results in a nice order
527 # (otherwise we'd have to do our own date sorting)
531 my $child = open(MTNLOG, "-|");
533 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
534 "--brief", "--last=$num") || error("mtn log failed to run");
537 while (my $line = <MTNLOG>) {
538 if ($line =~ m/^($sha1_pattern)/) {
542 close MTNLOG || debug("mtn log exited $?");
544 my $automator = Monotone->new();
545 $automator->open(undef, $config{mtnrootdir});
548 my $rev = shift @revs;
549 # first go through and figure out the messages, etc
551 my $certs = [read_certs($automator, $rev)];
556 my (@pages, @message);
558 foreach my $cert (@$certs) {
559 if ($cert->{signature} eq "ok" &&
560 $cert->{trust} eq "trusted") {
561 if ($cert->{name} eq "author") {
562 $user = $cert->{value};
563 # detect the source of the commit
565 if ($cert->{key} eq $config{mtnkey}) {
571 } elsif ($cert->{name} eq "date") {
572 $when = str2time($cert->{value}, 'UTC');
573 } elsif ($cert->{name} eq "changelog") {
574 my $messageText = $cert->{value};
575 # split the changelog into multiple
577 foreach my $msgline (split(/\n/, $messageText)) {
578 push @message, { line => $msgline };
584 my @changed_files = get_changed_files($automator, $rev);
586 my ($out, $err) = $automator->call("parents", $rev);
587 my @parents = ($out =~ m/^($sha1_pattern)$/);
588 my $parent = $parents[0];
590 foreach my $file (@changed_files) {
591 next unless length $file;
593 if (defined $config{diffurl} and (@parents == 1)) {
594 my $diffurl=$config{diffurl};
595 $diffurl=~s/\[\[r1\]\]/$parent/g;
596 $diffurl=~s/\[\[r2\]\]/$rev/g;
597 my $efile = uri_escape_utf8($file);
598 $diffurl=~s/\[\[file\]\]/$efile/g;
600 page => pagename($file),
606 page => pagename($file),
614 committype => $committype,
616 message => [@message],
629 my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
631 chdir $config{srcdir}
632 or error("Cannot chdir to $config{srcdir}: $!");
634 my $child = open(MTNDIFF, "-|");
636 exec("mtn", "diff", "--root=$config{mtnrootdir}", "-r", "p:".$sha1, "-r", $sha1) || error("mtn diff $sha1 failed to run");
640 while (my $line=<MTNDIFF>) {
641 last if defined $maxlines && @lines == $maxlines;
645 close MTNDIFF || debug("mtn diff $sha1 exited $?");
651 return join("", @lines);
655 sub rcs_getctime ($) {
658 chdir $config{srcdir}
659 or error("Cannot chdir to $config{srcdir}: $!");
661 my $child = open(MTNLOG, "-|");
663 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
664 "--brief", $file) || error("mtn log $file failed to run");
670 if (/^($sha1_pattern)/) {
675 close MTNLOG || debug("mtn log $file exited $?");
677 if (! defined $firstRev) {
678 debug "failed to parse mtn log for $file";
682 my $automator = Monotone->new();
683 $automator->open(undef, $config{mtnrootdir});
685 # mtn 0.48 has a bug that makes it list the creation of parent
686 # directories as last (first) log entry... So when we're dealing
687 # with that version, let's check that the file we're looking for
688 # is actually part of the last (first) revision. Otherwise, pick
689 # the one before (after) that one.
690 if ($mtn_version == 0.48) {
691 my $changes = [get_changed_files($automator, $firstRev)];
692 if (! exists {map { $_ => 1 } @$changes}->{$file}) {
693 $firstRev = $prevRev;
696 my $certs = [read_certs($automator, $firstRev)];
702 foreach my $cert (@$certs) {
703 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
704 if ($cert->{name} eq "date") {
705 $date = $cert->{value};
710 if (! defined $date) {
711 debug "failed to find date cert for revision $firstRev when looking for creation time of $file";
715 $date=str2time($date, 'UTC');
716 debug("found ctime ".localtime($date)." for $file");
720 sub rcs_getmtime ($) {
723 chdir $config{srcdir}
724 or error("Cannot chdir to $config{srcdir}: $!");
726 my $child = open(MTNLOG, "-|");
728 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
729 "--brief", $file) || error("mtn log $file failed to run");
734 if (/^($sha1_pattern)/ && $lastRev eq "") {
738 close MTNLOG || debug("mtn log $file exited $?");
740 if (! defined $lastRev) {
741 debug "failed to parse mtn log for $file";
745 my $automator = Monotone->new();
746 $automator->open(undef, $config{mtnrootdir});
748 my $certs = [read_certs($automator, $lastRev)];
754 foreach my $cert (@$certs) {
755 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
756 if ($cert->{name} eq "date") {
757 $date = $cert->{value};
762 if (! defined $date) {
763 debug "failed to find date cert for revision $lastRev when looking for creation time of $file";
767 $date=str2time($date, 'UTC');
768 debug("found mtime ".localtime($date)." for $file");