summaryrefslogtreecommitdiff
path: root/IkiWiki/Plugin/cvs.pm
blob: 4735c013810c0d44a9b51cdc1d5e0ba08ec9fedb (plain)
  1. #!/usr/pkg/bin/perl
  2. package IkiWiki::Plugin::cvs;
  3. use warnings;
  4. use strict;
  5. use IkiWiki;
  6. sub import {
  7. hook(type => "checkconfig", id => "cvs", call => \&checkconfig);
  8. hook(type => "getsetup", id => "cvs", call => \&getsetup);
  9. hook(type => "rcs", id => "rcs_update", call => \&rcs_update);
  10. hook(type => "rcs", id => "rcs_prepedit", call => \&rcs_prepedit);
  11. hook(type => "rcs", id => "rcs_commit", call => \&rcs_commit);
  12. hook(type => "rcs", id => "rcs_commit_staged", call => \&rcs_commit_staged);
  13. hook(type => "rcs", id => "rcs_add", call => \&rcs_add);
  14. hook(type => "rcs", id => "rcs_remove", call => \&rcs_remove);
  15. hook(type => "rcs", id => "rcs_rename", call => \&rcs_rename);
  16. hook(type => "rcs", id => "rcs_recentchanges", call => \&rcs_recentchanges);
  17. hook(type => "rcs", id => "rcs_diff", call => \&rcs_diff);
  18. hook(type => "rcs", id => "rcs_getctime", call => \&rcs_getctime);
  19. }
  20. sub checkconfig () {
  21. if (! defined $config{cvspath}) {
  22. $config{cvspath}="ikiwiki";
  23. }
  24. if (exists $config{cvspath}) {
  25. # code depends on the path not having extraneous slashes
  26. $config{cvspath}=~tr#/#/#s;
  27. $config{cvspath}=~s/\/$//;
  28. $config{cvspath}=~s/^\///;
  29. }
  30. if (defined $config{cvs_wrapper} && length $config{cvs_wrapper}) {
  31. push @{$config{wrappers}}, {
  32. wrapper => $config{cvs_wrapper},
  33. wrappermode => (defined $config{cvs_wrappermode} ? $config{cvs_wrappermode} : "04755"),
  34. };
  35. }
  36. }
  37. sub getsetup () {
  38. return
  39. plugin => {
  40. safe => 0, # rcs plugin
  41. rebuild => undef,
  42. },
  43. cvsrepo => {
  44. type => "string",
  45. example => "/cvs/wikirepo",
  46. description => "cvs repository location",
  47. safe => 0, # path
  48. rebuild => 0,
  49. },
  50. cvspath => {
  51. type => "string",
  52. example => "ikiwiki",
  53. description => "path inside repository where the wiki is located",
  54. safe => 0, # paranoia
  55. rebuild => 0,
  56. },
  57. cvs_wrapper => {
  58. type => "string",
  59. example => "/cvs/wikirepo/CVSROOT/post-commit",
  60. description => "cvs post-commit hook to generate (triggered by CVSROOT/loginfo entry",
  61. safe => 0, # file
  62. rebuild => 0,
  63. },
  64. cvs_wrappermode => {
  65. type => "string",
  66. example => '04755',
  67. description => "mode for cvs_wrapper (can safely be made suid)",
  68. safe => 0,
  69. rebuild => 0,
  70. },
  71. historyurl => {
  72. type => "string",
  73. example => "http://cvs.example.org/cvsweb.cgi/ikiwiki/[[file]]",
  74. description => "cvsweb url to show file history ([[file]] substituted)",
  75. safe => 1,
  76. rebuild => 1,
  77. },
  78. diffurl => {
  79. type => "string",
  80. example => "http://cvs.example.org/cvsweb.cgi/ikiwiki/[[file]].diff?r1=text&tr1=[[r1]]&r2=text&tr2=[[r2]]",
  81. description => "cvsweb url to show a diff ([[file]], [[r1]], and [[r2]] substituted)",
  82. safe => 1,
  83. rebuild => 1,
  84. },
  85. }
  86. sub cvs_info ($$) {
  87. my $field=shift;
  88. my $file=shift;
  89. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  90. my $info=`cvs status $file`;
  91. my ($ret)=$info=~/^\s*$field:\s*(\S+)/m;
  92. return $ret;
  93. }
  94. sub cvs_runcvs(@) {
  95. my ($cmd) = @_;
  96. unshift @$cmd, 'cvs', '-Q';
  97. eval q{use IPC::Cmd};
  98. error($@) if $@;
  99. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  100. my ($success, $error_code, $full_buf, $stdout_buf, $stderr_buf) =
  101. IPC::Cmd::run(command => $cmd, verbose => 0);
  102. if (! $success) {
  103. warn(join(" ", @$cmd) . " exited with code $error_code\n");
  104. warn(join "", @$stderr_buf);
  105. }
  106. return $success;
  107. }
  108. sub cvs_shquote_commit ($) {
  109. my $message = shift;
  110. my $test_message = "CVS autodiscover quoting CVS";
  111. eval q{use String::ShellQuote};
  112. error($@) if $@;
  113. eval q{use IPC::Cmd};
  114. error($@) if $@;
  115. my $cmd = ['echo', shell_quote($test_message)];
  116. my ($success, $error_code, $full_buf, $stdout_buf, $stderr_buf) =
  117. IPC::Cmd::run(command => $cmd, verbose => 0);
  118. if ((grep /'$test_message'/, @$stdout_buf) > 0) {
  119. return IkiWiki::possibly_foolish_untaint($message);
  120. } else {
  121. return shell_quote(IkiWiki::possibly_foolish_untaint($message));
  122. }
  123. }
  124. sub cvs_is_controlling {
  125. my $dir=shift;
  126. $dir=$config{srcdir} unless defined($dir);
  127. return (-d "$dir/CVS") ? 1 : 0;
  128. }
  129. sub rcs_update () {
  130. return unless cvs_is_controlling;
  131. cvs_runcvs(['update', '-dP']);
  132. }
  133. sub rcs_prepedit ($) {
  134. # Prepares to edit a file under revision control. Returns a token
  135. # that must be passed into rcs_commit when the file is ready
  136. # for committing.
  137. # The file is relative to the srcdir.
  138. my $file=shift;
  139. return unless cvs_is_controlling;
  140. # For cvs, return the revision of the file when
  141. # editing begins.
  142. my $rev=cvs_info("Repository revision", "$file");
  143. return defined $rev ? $rev : "";
  144. }
  145. sub rcs_commit ($$$;$$) {
  146. # Tries to commit the page; returns undef on _success_ and
  147. # a version of the page with the rcs's conflict markers on failure.
  148. # The file is relative to the srcdir.
  149. my $file=shift;
  150. my $message=shift;
  151. my $rcstoken=shift;
  152. my $user=shift;
  153. my $ipaddr=shift;
  154. return unless cvs_is_controlling;
  155. if (defined $user) {
  156. $message="web commit by $user".(length $message ? ": $message" : "");
  157. }
  158. elsif (defined $ipaddr) {
  159. $message="web commit from $ipaddr".(length $message ? ": $message" : "");
  160. }
  161. # Check to see if the page has been changed by someone
  162. # else since rcs_prepedit was called.
  163. my ($oldrev)=$rcstoken=~/^([0-9]+)$/; # untaint
  164. my $rev=cvs_info("Repository revision", "$config{srcdir}/$file");
  165. if (defined $rev && defined $oldrev && $rev != $oldrev) {
  166. # Merge their changes into the file that we've
  167. # changed.
  168. cvs_runcvs(['update', $file]) ||
  169. warn("cvs merge from $oldrev to $rev failed\n");
  170. }
  171. if (! cvs_runcvs(['commit', '-m', cvs_shquote_commit $message])) {
  172. my $conflict=readfile("$config{srcdir}/$file");
  173. cvs_runcvs(['update', '-C', $file]) ||
  174. warn("cvs revert failed\n");
  175. return $conflict;
  176. }
  177. return undef # success
  178. }
  179. sub rcs_commit_staged ($$$) {
  180. # Commits all staged changes. Changes can be staged using rcs_add,
  181. # rcs_remove, and rcs_rename.
  182. my ($message, $user, $ipaddr)=@_;
  183. if (defined $user) {
  184. $message="web commit by $user".(length $message ? ": $message" : "");
  185. }
  186. elsif (defined $ipaddr) {
  187. $message="web commit from $ipaddr".(length $message ? ": $message" : "");
  188. }
  189. if (! cvs_runcvs(['commit', '-m', cvs_shquote_commit $message])) {
  190. warn "cvs staged commit failed\n";
  191. return 1; # failure
  192. }
  193. return undef # success
  194. }
  195. sub rcs_add ($) {
  196. # filename is relative to the root of the srcdir
  197. my $file=shift;
  198. my $parent=IkiWiki::dirname($file);
  199. my @files_to_add = ($file);
  200. eval q{use File::MimeInfo};
  201. error($@) if $@;
  202. until ((length($parent) == 0) || cvs_is_controlling("$config{srcdir}/$parent")){
  203. push @files_to_add, $parent;
  204. $parent = IkiWiki::dirname($parent);
  205. }
  206. while ($file = pop @files_to_add) {
  207. if (@files_to_add == 0) {
  208. # file
  209. my $filemime = File::MimeInfo::default($file);
  210. if (defined($filemime) && $filemime eq 'text/plain') {
  211. cvs_runcvs(['add', $file]) ||
  212. warn("cvs add $file failed\n");
  213. } else {
  214. cvs_runcvs(['add', '-kb', $file]) ||
  215. warn("cvs add binary $file failed\n");
  216. }
  217. } else {
  218. # directory
  219. cvs_runcvs(['add', $file]) ||
  220. warn("cvs add $file failed\n");
  221. }
  222. }
  223. }
  224. sub rcs_remove ($) {
  225. # filename is relative to the root of the srcdir
  226. my $file=shift;
  227. return unless cvs_is_controlling;
  228. cvs_runcvs(['rm', '-f', $file]) ||
  229. warn("cvs rm $file failed\n");
  230. }
  231. sub rcs_rename ($$) {
  232. # filenames relative to the root of the srcdir
  233. my ($src, $dest)=@_;
  234. return unless cvs_is_controlling;
  235. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  236. if (system("mv", "$src", "$dest") != 0) {
  237. warn("filesystem rename failed\n");
  238. }
  239. rcs_add($dest);
  240. rcs_remove($src);
  241. }
  242. sub rcs_recentchanges($) {
  243. my $num = shift;
  244. my @ret;
  245. return unless cvs_is_controlling;
  246. eval q{use Date::Parse};
  247. error($@) if $@;
  248. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  249. # There's no cvsps option to get the last N changesets.
  250. # Write full output to a temp file and read backwards.
  251. eval q{use File::Temp qw/tempfile/};
  252. error($@) if $@;
  253. eval q{use File::ReadBackwards};
  254. error($@) if $@;
  255. my (undef, $tmpfile) = tempfile(OPEN=>0);
  256. system("env TZ=UTC cvsps -q --cvs-direct -z 30 -x >$tmpfile");
  257. if ($? == -1) {
  258. error "couldn't run cvsps: $!\n";
  259. } elsif (($? >> 8) != 0) {
  260. error "cvsps exited " . ($? >> 8) . ": $!\n";
  261. }
  262. tie(*SPSVC, 'File::ReadBackwards', $tmpfile)
  263. || error "couldn't open $tmpfile for read: $!\n";
  264. while (my $line = <SPSVC>) {
  265. $line =~ /^$/ || error "expected blank line, got $line";
  266. my ($rev, $user, $committype, $when);
  267. my (@message, @pages);
  268. # We're reading backwards.
  269. # Forwards, an entry looks like so:
  270. # ---------------------
  271. # PatchSet $rev
  272. # Date: $when
  273. # Author: $user (or user CGI runs as, for web commits)
  274. # Branch: branch
  275. # Tag: tag
  276. # Log:
  277. # @message_lines
  278. # Members:
  279. # @pages (and revisions)
  280. #
  281. while ($line = <SPSVC>) {
  282. last if ($line =~ /^Members:/);
  283. for ($line) {
  284. s/^\s+//;
  285. s/\s+$//;
  286. }
  287. my ($page, $revs) = split(/:/, $line);
  288. my ($oldrev, $newrev) = split(/->/, $revs);
  289. $oldrev =~ s/INITIAL/0/;
  290. $newrev =~ s/\(DEAD\)//;
  291. my $diffurl = defined $config{diffurl} ? $config{diffurl} : "";
  292. $diffurl=~s/\[\[file\]\]/$page/g;
  293. $diffurl=~s/\[\[r1\]\]/$oldrev/g;
  294. $diffurl=~s/\[\[r2\]\]/$newrev/g;
  295. unshift @pages, {
  296. page => pagename($page),
  297. diffurl => $diffurl,
  298. } if length $page;
  299. }
  300. while ($line = <SPSVC>) {
  301. last if ($line =~ /^Log:$/);
  302. chomp $line;
  303. unshift @message, { line => $line };
  304. }
  305. $committype = "web";
  306. if (defined $message[0] &&
  307. $message[0]->{line}=~/$config{web_commit_regexp}/) {
  308. $user=defined $2 ? "$2" : "$3";
  309. $message[0]->{line}=$4;
  310. } else {
  311. $committype="cvs";
  312. }
  313. $line = <SPSVC>; # Tag
  314. $line = <SPSVC>; # Branch
  315. $line = <SPSVC>;
  316. if ($line =~ /^Author: (.*)$/) {
  317. $user = $1 unless defined $user && length $user;
  318. } else {
  319. error "expected Author, got $line";
  320. }
  321. $line = <SPSVC>;
  322. if ($line =~ /^Date: (.*)$/) {
  323. $when = str2time($1, 'UTC');
  324. } else {
  325. error "expected Date, got $line";
  326. }
  327. $line = <SPSVC>;
  328. if ($line =~ /^PatchSet (.*)$/) {
  329. $rev = $1;
  330. } else {
  331. error "expected PatchSet, got $line";
  332. }
  333. $line = <SPSVC>; # ---------------------
  334. push @ret, {
  335. rev => $rev,
  336. user => $user,
  337. committype => $committype,
  338. when => $when,
  339. message => [@message],
  340. pages => [@pages],
  341. } if @pages;
  342. last if @ret >= $num;
  343. }
  344. unlink($tmpfile) || error "couldn't unlink $tmpfile: $!\n";
  345. return @ret;
  346. }
  347. sub rcs_diff ($) {
  348. my $rev=IkiWiki::possibly_foolish_untaint(int(shift));
  349. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  350. # diff output is unavoidably preceded by the cvsps PatchSet entry
  351. my @cvsps = `env TZ=UTC cvsps -q --cvs-direct -z 30 -g -s $rev`;
  352. my $blank_lines_seen = 0;
  353. while (my $line = shift @cvsps) {
  354. $blank_lines_seen++ if ($line =~ /^$/);
  355. last if $blank_lines_seen == 2;
  356. }
  357. if (wantarray) {
  358. return @cvsps;
  359. } else {
  360. return join("", @cvsps);
  361. }
  362. }
  363. sub rcs_getctime ($) {
  364. my $file=shift;
  365. my $cvs_log_infoline=qr/^date: (.+);\s+author/;
  366. open CVSLOG, "cvs -Q log -r1.1 '$file' |"
  367. || error "couldn't get cvs log output: $!\n";
  368. my $date;
  369. while (<CVSLOG>) {
  370. if (/$cvs_log_infoline/) {
  371. $date=$1;
  372. }
  373. }
  374. close CVSLOG || warn "cvs log $file exited $?";
  375. if (! defined $date) {
  376. warn "failed to parse cvs log for $file\n";
  377. return 0;
  378. }
  379. eval q{use Date::Parse};
  380. error($@) if $@;
  381. $date=str2time($date, 'UTC');
  382. debug("found ctime ".localtime($date)." for $file");
  383. return $date;
  384. }
  385. 1