0ae2c1a3268e26489721de080078e07ab0bacbb5
[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}", "sync",
346                            "--quiet", "--ticker=none", "--key",
347                            $config{mtnkey}) != 0) {
348                         debug("monotone sync 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                 foreach $file (@changed_files) {
435                         push @pages, {
436                                 page => pagename($file),
437                         } if length $file;
438                 }
439                 
440                 push @ret, {
441                         rev => $rev,
442                         user => $user,
443                         committype => $committype,
444                         when => $when,
445                         message => [@message],
446                         pages => [@pages],
447                 } if @pages;
448         }
449
450         $automator->close();
451
452         return @ret;
453 } #}}}
454
455 sub rcs_getctime ($) { #{{{
456         my $file=shift;
457
458         check_config();
459
460         my $child = open(MTNLOG, "-|");
461         if (! $child) {
462                 exec("mtn", "log", "--root=$config{mtnrootdir}", "--no-graph",
463                      "--brief", $file) || error("mtn log $file failed to run");
464         }
465
466         my $firstRev;
467         while (<MTNLOG>) {
468                 if (/^($sha1_pattern)/) {
469                         $firstRev=$1;
470                 }
471         }
472         close MTNLOG || debug("mtn log $file exited $?");
473
474         if (! defined $firstRev) {
475                 debug "failed to parse mtn log for $file";
476                 return 0;
477         }
478
479         my $automator = Monotone->new();
480         $automator->open(undef, $config{mtnrootdir});
481
482         my $certs = [read_certs($automator, $firstRev)];
483
484         $automator->close();
485
486         my $date;
487
488         foreach my $cert (@$certs) {
489                 if ($cert->{signature} eq "ok" && $cert->{trust} eq "trusted") {
490                         if ($cert->{name} eq "date") {
491                                 $date = $cert->{value};
492                         }
493                 }
494         }
495
496         if (! defined $date) {
497                 debug "failed to find date cert for revision $firstRev when looking for creation time of $file";
498                 return 0;
499         }
500
501         $date=str2time($date, 'UTC');
502         debug("found ctime ".localtime($date)." for $file");
503         return $date;
504 } #}}}
505
506 1
507
508 # default mergerc content
509 __DATA__
510         function local_execute_redirected(stdin, stdout, stderr, path, ...)
511            local pid
512            local ret = -1
513            io.flush();
514            pid = spawn_redirected(stdin, stdout, stderr, path, unpack(arg))
515            if (pid ~= -1) then ret, pid = wait(pid) end
516            return ret
517         end
518         if (not execute_redirected) then -- use standard function if available
519            execute_redirected = local_execute_redirected
520         end
521         if (not mergers.fail) then -- use standard merger if available
522            mergers.fail = {
523               cmd = function (tbl) return false end,
524               available = function () return true end,
525               wanted = function () return true end
526            }
527         end
528         mergers.diffutils_force = {
529            cmd = function (tbl)
530               local ret = execute_redirected(
531                   "",
532                   tbl.outfile,
533                   "",
534                   "diff3",
535                   "--merge",
536                   "--show-overlap",
537                   "--label", string.format("[Yours]",     tbl.left_path ),
538                   "--label", string.format("[Original]",  tbl.anc_path  ),
539                   "--label", string.format("[Theirs]",    tbl.right_path),
540                   tbl.lfile,
541                   tbl.afile,
542                   tbl.rfile
543               )
544               if (ret > 1) then
545                  io.write(gettext("Error running GNU diffutils 3-way difference tool 'diff3'"))
546                  return false
547               end
548               return tbl.outfile
549            end,
550            available =
551               function ()
552                   return program_exists_in_path("diff3");
553               end,
554            wanted =
555               function ()
556                    return true
557               end
558         }
559 EOF