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 if (exists $IkiWiki::hooks{rcs}) {
15 error(gettext("cannot use multiple rcs plugins"));
17 hook(type => "checkconfig", id => "monotone", call => \&checkconfig);
18 hook(type => "getsetup", id => "monotone", call => \&getsetup);
19 hook(type => "rcs", id => "rcs_update", call => \&rcs_update);
20 hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit);
21 hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit);
22 hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged);
23 hook(type => "rcs", id => "rcs_add", call => \&rcs_add);
24 hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove);
25 hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename);
26 hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges);
27 hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff);
28 hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime);
31 sub checkconfig () { #{{{
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");
47 if (/^monotone (\d+\.\d+) /) {
52 close MTN || debug("mtn version exited $?");
54 if (!defined($version)) {
55 error("Cannot determine monotone version");
57 if ($version < 0.38) {
58 error("Monotone version too old, is $version but required 0.38");
61 if (length $config{mtn_wrapper}) {
62 push @{$config{wrappers}}, {
63 wrapper => $config{mtn_wrapper},
64 wrappermode => (defined $config{mtn_wrappermode} ? $config{mtn_wrappermode} : "06755"),
69 sub getsetup () { #{{{
73 example => "/srv/mtn/wiki/_MTN/ikiwiki-netsync-hook",
74 description => "monotone netsync hook executable to generate",
81 description => "mode for mtn_wrapper (can safely be made suid)",
87 example => 'web@example.com',
88 description => "your monotone key",
94 example => "http://viewmtn.example.com/branch/head/filechanges/com.example.branch/[[file]]",
95 description => "viewmtn url to show file history ([[file]] substituted)",
101 example => "http://viewmtn.example.com/revision/diff/[[r1]]/with/[[r2]]/[[file]]",
102 description => "viewmtn url to show a diff ([[r1]], [[r2]], and [[file]] substituted)",
109 description => "sync on update and commit?",
110 safe => 0, # paranoia
115 description => "path to your workspace (defaults to the srcdir; specify if the srcdir is a subdirectory of the workspace)",
121 sub get_rev () { #{{{
122 my $sha1 = `mtn --root=$config{mtnrootdir} automate get_base_revision_id`;
124 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
126 debug("Unable to get base revision for '$config{srcdir}'.")
132 sub get_rev_auto ($) { #{{{
135 my @results = $automator->call("get_base_revision_id");
137 my $sha1 = $results[0];
138 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
140 debug("Unable to get base revision for '$config{srcdir}'.")
146 sub mtn_merge ($$$$) { #{{{
154 my $child = open(MTNMERGE, "-|");
156 open STDERR, ">&STDOUT";
157 exec("mtn", "--root=$config{mtnrootdir}",
158 "explicit_merge", $leftRev, $rightRev,
159 $branch, "--author", $author, "--key",
160 $config{mtnkey}) || error("mtn merge failed to run");
164 if (/^mtn.\s.merged.\s($sha1_pattern)$/) {
169 close MTNMERGE || return undef;
171 debug("merged $leftRev, $rightRev to make $mergeRev");
176 sub commit_file_to_new_rev ($$$$$$$$) { #{{{
178 my $wsfilename=shift;
180 my $newFileContents=shift;
187 my ($out, $err) = $automator->call("put_file", $oldFileID, $newFileContents);
188 my ($newFileID) = ($out =~ m/^($sha1_pattern)$/);
189 error("Failed to store file data for $wsfilename in repository")
190 if (! defined $newFileID || length $newFileID != 40);
192 # get the mtn filename rather than the workspace filename
193 ($out, $err) = $automator->call("get_corresponding_path", $oldrev, $wsfilename, $oldrev);
194 my ($filename) = ($out =~ m/^file "(.*)"$/);
195 error("Couldn't find monotone repository path for file $wsfilename") if (! $filename);
196 debug("Converted ws filename of $wsfilename to repos filename of $filename");
198 # then stick in a new revision for this file
199 my $manifest = "format_version \"1\"\n\n".
200 "new_manifest [0000000000000000000000000000000000000000]\n\n".
201 "old_revision [$oldrev]\n\n".
202 "patch \"$filename\"\n".
203 " from [$oldFileID]\n".
204 " to [$newFileID]\n";
205 ($out, $err) = $automator->call("put_revision", $manifest);
206 my ($newRevID) = ($out =~ m/^($sha1_pattern)$/);
207 error("Unable to make new monotone repository revision")
208 if (! defined $newRevID || length $newRevID != 40);
209 debug("put revision: $newRevID");
211 # now we need to add certs for this revision...
212 # author, branch, changelog, date
213 $automator->call("cert", $newRevID, "author", $author);
214 $automator->call("cert", $newRevID, "branch", $branch);
215 $automator->call("cert", $newRevID, "changelog", $message);
216 $automator->call("cert", $newRevID, "date",
217 time2str("%Y-%m-%dT%T", time, "UTC"));
219 debug("Added certs for rev: $newRevID");
223 sub read_certs ($$) { #{{{
226 my @results = $automator->call("certs", $rev);
229 my $line = $results[0];
230 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) {
243 sub get_changed_files ($$) { #{{{
247 my @results = $automator->call("get_revision", $rev);
248 my $changes=$results[0];
253 while ($changes =~ m/\s*(add_file|patch|delete|rename)\s"(.*?)(?<!\\)"\n/sg) {
255 # don't add the same file multiple times
256 if (! $seen{$file}) {
265 sub rcs_update () { #{{{
266 chdir $config{srcdir}
267 or error("Cannot chdir to $config{srcdir}: $!");
269 if (defined($config{mtnsync}) && $config{mtnsync}) {
270 if (system("mtn", "--root=$config{mtnrootdir}", "sync",
271 "--quiet", "--ticker=none",
272 "--key", $config{mtnkey}) != 0) {
273 debug("monotone sync failed before update");
277 if (system("mtn", "--root=$config{mtnrootdir}", "update", "--quiet") != 0) {
278 debug("monotone update failed");
282 sub rcs_prepedit ($) { #{{{
285 chdir $config{srcdir}
286 or error("Cannot chdir to $config{srcdir}: $!");
288 # For monotone, return the revision of the file when
293 sub rcs_commit ($$$;$$) { #{{{
294 # Tries to commit the page; returns undef on _success_ and
295 # a version of the page with the rcs's conflict markers on failure.
296 # The file is relative to the srcdir.
305 $author="Web user: " . $user;
307 elsif (defined $ipaddr) {
308 $author="Web IP: " . $ipaddr;
311 $author="Web: Anonymous";
314 chdir $config{srcdir}
315 or error("Cannot chdir to $config{srcdir}: $!");
317 my ($oldrev)= $rcstoken=~ m/^($sha1_pattern)$/; # untaint
319 if (defined $rev && defined $oldrev && $rev ne $oldrev) {
320 my $automator = Monotone->new();
321 $automator->open_args("--root", $config{mtnrootdir}, "--key", $config{mtnkey});
323 # Something has been committed, has this file changed?
325 $automator->setOpts("r", $oldrev, "r", $rev);
326 ($out, $err) = $automator->call("content_diff", $file);
327 debug("Problem committing $file") if ($err ne "");
331 # Commit a revision with just this file changed off
334 # first get the contents
335 debug("File changed: forming branch");
336 my $newfile=readfile("$config{srcdir}/$file");
338 # then get the old content ID from the diff
339 if ($diff !~ m/^---\s$file\s+($sha1_pattern)$/m) {
340 error("Unable to find previous file ID for $file");
344 # get the branch we're working in
345 ($out, $err) = $automator->call("get_option", "branch");
347 error("Illegal branch name in monotone workspace") if ($out !~ m/^([-\@\w\.]+)$/);
350 # then put the new content into the DB (and record the new content ID)
351 my $newRevID = commit_file_to_new_rev($automator, $file, $oldFileID, $newfile, $oldrev, $branch, $author, $message);
355 # if we made it to here then the file has been committed... revert the local copy
356 if (system("mtn", "--root=$config{mtnrootdir}", "revert", $file) != 0) {
357 debug("Unable to revert $file after merge on conflicted commit!");
359 debug("Divergence created! Attempting auto-merge.");
361 # see if it will merge cleanly
362 $ENV{MTN_MERGE}="fail";
363 my $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
366 # push any changes so far
367 if (defined($config{mtnsync}) && $config{mtnsync}) {
368 if (system("mtn", "--root=$config{mtnrootdir}", "push", "--quiet", "--ticker=none", "--key", $config{mtnkey}) != 0) {
369 debug("monotone push failed");
373 if (defined($mergeResult)) {
374 # everything is merged - bring outselves up to date
375 if (system("mtn", "--root=$config{mtnrootdir}",
376 "update", "-r", $mergeResult) != 0) {
377 debug("Unable to update to rev $mergeResult after merge on conflicted commit!");
381 debug("Auto-merge failed. Using diff-merge to add conflict markers.");
383 $ENV{MTN_MERGE}="diffutils";
384 $ENV{MTN_MERGE_DIFFUTILS}="partial=true";
385 $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
387 $ENV{MTN_MERGE_DIFFUTILS}="";
389 if (!defined($mergeResult)) {
390 debug("Unable to insert conflict markers!");
391 error("Your commit succeeded. Unfortunately, someone else committed something to the same ".
392 "part of the wiki at the same time. Both versions are stored in the monotone repository, ".
393 "but at present the different versions cannot be reconciled through the web interface. ".
394 "Please use the non-web interface to resolve the conflicts.");
397 if (system("mtn", "--root=$config{mtnrootdir}",
398 "update", "-r", $mergeResult) != 0) {
399 debug("Unable to update to rev $mergeResult after conflict-enhanced merge on conflicted commit!");
402 # return "conflict enhanced" file to the user
403 # for cleanup note, this relies on the fact
404 # that ikiwiki seems to call rcs_prepedit()
405 # again after we return
406 return readfile("$config{srcdir}/$file");
413 # If we reached here then the file we're looking at hasn't changed
414 # since $oldrev. Commit it.
416 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
417 "--author", $author, "--key", $config{mtnkey}, "-m",
418 IkiWiki::possibly_foolish_untaint($message), $file) != 0) {
419 debug("Traditional commit failed! Returning data as conflict.");
420 my $conflict=readfile("$config{srcdir}/$file");
421 if (system("mtn", "--root=$config{mtnrootdir}", "revert",
422 "--quiet", $file) != 0) {
423 debug("monotone revert failed");
427 if (defined($config{mtnsync}) && $config{mtnsync}) {
428 if (system("mtn", "--root=$config{mtnrootdir}", "push",
429 "--quiet", "--ticker=none", "--key",
430 $config{mtnkey}) != 0) {
431 debug("monotone push failed");
435 return undef # success
438 sub rcs_commit_staged ($$$) {
439 # Commits all staged changes. Changes can be staged using rcs_add,
440 # rcs_remove, and rcs_rename.
441 my ($message, $user, $ipaddr)=@_;
443 # Note - this will also commit any spurious changes that happen to be
444 # lying around in the working copy. There shouldn't be any, but...
446 chdir $config{srcdir}
447 or error("Cannot chdir to $config{srcdir}: $!");
452 $author="Web user: " . $user;
454 elsif (defined $ipaddr) {
455 $author="Web IP: " . $ipaddr;
458 $author="Web: Anonymous";
461 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
462 "--author", $author, "--key", $config{mtnkey}, "-m",
463 IkiWiki::possibly_foolish_untaint($message)) != 0) {
464 error("Monotone commit failed");
468 sub rcs_add ($) { #{{{
471 chdir $config{srcdir}
472 or error("Cannot chdir to $config{srcdir}: $!");
474 if (system("mtn", "--root=$config{mtnrootdir}", "add", "--quiet",
476 error("Monotone add failed");
480 sub rcs_remove ($) { # {{{
483 chdir $config{srcdir}
484 or error("Cannot chdir to $config{srcdir}: $!");
486 # Note: it is difficult to undo a remove in Monotone at the moment.
487 # Until this is fixed, it might be better to make 'rm' move things
488 # into an attic, rather than actually remove them.
489 # To resurrect a file, you currently add a new file with the contents
490 # you want it to have. This loses all connectivity and automated
491 # merging with the 'pre-delete' versions of the file.
493 if (system("mtn", "--root=$config{mtnrootdir}", "rm", "--quiet",
495 error("Monotone remove failed");
499 sub rcs_rename ($$) { # {{{
500 my ($src, $dest) = @_;
502 chdir $config{srcdir}
503 or error("Cannot chdir to $config{srcdir}: $!");
505 if (system("mtn", "--root=$config{mtnrootdir}", "rename", "--quiet",
507 error("Monotone rename failed");
511 sub rcs_recentchanges ($) { #{{{
515 chdir $config{srcdir}
516 or error("Cannot chdir to $config{srcdir}: $!");
518 # use log --brief to get a list of revs, as this
519 # gives the results in a nice order
520 # (otherwise we'd have to do our own date sorting)
524 my $child = open(MTNLOG, "-|");
526 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
527 "--brief") || error("mtn log failed to run");
530 while (($num >= 0) and (my $line = <MTNLOG>)) {
531 if ($line =~ m/^($sha1_pattern)/) {
536 close MTNLOG || debug("mtn log exited $?");
538 my $automator = Monotone->new();
539 $automator->open(undef, $config{mtnrootdir});
542 my $rev = shift @revs;
543 # first go through and figure out the messages, etc
545 my $certs = [read_certs($automator, $rev)];
550 my (@pages, @message);
552 foreach my $cert (@$certs) {
553 if ($cert->{signature} eq "ok" &&
554 $cert->{trust} eq "trusted") {
555 if ($cert->{name} eq "author") {
556 $user = $cert->{value};
557 # detect the source of the commit
559 if ($cert->{key} eq $config{mtnkey}) {
562 $committype = "monotone";
564 } elsif ($cert->{name} eq "date") {
565 $when = str2time($cert->{value}, 'UTC');
566 } elsif ($cert->{name} eq "changelog") {
567 my $messageText = $cert->{value};
568 # split the changelog into multiple
570 foreach my $msgline (split(/\n/, $messageText)) {
571 push @message, { line => $msgline };
577 my @changed_files = get_changed_files($automator, $rev);
580 my ($out, $err) = $automator->call("parents", $rev);
581 my @parents = ($out =~ m/^($sha1_pattern)$/);
582 my $parent = $parents[0];
584 foreach $file (@changed_files) {
585 next unless length $file;
587 if (defined $config{diffurl} and (@parents == 1)) {
588 my $diffurl=$config{diffurl};
589 $diffurl=~s/\[\[r1\]\]/$parent/g;
590 $diffurl=~s/\[\[r2\]\]/$rev/g;
591 $diffurl=~s/\[\[file\]\]/$file/g;
593 page => pagename($file),
599 page => pagename($file),
607 committype => $committype,
609 message => [@message],
619 sub rcs_diff ($) { #{{{
621 my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
623 chdir $config{srcdir}
624 or error("Cannot chdir to $config{srcdir}: $!");
626 my $child = open(MTNDIFF, "-|");
628 exec("mtn", "diff", "--root=$config{mtnrootdir}", "-r", "p:".$sha1, "-r", $sha1) || error("mtn diff $sha1 failed to run");
631 my (@lines) = <MTNDIFF>;
633 close MTNDIFF || debug("mtn diff $sha1 exited $?");
639 return join("", @lines);
643 sub rcs_getctime ($) { #{{{
646 chdir $config{srcdir}
647 or error("Cannot chdir to $config{srcdir}: $!");
649 my $child = open(MTNLOG, "-|");
651 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
652 "--brief", $file) || error("mtn log $file failed to run");
657 if (/^($sha1_pattern)/) {
661 close MTNLOG || debug("mtn log $file exited $?");
663 if (! defined $firstRev) {
664 debug "failed to parse mtn log for $file";
668 my $automator = Monotone->new();
669 $automator->open(undef, $config{mtnrootdir});
671 my $certs = [read_certs($automator, $firstRev)];
677 foreach my $cert (@$certs) {
678 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
679 if ($cert->{name} eq "date") {
680 $date = $cert->{value};
685 if (! defined $date) {
686 debug "failed to find date cert for revision $firstRev when looking for creation time of $file";
690 $date=str2time($date, 'UTC');
691 debug("found ctime ".localtime($date)." for $file");