* monotone changes by Brian May:
[ikiwiki.git] / IkiWiki / Rcs / monotone.pm
1 #!/usr/bin/perl
2 use warnings;
3 use strict;
4 use IkiWiki;
5 use Monotone;
6 use Date::Parse qw(str2time);
7 use Date::Format qw(time2str);
8
9 package IkiWiki;
10
11 my $sha1_pattern = qr/[0-9a-fA-F]{40}/; # pattern to validate sha1sums
12
13 sub check_config() { #{{{
14         if (!defined($config{mtnrootdir})) {
15                 $config{mtnrootdir} = $config{srcdir};
16         }
17         if (! -d "$config{mtnrootdir}/_MTN") {
18                 error("Ikiwiki srcdir does not seem to be a Monotone workspace (or set the mtnrootdir)!");
19         }
20         
21         if (!defined($config{mtnmergerc})) {
22                 $config{mtnmergerc} = "$config{mtnrootdir}/_MTN/mergerc";
23         }
24         
25         chdir $config{srcdir}
26             or error("Cannot chdir to $config{srcdir}: $!");
27 } #}}}
28
29 sub get_rev () { #{{{
30         my $sha1 = `mtn --root=$config{mtnrootdir} automate get_base_revision_id`;
31
32         ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
33         if (! $sha1) {
34                 debug("Unable to get base revision for '$config{srcdir}'.")
35         }
36
37         return $sha1;
38 } #}}}
39
40 sub get_rev_auto ($) { #{{{
41         my $automator=shift;
42
43         my @results = $automator->call("get_base_revision_id");
44
45         my $sha1 = $results[0];
46         ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
47         if (! $sha1) {
48                 debug("Unable to get base revision for '$config{srcdir}'.")
49         }
50
51         return $sha1;
52 } #}}}
53
54 sub mtn_merge ($$$$) { #{{{
55         my $leftRev=shift;
56         my $rightRev=shift;
57         my $branch=shift;
58         my $author=shift;
59     
60         my $mergeRev;
61
62         my $mergerc = $config{mtnmergerc};
63     
64         my $child = open(MTNMERGE, "-|");
65         if (! $child) {
66                 open STDERR, ">&STDOUT";
67                 exec("mtn", "--root=$config{mtnrootdir}", "--rcfile",
68                      $mergerc, "explicit_merge", $leftRev, $rightRev,
69                      $branch, "--author", $author, "--key", 
70                      $config{mtnkey}) || error("mtn merge failed to run");
71         }
72
73         while (<MTNMERGE>) {
74                 if (/^mtn.\s.merged.\s($sha1_pattern)$/) {
75                         $mergeRev=$1;
76                 }
77         }
78         
79         close MTNMERGE || return undef;
80
81         debug("merged $leftRev, $rightRev to make $mergeRev");
82
83         return $mergeRev;
84 } #}}}
85
86 sub commit_file_to_new_rev($$$$$$$$) { #{{{
87         my $automator=shift;
88         my $wsfilename=shift;
89         my $oldFileID=shift;
90         my $newFileContents=shift;
91         my $oldrev=shift;
92         my $branch=shift;
93         my $author=shift;
94         my $message=shift;
95         
96         #store the file
97         my ($out, $err) = $automator->call("put_file", $oldFileID, $newFileContents);
98         my ($newFileID) = ($out =~ m/^($sha1_pattern)$/);
99         error("Failed to store file data for $wsfilename in repository")
100                 if (! defined $newFileID || length $newFileID != 40);
101
102         # get the mtn filename rather than the workspace filename
103         ($out, $err) = $automator->call("get_corresponding_path", $oldrev, $wsfilename, $oldrev);
104         my ($filename) = ($out =~ m/^file "(.*)"$/);
105         error("Couldn't find monotone repository path for file $wsfilename") if (! $filename);
106         debug("Converted ws filename of $wsfilename to repos filename of $filename");
107
108         # then stick in a new revision for this file
109         my $manifest = "format_version \"1\"\n\n".
110                        "new_manifest [0000000000000000000000000000000000000000]\n\n".
111                        "old_revision [$oldrev]\n\n".
112                        "patch \"$filename\"\n".
113                        " from [$oldFileID]\n".
114                        "   to [$newFileID]\n";
115         ($out, $err) = $automator->call("put_revision", $manifest);
116         my ($newRevID) = ($out =~ m/^($sha1_pattern)$/);
117         error("Unable to make new monotone repository revision")
118                 if (! defined $newRevID || length $newRevID != 40);
119         debug("put revision: $newRevID");
120         
121         # now we need to add certs for this revision...
122         # author, branch, changelog, date
123         $automator->call("cert", $newRevID, "author", $author);
124         $automator->call("cert", $newRevID, "branch", $branch);
125         $automator->call("cert", $newRevID, "changelog", $message);
126         $automator->call("cert", $newRevID, "date",
127                 time2str("%Y-%m-%dT%T", time, "UTC"));
128         
129         debug("Added certs for rev: $newRevID");
130         return $newRevID;
131 } #}}}
132
133 sub check_mergerc () { #{{{
134         my $mergerc = $config{mtnmergerc};
135         if (! -r $mergerc ) {
136                 debug("$mergerc doesn't exist. Creating file with default mergers.");
137                 open (my $out, ">", $mergerc) or error("can't open $mergerc: $!");
138                 print $out <DATA>;
139                 close $out;
140         }
141 } #}}}
142
143 sub read_certs ($$) { #{{{
144         my $automator=shift;
145         my $rev=shift;
146         my @results = $automator->call("certs", $rev);
147         my @ret;
148
149         my $line = $results[0];
150         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) {
151                 push @ret, {
152                         key => $1,
153                         signature => $2,
154                         name => $3,
155                         value => $4,
156                         trust => $5,
157                 };
158         }
159
160         return @ret;
161 } #}}}
162
163 sub get_changed_files ($$) { #{{{
164         my $automator=shift;
165         my $rev=shift;
166         
167         my @results = $automator->call("get_revision", $rev);
168         my $changes=$results[0];
169
170         my @ret;
171         my %seen = ();
172         
173         while ($changes =~ m/\s*(add_file|patch|delete|rename)\s"(.*?)(?<!\\)"\n/sg) {
174                 my $file = $2;
175                 # don't add the same file multiple times
176                 if (! $seen{$file}) {
177                         push @ret, $file;
178                         $seen{$file} = 1;
179                 }
180         }
181         
182         return @ret;
183 } #}}}
184
185 sub rcs_update () { #{{{
186         check_config();
187
188         if (defined($config{mtnsync}) && $config{mtnsync}) {
189                 if (system("mtn", "--root=$config{mtnrootdir}", "sync",
190                            "--quiet", "--ticker=none", 
191                            "--key", $config{mtnkey}) != 0) {
192                         debug("monotone sync failed before update");
193                 }
194         }
195
196         if (system("mtn", "--root=$config{mtnrootdir}", "update", "--quiet") != 0) {
197                 debug("monotone update failed");
198         }
199 } #}}}
200
201 sub rcs_prepedit ($) { #{{{
202         my $file=shift;
203
204         check_config();
205
206         # For monotone, return the revision of the file when
207         # editing begins.
208         return get_rev();
209 } #}}}
210
211 sub rcs_commit ($$$;$$) { #{{{
212         # Tries to commit the page; returns undef on _success_ and
213         # a version of the page with the rcs's conflict markers on failure.
214         # The file is relative to the srcdir.
215         my $file=shift;
216         my $message=shift;
217         my $rcstoken=shift;
218         my $user=shift;
219         my $ipaddr=shift;
220         my $author;
221
222         if (defined $user) {
223                 $author="Web user: " . $user;
224         }
225         elsif (defined $ipaddr) {
226                 $author="Web IP: " . $ipaddr;
227         }
228         else {
229                 $author="Web: Anonymous";
230         }
231
232         check_config();
233
234         my ($oldrev)= $rcstoken=~ m/^($sha1_pattern)$/; # untaint
235         my $rev = get_rev();
236         if (defined $rev && defined $oldrev && $rev ne $oldrev) {
237                 my $automator = Monotone->new();
238                 $automator->open_args("--root", $config{mtnrootdir}, "--key", $config{mtnkey});
239
240                 # Something has been committed, has this file changed?
241                 my ($out, $err);
242                 $automator->setOpts("r", $oldrev, "r", $rev);
243                 ($out, $err) = $automator->call("content_diff", $file);
244                 debug("Problem committing $file") if ($err ne "");
245                 my $diff = $out;
246                 
247                 if ($diff) {
248                         # Commit a revision with just this file changed off
249                         # the old revision.
250                         #
251                         # first get the contents
252                         debug("File changed: forming branch");
253                         my $newfile=readfile("$config{srcdir}/$file");
254                         
255                         # then get the old content ID from the diff
256                         if ($diff !~ m/^---\s$file\s+($sha1_pattern)$/m) {
257                                 error("Unable to find previous file ID for $file");
258                         }
259                         my $oldFileID = $1;
260
261                         # get the branch we're working in
262                         ($out, $err) = $automator->call("get_option", "branch");
263                         chomp $out;
264                         error("Illegal branch name in monotone workspace") if ($out !~ m/^([-\@\w\.]+)$/);
265                         my $branch = $1;
266
267                         # then put the new content into the DB (and record the new content ID)
268                         my $newRevID = commit_file_to_new_rev($automator, $file, $oldFileID, $newfile, $oldrev, $branch, $author, $message);
269
270                         $automator->close();
271
272                         # if we made it to here then the file has been committed... revert the local copy
273                         if (system("mtn", "--root=$config{mtnrootdir}", "revert", $file) != 0) {
274                                 debug("Unable to revert $file after merge on conflicted commit!");
275                         }
276                         debug("Divergence created! Attempting auto-merge.");
277
278                         check_mergerc();
279
280                         # see if it will merge cleanly
281                         $ENV{MTN_MERGE}="fail";
282                         my $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
283                         $ENV{MTN_MERGE}="";
284
285                         # push any changes so far
286                         if (defined($config{mtnsync}) && $config{mtnsync}) {
287                                 if (system("mtn", "--root=$config{mtnrootdir}", "push", "--quiet", "--ticker=none", "--key", $config{mtnkey}) != 0) {
288                                         debug("monotone push failed");
289                                 }
290                         }
291                         
292                         if (defined($mergeResult)) {
293                                 # everything is merged - bring outselves up to date
294                                 if (system("mtn", "--root=$config{mtnrootdir}",
295                                            "update", "-r", $mergeResult) != 0) {
296                                         debug("Unable to update to rev $mergeResult after merge on conflicted commit!");
297                                 }
298                         }
299                         else {
300                                 debug("Auto-merge failed.  Using diff-merge to add conflict markers.");
301                                 
302                                 $ENV{MTN_MERGE}="diffutils_force";
303                                 $mergeResult = mtn_merge($newRevID, $rev, $branch, $author);
304                                 $ENV{MTN_MERGE}="";
305                                 
306                                 if (!defined($mergeResult)) {
307                                         debug("Unable to insert conflict markers!");
308                                         error("Your commit succeeded. Unfortunately, someone else committed something to the same ".
309                                                 "part of the wiki at the same time. Both versions are stored in the monotone repository, ".
310                                                 "but at present the different versions cannot be reconciled through the web interface. ".
311                                                 "Please use the non-web interface to resolve the conflicts.");
312                                 }
313                                 
314                                 if (system("mtn", "--root=$config{mtnrootdir}",
315                                            "update", "-r", $mergeResult) != 0) {
316                                         debug("Unable to update to rev $mergeResult after conflict-enhanced merge on conflicted commit!");
317                                 }
318                                 
319                                 # return "conflict enhanced" file to the user
320                                 # for cleanup note, this relies on the fact
321                                 # that ikiwiki seems to call rcs_prepedit()
322                                 # again after we return
323                                 return readfile("$config{srcdir}/$file");
324                         }
325                         return undef;
326                 }
327                 $automator->close();
328         }
329
330         # If we reached here then the file we're looking at hasn't changed
331         # since $oldrev. Commit it.
332
333         if (system("mtn", "--root=$config{mtnrootdir}", "commit", "--quiet",
334                    "--author", $author, "--key", $config{mtnkey}, "-m",
335                    possibly_foolish_untaint($message), $file) != 0) {
336                 debug("Traditional commit failed! Returning data as conflict.");
337                 my $conflict=readfile("$config{srcdir}/$file");
338                 if (system("mtn", "--root=$config{mtnrootdir}", "revert",
339                            "--quiet", $file) != 0) {
340                         debug("monotone revert failed");
341                 }
342                 return $conflict;
343         }
344         if (defined($config{mtnsync}) && $config{mtnsync}) {
345                 if (system("mtn", "--root=$config{mtnrootdir}", "push",
346                            "--quiet", "--ticker=none", "--key",
347                            $config{mtnkey}) != 0) {
348                         debug("monotone push failed");
349                 }
350         }
351
352         return undef # success
353 } #}}}
354
355 sub rcs_add ($) { #{{{
356         my $file=shift;
357
358         check_config();
359
360         if (system("mtn", "--root=$config{mtnrootdir}", "add", "--quiet",
361                    $file) != 0) {
362                 error("Monotone add failed");
363         }
364 } #}}}
365
366 sub rcs_recentchanges ($) { #{{{
367         my $num=shift;
368         my @ret;
369
370         check_config();
371
372         # use log --brief to get a list of revs, as this
373         # gives the results in a nice order
374         # (otherwise we'd have to do our own date sorting)
375
376         my @revs;
377
378         my $child = open(MTNLOG, "-|");
379         if (! $child) {
380                 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
381                      "--brief") || error("mtn log failed to run");
382         }
383
384         while (($num >= 0) and (my $line = <MTNLOG>)) {
385                 if ($line =~ m/^($sha1_pattern)/) {
386                         push @revs, $1;
387                         $num -= 1;
388                 }
389         }
390         close MTNLOG || debug("mtn log exited $?");
391
392         my $automator = Monotone->new();
393         $automator->open(undef, $config{mtnrootdir});
394
395         while (@revs != 0) {
396                 my $rev = shift @revs;
397                 # first go through and figure out the messages, etc
398
399                 my $certs = [read_certs($automator, $rev)];
400                 
401                 my $user;
402                 my $when;
403                 my $committype;
404                 my (@pages, @message);
405                 
406                 foreach my $cert (@$certs) {
407                         if ($cert->{signature} eq "ok" &&
408                             $cert->{trust} eq "trusted") {
409                                 if ($cert->{name} eq "author") {
410                                         $user = $cert->{value};
411                                         # detect the source of the commit
412                                         # from the changelog
413                                         if ($cert->{key} eq $config{mtnkey}) {
414                                                 $committype = "web";
415                                         } else {
416                                                 $committype = "monotone";
417                                         }
418                                 } elsif ($cert->{name} eq "date") {
419                                         $when = str2time($cert->{value}, 'UTC');
420                                 } elsif ($cert->{name} eq "changelog") {
421                                         my $messageText = $cert->{value};
422                                         # split the changelog into multiple
423                                         # lines
424                                         foreach my $msgline (split(/\n/, $messageText)) {
425                                                 push @message, { line => $msgline };
426                                         }
427                                 }
428                         }
429                 }
430                 
431                 my @changed_files = get_changed_files($automator, $rev);
432                 my $file;
433                 
434                 my ($out, $err) = $automator->call("parents", $rev);
435                 my @parents = ($out =~ m/^($sha1_pattern)$/);
436                 my $parent = $parents[0];
437
438                 foreach $file (@changed_files) {
439                         next unless length $file;
440                         
441                         if (defined $config{diffurl} and (@parents == 1)) {
442                                 my $diffurl=$config{diffurl};
443                                 $diffurl=~s/\[\[r1\]\]/$parent/g;
444                                 $diffurl=~s/\[\[r2\]\]/$rev/g;
445                                 $diffurl=~s/\[\[file\]\]/$file/g;
446                                 push @pages, {
447                                         page => pagename($file),
448                                         diffurl => $diffurl,
449                                 };
450                         }
451                         else {
452                                 push @pages, {
453                                         page => pagename($file),
454                                 }
455                         }
456                 }
457                 
458                 push @ret, {
459                         rev => $rev,
460                         user => $user,
461                         committype => $committype,
462                         when => $when,
463                         message => [@message],
464                         pages => [@pages],
465                 } if @pages;
466         }
467
468         $automator->close();
469
470         return @ret;
471 } #}}}
472
473 sub rcs_getctime ($) { #{{{
474         my $file=shift;
475
476         check_config();
477
478         my $child = open(MTNLOG, "-|");
479         if (! $child) {
480                 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
481                      "--brief", $file) || error("mtn log $file failed to run");
482         }
483
484         my $firstRev;
485         while (<MTNLOG>) {
486                 if (/^($sha1_pattern)/) {
487                         $firstRev=$1;
488                 }
489         }
490         close MTNLOG || debug("mtn log $file exited $?");
491
492         if (! defined $firstRev) {
493                 debug "failed to parse mtn log for $file";
494                 return 0;
495         }
496
497         my $automator = Monotone->new();
498         $automator->open(undef, $config{mtnrootdir});
499
500         my $certs = [read_certs($automator, $firstRev)];
501
502         $automator->close();
503
504         my $date;
505
506         foreach my $cert (@$certs) {
507                 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
508                         if ($cert->{name} eq "date") {
509                                 $date = $cert->{value};
510                         }
511                 }
512         }
513
514         if (! defined $date) {
515                 debug "failed to find date cert for revision $firstRev when looking for creation time of $file";
516                 return 0;
517         }
518
519         $date=str2time($date, 'UTC');
520         debug("found ctime ".localtime($date)." for $file");
521         return $date;
522 } #}}}
523
524 1
525
526 # default mergerc content
527 __DATA__
528         function local_execute_redirected(stdin, stdout, stderr, path, ...)
529            local pid
530            local ret = -1
531            io.flush();
532            pid = spawn_redirected(stdin, stdout, stderr, path, unpack(arg))
533            if (pid ~= -1) then ret, pid = wait(pid) end
534            return ret
535         end
536         if (not execute_redirected) then -- use standard function if available
537            execute_redirected = local_execute_redirected
538         end
539         if (not mergers.fail) then -- use standard merger if available
540            mergers.fail = {
541               cmd = function (tbl) return false end,
542               available = function () return true end,
543               wanted = function () return true end
544            }
545         end
546         mergers.diffutils_force = {
547            cmd = function (tbl)
548               local ret = execute_redirected(
549                   "",
550                   tbl.outfile,
551                   "",
552                   "diff3",
553                   "--merge",
554                   "--show-overlap",
555                   "--label", string.format("[Yours]",     tbl.left_path ),
556                   "--label", string.format("[Original]",  tbl.anc_path  ),
557                   "--label", string.format("[Theirs]",    tbl.right_path),
558                   tbl.lfile,
559                   tbl.afile,
560                   tbl.rfile
561               )
562               if (ret > 1) then
563                  io.write(gettext("Error running GNU diffutils 3-way difference tool 'diff3'"))
564                  return false
565               end
566               return tbl.outfile
567            end,
568            available =
569               function ()
570                   return program_exists_in_path("diff3");
571               end,
572            wanted =
573               function ()
574                    return true
575               end
576         }
577 EOF