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);
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 (defined $config{mtn_wrapper} && length $config{mtn_wrapper}) {
59 push @{$config{wrappers}}, {
60 wrapper => $config{mtn_wrapper},
61 wrappermode => (defined $config{mtn_wrappermode} ? $config{mtn_wrappermode} : "06755"),
69 safe => 0, # rcs plugin
75 example => "/srv/mtn/wiki/_MTN/ikiwiki-netsync-hook",
76 description => "monotone netsync hook to generate",
83 description => "mode for mtn_wrapper (can safely be made suid)",
89 example => 'web@example.com',
90 description => "your monotone key",
96 example => "http://viewmtn.example.com/branch/head/filechanges/com.example.branch/[[file]]",
97 description => "viewmtn url to show file history ([[file]] substituted)",
103 example => "http://viewmtn.example.com/revision/diff/[[r1]]/with/[[r2]]/[[file]]",
104 description => "viewmtn url to show a diff ([[r1]], [[r2]], and [[file]] substituted)",
111 description => "sync on update and commit?",
112 safe => 0, # paranoia
117 description => "path to your workspace (defaults to the srcdir; specify if the srcdir is a subdirectory of the workspace)",
124 my $sha1 = `mtn --root=$config{mtnrootdir} automate get_base_revision_id`;
126 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
128 debug("Unable to get base revision for '$config{srcdir}'.")
134 sub get_rev_auto ($) {
137 my @results = $automator->call("get_base_revision_id");
139 my $sha1 = $results[0];
140 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
142 debug("Unable to get base revision for '$config{srcdir}'.")
148 sub mtn_merge ($$$$) {
156 my $child = open(MTNMERGE, "-|");
158 open STDERR, ">&STDOUT";
159 exec("mtn", "--root=$config{mtnrootdir}",
160 "explicit_merge", $leftRev, $rightRev,
161 $branch, "--author", $author, "--key",
162 $config{mtnkey}) || error("mtn merge failed to run");
166 if (/^mtn.\s.merged.\s($sha1_pattern)$/) {
171 close MTNMERGE || return undef;
173 debug("merged $leftRev, $rightRev to make $mergeRev");
178 sub commit_file_to_new_rev ($$$$$$$$) {
180 my $wsfilename=shift;
182 my $newFileContents=shift;
189 my ($out, $err) = $automator->call("put_file", $oldFileID, $newFileContents);
190 my ($newFileID) = ($out =~ m/^($sha1_pattern)$/);
191 error("Failed to store file data for $wsfilename in repository")
192 if (! defined $newFileID || length $newFileID != 40);
194 # get the mtn filename rather than the workspace filename
195 ($out, $err) = $automator->call("get_corresponding_path", $oldrev, $wsfilename, $oldrev);
196 my ($filename) = ($out =~ m/^file "(.*)"$/);
197 error("Couldn't find monotone repository path for file $wsfilename") if (! $filename);
198 debug("Converted ws filename of $wsfilename to repos filename of $filename");
200 # then stick in a new revision for this file
201 my $manifest = "format_version \"1\"\n\n".
202 "new_manifest [0000000000000000000000000000000000000000]\n\n".
203 "old_revision [$oldrev]\n\n".
204 "patch \"$filename\"\n".
205 " from [$oldFileID]\n".
206 " to [$newFileID]\n";
207 ($out, $err) = $automator->call("put_revision", $manifest);
208 my ($newRevID) = ($out =~ m/^($sha1_pattern)$/);
209 error("Unable to make new monotone repository revision")
210 if (! defined $newRevID || length $newRevID != 40);
211 debug("put revision: $newRevID");
213 # now we need to add certs for this revision...
214 # author, branch, changelog, date
215 $automator->call("cert", $newRevID, "author", $author);
216 $automator->call("cert", $newRevID, "branch", $branch);
217 $automator->call("cert", $newRevID, "changelog", $message);
218 $automator->call("cert", $newRevID, "date",
219 time2str("%Y-%m-%dT%T", time, "UTC"));
221 debug("Added certs for rev: $newRevID");
225 sub read_certs ($$) {
228 my @results = $automator->call("certs", $rev);
231 my $line = $results[0];
232 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) {
245 sub get_changed_files ($$) {
249 my @results = $automator->call("get_revision", $rev);
250 my $changes=$results[0];
255 while ($changes =~ m/\s*(add_file|patch|delete|rename)\s"(.*?)(?<!\\)"\n/sg) {
257 # don't add the same file multiple times
258 if (! $seen{$file}) {
268 chdir $config{srcdir}
269 or error("Cannot chdir to $config{srcdir}: $!");
271 if (defined($config{mtnsync}) && $config{mtnsync}) {
272 if (system("mtn", "--root=$config{mtnrootdir}", "sync",
273 "--quiet", "--ticker=none",
274 "--key", $config{mtnkey}) != 0) {
275 debug("monotone sync failed before update");
279 if (system("mtn", "--root=$config{mtnrootdir}", "update", "--quiet") != 0) {
280 debug("monotone update failed");
284 sub rcs_prepedit ($) {
287 chdir $config{srcdir}
288 or error("Cannot chdir to $config{srcdir}: $!");
290 # For monotone, return the revision of the file when
295 sub rcs_commit ($$$;$$) {
296 # Tries to commit the page; returns undef on _success_ and
297 # a version of the page with the rcs's conflict markers on failure.
298 # The file is relative to the srcdir.
307 $author="Web user: " . $user;
309 elsif (defined $ipaddr) {
310 $author="Web IP: " . $ipaddr;
313 $author="Web: Anonymous";
316 chdir $config{srcdir}
317 or error("Cannot chdir to $config{srcdir}: $!");
319 my ($oldrev)= $rcstoken=~ m/^($sha1_pattern)$/; # untaint
321 if (defined $rev && defined $oldrev && $rev ne $oldrev) {
322 my $automator = Monotone->new();
323 $automator->open_args("--root", $config{mtnrootdir}, "--key", $config{mtnkey});
325 # Something has been committed, has this file changed?
327 $automator->setOpts("r", $oldrev, "r", $rev);
328 ($out, $err) = $automator->call("content_diff", $file);
329 debug("Problem committing $file") if ($err ne "");
333 # Commit a revision with just this file changed off
336 # first get the contents
337 debug("File changed: forming branch");
338 my $newfile=readfile("$config{srcdir}/$file");
340 # then get the old content ID from the diff
341 if ($diff !~ m/^---\s$file\s+($sha1_pattern)$/m) {
342 error("Unable to find previous file ID for $file");
346 # get the branch we're working in
347 ($out, $err) = $automator->call("get_option", "branch");
349 error("Illegal branch name in monotone workspace") if ($out !~ m/^([-\@\w\.]+)$/);
352 # then put the new content into the DB (and record the new content ID)
353 my $newRevID = commit_file_to_new_rev($automator, $file, $oldFileID, $newfile, $oldrev, $branch, $author, $message);
357 # if we made it to here then the file has been committed... revert the local copy
358 if (system("mtn", "--root=$config{mtnrootdir}", "revert", $file) != 0) {
359 debug("Unable to revert $file after merge on conflicted commit!");
361 debug("Divergence created! Attempting auto-merge.");
363 # see if it will merge cleanly
364 $ENV{MTN_MERGE}="fail";
365 my $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
368 # push any changes so far
369 if (defined($config{mtnsync}) && $config{mtnsync}) {
370 if (system("mtn", "--root=$config{mtnrootdir}", "push", "--quiet", "--ticker=none", "--key", $config{mtnkey}) != 0) {
371 debug("monotone push failed");
375 if (defined($mergeResult)) {
376 # everything is merged - bring outselves up to date
377 if (system("mtn", "--root=$config{mtnrootdir}",
378 "update", "-r", $mergeResult) != 0) {
379 debug("Unable to update to rev $mergeResult after merge on conflicted commit!");
383 debug("Auto-merge failed. Using diff-merge to add conflict markers.");
385 $ENV{MTN_MERGE}="diffutils";
386 $ENV{MTN_MERGE_DIFFUTILS}="partial=true";
387 $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
389 $ENV{MTN_MERGE_DIFFUTILS}="";
391 if (!defined($mergeResult)) {
392 debug("Unable to insert conflict markers!");
393 error("Your commit succeeded. Unfortunately, someone else committed something to the same ".
394 "part of the wiki at the same time. Both versions are stored in the monotone repository, ".
395 "but at present the different versions cannot be reconciled through the web interface. ".
396 "Please use the non-web interface to resolve the conflicts.");
399 if (system("mtn", "--root=$config{mtnrootdir}",
400 "update", "-r", $mergeResult) != 0) {
401 debug("Unable to update to rev $mergeResult after conflict-enhanced merge on conflicted commit!");
404 # return "conflict enhanced" file to the user
405 # for cleanup note, this relies on the fact
406 # that ikiwiki seems to call rcs_prepedit()
407 # again after we return
408 return readfile("$config{srcdir}/$file");
415 # If we reached here then the file we're looking at hasn't changed
416 # since $oldrev. Commit it.
418 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
419 "--author", $author, "--key", $config{mtnkey}, "-m",
420 IkiWiki::possibly_foolish_untaint($message), $file) != 0) {
421 debug("Traditional commit failed! Returning data as conflict.");
422 my $conflict=readfile("$config{srcdir}/$file");
423 if (system("mtn", "--root=$config{mtnrootdir}", "revert",
424 "--quiet", $file) != 0) {
425 debug("monotone revert failed");
429 if (defined($config{mtnsync}) && $config{mtnsync}) {
430 if (system("mtn", "--root=$config{mtnrootdir}", "push",
431 "--quiet", "--ticker=none", "--key",
432 $config{mtnkey}) != 0) {
433 debug("monotone push failed");
437 return undef # success
440 sub rcs_commit_staged ($$$) {
441 # Commits all staged changes. Changes can be staged using rcs_add,
442 # rcs_remove, and rcs_rename.
443 my ($message, $user, $ipaddr)=@_;
445 # Note - this will also commit any spurious changes that happen to be
446 # lying around in the working copy. There shouldn't be any, but...
448 chdir $config{srcdir}
449 or error("Cannot chdir to $config{srcdir}: $!");
454 $author="Web user: " . $user;
456 elsif (defined $ipaddr) {
457 $author="Web IP: " . $ipaddr;
460 $author="Web: Anonymous";
463 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
464 "--author", $author, "--key", $config{mtnkey}, "-m",
465 IkiWiki::possibly_foolish_untaint($message)) != 0) {
466 error("Monotone commit failed");
473 chdir $config{srcdir}
474 or error("Cannot chdir to $config{srcdir}: $!");
476 if (system("mtn", "--root=$config{mtnrootdir}", "add", "--quiet",
478 error("Monotone add failed");
485 chdir $config{srcdir}
486 or error("Cannot chdir to $config{srcdir}: $!");
488 # Note: it is difficult to undo a remove in Monotone at the moment.
489 # Until this is fixed, it might be better to make 'rm' move things
490 # into an attic, rather than actually remove them.
491 # To resurrect a file, you currently add a new file with the contents
492 # you want it to have. This loses all connectivity and automated
493 # merging with the 'pre-delete' versions of the file.
495 if (system("mtn", "--root=$config{mtnrootdir}", "rm", "--quiet",
497 error("Monotone remove failed");
501 sub rcs_rename ($$) {
502 my ($src, $dest) = @_;
504 chdir $config{srcdir}
505 or error("Cannot chdir to $config{srcdir}: $!");
507 if (system("mtn", "--root=$config{mtnrootdir}", "rename", "--quiet",
509 error("Monotone rename failed");
513 sub rcs_recentchanges ($) {
517 chdir $config{srcdir}
518 or error("Cannot chdir to $config{srcdir}: $!");
520 # use log --brief to get a list of revs, as this
521 # gives the results in a nice order
522 # (otherwise we'd have to do our own date sorting)
526 my $child = open(MTNLOG, "-|");
528 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
529 "--brief", "--last=$num") || error("mtn log failed to run");
532 while (my $line = <MTNLOG>) {
533 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}) {
566 } elsif ($cert->{name} eq "date") {
567 $when = str2time($cert->{value}, 'UTC');
568 } elsif ($cert->{name} eq "changelog") {
569 my $messageText = $cert->{value};
570 # split the changelog into multiple
572 foreach my $msgline (split(/\n/, $messageText)) {
573 push @message, { line => $msgline };
579 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 my $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],
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");