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 () { #{{{
70 example => "/srv/mtn/wiki/_MTN/ikiwiki-netsync-hook",
71 description => "monotone netsync hook to generate",
78 description => "mode for mtn_wrapper (can safely be made suid)",
84 example => 'web@example.com',
85 description => "your monotone key",
91 example => "http://viewmtn.example.com/branch/head/filechanges/com.example.branch/[[file]]",
92 description => "viewmtn url to show file history ([[file]] substituted)",
98 example => "http://viewmtn.example.com/revision/diff/[[r1]]/with/[[r2]]/[[file]]",
99 description => "viewmtn url to show a diff ([[r1]], [[r2]], and [[file]] substituted)",
106 description => "sync on update and commit?",
107 safe => 0, # paranoia
112 description => "path to your workspace (defaults to the srcdir; specify if the srcdir is a subdirectory of the workspace)",
118 sub get_rev () { #{{{
119 my $sha1 = `mtn --root=$config{mtnrootdir} automate get_base_revision_id`;
121 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
123 debug("Unable to get base revision for '$config{srcdir}'.")
129 sub get_rev_auto ($) { #{{{
132 my @results = $automator->call("get_base_revision_id");
134 my $sha1 = $results[0];
135 ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
137 debug("Unable to get base revision for '$config{srcdir}'.")
143 sub mtn_merge ($$$$) { #{{{
151 my $child = open(MTNMERGE, "-|");
153 open STDERR, ">&STDOUT";
154 exec("mtn", "--root=$config{mtnrootdir}",
155 "explicit_merge", $leftRev, $rightRev,
156 $branch, "--author", $author, "--key",
157 $config{mtnkey}) || error("mtn merge failed to run");
161 if (/^mtn.\s.merged.\s($sha1_pattern)$/) {
166 close MTNMERGE || return undef;
168 debug("merged $leftRev, $rightRev to make $mergeRev");
173 sub commit_file_to_new_rev ($$$$$$$$) { #{{{
175 my $wsfilename=shift;
177 my $newFileContents=shift;
184 my ($out, $err) = $automator->call("put_file", $oldFileID, $newFileContents);
185 my ($newFileID) = ($out =~ m/^($sha1_pattern)$/);
186 error("Failed to store file data for $wsfilename in repository")
187 if (! defined $newFileID || length $newFileID != 40);
189 # get the mtn filename rather than the workspace filename
190 ($out, $err) = $automator->call("get_corresponding_path", $oldrev, $wsfilename, $oldrev);
191 my ($filename) = ($out =~ m/^file "(.*)"$/);
192 error("Couldn't find monotone repository path for file $wsfilename") if (! $filename);
193 debug("Converted ws filename of $wsfilename to repos filename of $filename");
195 # then stick in a new revision for this file
196 my $manifest = "format_version \"1\"\n\n".
197 "new_manifest [0000000000000000000000000000000000000000]\n\n".
198 "old_revision [$oldrev]\n\n".
199 "patch \"$filename\"\n".
200 " from [$oldFileID]\n".
201 " to [$newFileID]\n";
202 ($out, $err) = $automator->call("put_revision", $manifest);
203 my ($newRevID) = ($out =~ m/^($sha1_pattern)$/);
204 error("Unable to make new monotone repository revision")
205 if (! defined $newRevID || length $newRevID != 40);
206 debug("put revision: $newRevID");
208 # now we need to add certs for this revision...
209 # author, branch, changelog, date
210 $automator->call("cert", $newRevID, "author", $author);
211 $automator->call("cert", $newRevID, "branch", $branch);
212 $automator->call("cert", $newRevID, "changelog", $message);
213 $automator->call("cert", $newRevID, "date",
214 time2str("%Y-%m-%dT%T", time, "UTC"));
216 debug("Added certs for rev: $newRevID");
220 sub read_certs ($$) { #{{{
223 my @results = $automator->call("certs", $rev);
226 my $line = $results[0];
227 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) {
240 sub get_changed_files ($$) { #{{{
244 my @results = $automator->call("get_revision", $rev);
245 my $changes=$results[0];
250 while ($changes =~ m/\s*(add_file|patch|delete|rename)\s"(.*?)(?<!\\)"\n/sg) {
252 # don't add the same file multiple times
253 if (! $seen{$file}) {
262 sub rcs_update () { #{{{
263 chdir $config{srcdir}
264 or error("Cannot chdir to $config{srcdir}: $!");
266 if (defined($config{mtnsync}) && $config{mtnsync}) {
267 if (system("mtn", "--root=$config{mtnrootdir}", "sync",
268 "--quiet", "--ticker=none",
269 "--key", $config{mtnkey}) != 0) {
270 debug("monotone sync failed before update");
274 if (system("mtn", "--root=$config{mtnrootdir}", "update", "--quiet") != 0) {
275 debug("monotone update failed");
279 sub rcs_prepedit ($) { #{{{
282 chdir $config{srcdir}
283 or error("Cannot chdir to $config{srcdir}: $!");
285 # For monotone, return the revision of the file when
290 sub rcs_commit ($$$;$$) { #{{{
291 # Tries to commit the page; returns undef on _success_ and
292 # a version of the page with the rcs's conflict markers on failure.
293 # The file is relative to the srcdir.
302 $author="Web user: " . $user;
304 elsif (defined $ipaddr) {
305 $author="Web IP: " . $ipaddr;
308 $author="Web: Anonymous";
311 chdir $config{srcdir}
312 or error("Cannot chdir to $config{srcdir}: $!");
314 my ($oldrev)= $rcstoken=~ m/^($sha1_pattern)$/; # untaint
316 if (defined $rev && defined $oldrev && $rev ne $oldrev) {
317 my $automator = Monotone->new();
318 $automator->open_args("--root", $config{mtnrootdir}, "--key", $config{mtnkey});
320 # Something has been committed, has this file changed?
322 $automator->setOpts("r", $oldrev, "r", $rev);
323 ($out, $err) = $automator->call("content_diff", $file);
324 debug("Problem committing $file") if ($err ne "");
328 # Commit a revision with just this file changed off
331 # first get the contents
332 debug("File changed: forming branch");
333 my $newfile=readfile("$config{srcdir}/$file");
335 # then get the old content ID from the diff
336 if ($diff !~ m/^---\s$file\s+($sha1_pattern)$/m) {
337 error("Unable to find previous file ID for $file");
341 # get the branch we're working in
342 ($out, $err) = $automator->call("get_option", "branch");
344 error("Illegal branch name in monotone workspace") if ($out !~ m/^([-\@\w\.]+)$/);
347 # then put the new content into the DB (and record the new content ID)
348 my $newRevID = commit_file_to_new_rev($automator, $file, $oldFileID, $newfile, $oldrev, $branch, $author, $message);
352 # if we made it to here then the file has been committed... revert the local copy
353 if (system("mtn", "--root=$config{mtnrootdir}", "revert", $file) != 0) {
354 debug("Unable to revert $file after merge on conflicted commit!");
356 debug("Divergence created! Attempting auto-merge.");
358 # see if it will merge cleanly
359 $ENV{MTN_MERGE}="fail";
360 my $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
363 # push any changes so far
364 if (defined($config{mtnsync}) && $config{mtnsync}) {
365 if (system("mtn", "--root=$config{mtnrootdir}", "push", "--quiet", "--ticker=none", "--key", $config{mtnkey}) != 0) {
366 debug("monotone push failed");
370 if (defined($mergeResult)) {
371 # everything is merged - bring outselves up to date
372 if (system("mtn", "--root=$config{mtnrootdir}",
373 "update", "-r", $mergeResult) != 0) {
374 debug("Unable to update to rev $mergeResult after merge on conflicted commit!");
378 debug("Auto-merge failed. Using diff-merge to add conflict markers.");
380 $ENV{MTN_MERGE}="diffutils";
381 $ENV{MTN_MERGE_DIFFUTILS}="partial=true";
382 $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
384 $ENV{MTN_MERGE_DIFFUTILS}="";
386 if (!defined($mergeResult)) {
387 debug("Unable to insert conflict markers!");
388 error("Your commit succeeded. Unfortunately, someone else committed something to the same ".
389 "part of the wiki at the same time. Both versions are stored in the monotone repository, ".
390 "but at present the different versions cannot be reconciled through the web interface. ".
391 "Please use the non-web interface to resolve the conflicts.");
394 if (system("mtn", "--root=$config{mtnrootdir}",
395 "update", "-r", $mergeResult) != 0) {
396 debug("Unable to update to rev $mergeResult after conflict-enhanced merge on conflicted commit!");
399 # return "conflict enhanced" file to the user
400 # for cleanup note, this relies on the fact
401 # that ikiwiki seems to call rcs_prepedit()
402 # again after we return
403 return readfile("$config{srcdir}/$file");
410 # If we reached here then the file we're looking at hasn't changed
411 # since $oldrev. Commit it.
413 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
414 "--author", $author, "--key", $config{mtnkey}, "-m",
415 IkiWiki::possibly_foolish_untaint($message), $file) != 0) {
416 debug("Traditional commit failed! Returning data as conflict.");
417 my $conflict=readfile("$config{srcdir}/$file");
418 if (system("mtn", "--root=$config{mtnrootdir}", "revert",
419 "--quiet", $file) != 0) {
420 debug("monotone revert failed");
424 if (defined($config{mtnsync}) && $config{mtnsync}) {
425 if (system("mtn", "--root=$config{mtnrootdir}", "push",
426 "--quiet", "--ticker=none", "--key",
427 $config{mtnkey}) != 0) {
428 debug("monotone push failed");
432 return undef # success
435 sub rcs_commit_staged ($$$) {
436 # Commits all staged changes. Changes can be staged using rcs_add,
437 # rcs_remove, and rcs_rename.
438 my ($message, $user, $ipaddr)=@_;
440 # Note - this will also commit any spurious changes that happen to be
441 # lying around in the working copy. There shouldn't be any, but...
443 chdir $config{srcdir}
444 or error("Cannot chdir to $config{srcdir}: $!");
449 $author="Web user: " . $user;
451 elsif (defined $ipaddr) {
452 $author="Web IP: " . $ipaddr;
455 $author="Web: Anonymous";
458 if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
459 "--author", $author, "--key", $config{mtnkey}, "-m",
460 IkiWiki::possibly_foolish_untaint($message)) != 0) {
461 error("Monotone commit failed");
465 sub rcs_add ($) { #{{{
468 chdir $config{srcdir}
469 or error("Cannot chdir to $config{srcdir}: $!");
471 if (system("mtn", "--root=$config{mtnrootdir}", "add", "--quiet",
473 error("Monotone add failed");
477 sub rcs_remove ($) { # {{{
480 chdir $config{srcdir}
481 or error("Cannot chdir to $config{srcdir}: $!");
483 # Note: it is difficult to undo a remove in Monotone at the moment.
484 # Until this is fixed, it might be better to make 'rm' move things
485 # into an attic, rather than actually remove them.
486 # To resurrect a file, you currently add a new file with the contents
487 # you want it to have. This loses all connectivity and automated
488 # merging with the 'pre-delete' versions of the file.
490 if (system("mtn", "--root=$config{mtnrootdir}", "rm", "--quiet",
492 error("Monotone remove failed");
496 sub rcs_rename ($$) { # {{{
497 my ($src, $dest) = @_;
499 chdir $config{srcdir}
500 or error("Cannot chdir to $config{srcdir}: $!");
502 if (system("mtn", "--root=$config{mtnrootdir}", "rename", "--quiet",
504 error("Monotone rename failed");
508 sub rcs_recentchanges ($) { #{{{
512 chdir $config{srcdir}
513 or error("Cannot chdir to $config{srcdir}: $!");
515 # use log --brief to get a list of revs, as this
516 # gives the results in a nice order
517 # (otherwise we'd have to do our own date sorting)
521 my $child = open(MTNLOG, "-|");
523 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
524 "--brief") || error("mtn log failed to run");
527 while (($num >= 0) and (my $line = <MTNLOG>)) {
528 if ($line =~ m/^($sha1_pattern)/) {
533 close MTNLOG || debug("mtn log exited $?");
535 my $automator = Monotone->new();
536 $automator->open(undef, $config{mtnrootdir});
539 my $rev = shift @revs;
540 # first go through and figure out the messages, etc
542 my $certs = [read_certs($automator, $rev)];
547 my (@pages, @message);
549 foreach my $cert (@$certs) {
550 if ($cert->{signature} eq "ok" &&
551 $cert->{trust} eq "trusted") {
552 if ($cert->{name} eq "author") {
553 $user = $cert->{value};
554 # detect the source of the commit
556 if ($cert->{key} eq $config{mtnkey}) {
559 $committype = "monotone";
561 } elsif ($cert->{name} eq "date") {
562 $when = str2time($cert->{value}, 'UTC');
563 } elsif ($cert->{name} eq "changelog") {
564 my $messageText = $cert->{value};
565 # split the changelog into multiple
567 foreach my $msgline (split(/\n/, $messageText)) {
568 push @message, { line => $msgline };
574 my @changed_files = get_changed_files($automator, $rev);
577 my ($out, $err) = $automator->call("parents", $rev);
578 my @parents = ($out =~ m/^($sha1_pattern)$/);
579 my $parent = $parents[0];
581 foreach $file (@changed_files) {
582 next unless length $file;
584 if (defined $config{diffurl} and (@parents == 1)) {
585 my $diffurl=$config{diffurl};
586 $diffurl=~s/\[\[r1\]\]/$parent/g;
587 $diffurl=~s/\[\[r2\]\]/$rev/g;
588 $diffurl=~s/\[\[file\]\]/$file/g;
590 page => pagename($file),
596 page => pagename($file),
604 committype => $committype,
606 message => [@message],
616 sub rcs_diff ($) { #{{{
618 my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
620 chdir $config{srcdir}
621 or error("Cannot chdir to $config{srcdir}: $!");
623 my $child = open(MTNDIFF, "-|");
625 exec("mtn", "diff", "--root=$config{mtnrootdir}", "-r", "p:".$sha1, "-r", $sha1) || error("mtn diff $sha1 failed to run");
628 my (@lines) = <MTNDIFF>;
630 close MTNDIFF || debug("mtn diff $sha1 exited $?");
636 return join("", @lines);
640 sub rcs_getctime ($) { #{{{
643 chdir $config{srcdir}
644 or error("Cannot chdir to $config{srcdir}: $!");
646 my $child = open(MTNLOG, "-|");
648 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
649 "--brief", $file) || error("mtn log $file failed to run");
654 if (/^($sha1_pattern)/) {
658 close MTNLOG || debug("mtn log $file exited $?");
660 if (! defined $firstRev) {
661 debug "failed to parse mtn log for $file";
665 my $automator = Monotone->new();
666 $automator->open(undef, $config{mtnrootdir});
668 my $certs = [read_certs($automator, $firstRev)];
674 foreach my $cert (@$certs) {
675 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
676 if ($cert->{name} eq "date") {
677 $date = $cert->{value};
682 if (! defined $date) {
683 debug "failed to find date cert for revision $firstRev when looking for creation time of $file";
687 $date=str2time($date, 'UTC');
688 debug("found ctime ".localtime($date)." for $file");