summaryrefslogtreecommitdiff
path: root/IkiWiki/Plugin/cvs.pm
blob: 9f91c933508b0f6fe725704dafa21a967064af7c (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. }
  121. else {
  122. return shell_quote(IkiWiki::possibly_foolish_untaint($message));
  123. }
  124. }
  125. sub cvs_is_controlling {
  126. my $dir=shift;
  127. $dir=$config{srcdir} unless defined($dir);
  128. return (-d "$dir/CVS") ? 1 : 0;
  129. }
  130. sub rcs_update () {
  131. return unless cvs_is_controlling;
  132. cvs_runcvs(['update', '-dP']);
  133. }
  134. sub rcs_prepedit ($) {
  135. # Prepares to edit a file under revision control. Returns a token
  136. # that must be passed into rcs_commit when the file is ready
  137. # for committing.
  138. # The file is relative to the srcdir.
  139. my $file=shift;
  140. return unless cvs_is_controlling;
  141. # For cvs, return the revision of the file when
  142. # editing begins.
  143. my $rev=cvs_info("Repository revision", "$file");
  144. return defined $rev ? $rev : "";
  145. }
  146. sub rcs_commit ($$$;$$) {
  147. # Tries to commit the page; returns undef on _success_ and
  148. # a version of the page with the rcs's conflict markers on failure.
  149. # The file is relative to the srcdir.
  150. my $file=shift;
  151. my $message=shift;
  152. my $rcstoken=shift;
  153. my $user=shift;
  154. my $ipaddr=shift;
  155. return unless cvs_is_controlling;
  156. if (defined $user) {
  157. $message="web commit by $user".(length $message ? ": $message" : "");
  158. }
  159. elsif (defined $ipaddr) {
  160. $message="web commit from $ipaddr".(length $message ? ": $message" : "");
  161. }
  162. # Check to see if the page has been changed by someone
  163. # else since rcs_prepedit was called.
  164. my ($oldrev)=$rcstoken=~/^([0-9]+)$/; # untaint
  165. my $rev=cvs_info("Repository revision", "$config{srcdir}/$file");
  166. if (defined $rev && defined $oldrev && $rev != $oldrev) {
  167. # Merge their changes into the file that we've
  168. # changed.
  169. cvs_runcvs(['update', $file]) ||
  170. warn("cvs merge from $oldrev to $rev failed\n");
  171. }
  172. if (! cvs_runcvs(['commit', '-m', cvs_shquote_commit $message])) {
  173. my $conflict=readfile("$config{srcdir}/$file");
  174. cvs_runcvs(['update', '-C', $file]) ||
  175. warn("cvs revert failed\n");
  176. return $conflict;
  177. }
  178. return undef # success
  179. }
  180. sub rcs_commit_staged ($$$) {
  181. # Commits all staged changes. Changes can be staged using rcs_add,
  182. # rcs_remove, and rcs_rename.
  183. my ($message, $user, $ipaddr)=@_;
  184. if (defined $user) {
  185. $message="web commit by $user".(length $message ? ": $message" : "");
  186. }
  187. elsif (defined $ipaddr) {
  188. $message="web commit from $ipaddr".(length $message ? ": $message" : "");
  189. }
  190. if (! cvs_runcvs(['commit', '-m', cvs_shquote_commit $message])) {
  191. warn "cvs staged commit failed\n";
  192. return 1; # failure
  193. }
  194. return undef # success
  195. }
  196. sub rcs_add ($) {
  197. # filename is relative to the root of the srcdir
  198. my $file=shift;
  199. my $parent=IkiWiki::dirname($file);
  200. my @files_to_add = ($file);
  201. eval q{use File::MimeInfo};
  202. error($@) if $@;
  203. until ((length($parent) == 0) || cvs_is_controlling("$config{srcdir}/$parent")){
  204. push @files_to_add, $parent;
  205. $parent = IkiWiki::dirname($parent);
  206. }
  207. while ($file = pop @files_to_add) {
  208. if (@files_to_add == 0) {
  209. # file
  210. my $filemime = File::MimeInfo::default($file);
  211. if (defined($filemime) && $filemime eq 'text/plain') {
  212. cvs_runcvs(['add', $file]) ||
  213. warn("cvs add $file failed\n");
  214. }
  215. else {
  216. cvs_runcvs(['add', '-kb', $file]) ||
  217. warn("cvs add binary $file failed\n");
  218. }
  219. }
  220. else {
  221. # directory
  222. cvs_runcvs(['add', $file]) ||
  223. warn("cvs add $file failed\n");
  224. }
  225. }
  226. }
  227. sub rcs_remove ($) {
  228. # filename is relative to the root of the srcdir
  229. my $file=shift;
  230. return unless cvs_is_controlling;
  231. cvs_runcvs(['rm', '-f', $file]) ||
  232. warn("cvs rm $file failed\n");
  233. }
  234. sub rcs_rename ($$) {
  235. # filenames relative to the root of the srcdir
  236. my ($src, $dest)=@_;
  237. return unless cvs_is_controlling;
  238. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  239. if (system("mv", "$src", "$dest") != 0) {
  240. warn("filesystem rename failed\n");
  241. }
  242. rcs_add($dest);
  243. rcs_remove($src);
  244. }
  245. sub rcs_recentchanges($) {
  246. my $num = shift;
  247. my @ret;
  248. return unless cvs_is_controlling;
  249. eval q{use Date::Parse};
  250. error($@) if $@;
  251. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  252. # There's no cvsps option to get the last N changesets.
  253. # Write full output to a temp file and read backwards.
  254. eval q{use File::Temp qw/tempfile/};
  255. error($@) if $@;
  256. eval q{use File::ReadBackwards};
  257. error($@) if $@;
  258. my (undef, $tmpfile) = tempfile(OPEN=>0);
  259. system("env TZ=UTC cvsps -q --cvs-direct -z 30 -x >$tmpfile");
  260. if ($? == -1) {
  261. error "couldn't run cvsps: $!\n";
  262. } elsif (($? >> 8) != 0) {
  263. error "cvsps exited " . ($? >> 8) . ": $!\n";
  264. }
  265. tie(*SPSVC, 'File::ReadBackwards', $tmpfile)
  266. || error "couldn't open $tmpfile for read: $!\n";
  267. while (my $line = <SPSVC>) {
  268. $line =~ /^$/ || error "expected blank line, got $line";
  269. my ($rev, $user, $committype, $when);
  270. my (@message, @pages);
  271. # We're reading backwards.
  272. # Forwards, an entry looks like so:
  273. # ---------------------
  274. # PatchSet $rev
  275. # Date: $when
  276. # Author: $user (or user CGI runs as, for web commits)
  277. # Branch: branch
  278. # Tag: tag
  279. # Log:
  280. # @message_lines
  281. # Members:
  282. # @pages (and revisions)
  283. #
  284. while ($line = <SPSVC>) {
  285. last if ($line =~ /^Members:/);
  286. for ($line) {
  287. s/^\s+//;
  288. s/\s+$//;
  289. }
  290. my ($page, $revs) = split(/:/, $line);
  291. my ($oldrev, $newrev) = split(/->/, $revs);
  292. $oldrev =~ s/INITIAL/0/;
  293. $newrev =~ s/\(DEAD\)//;
  294. my $diffurl = defined $config{diffurl} ? $config{diffurl} : "";
  295. $diffurl=~s/\[\[file\]\]/$page/g;
  296. $diffurl=~s/\[\[r1\]\]/$oldrev/g;
  297. $diffurl=~s/\[\[r2\]\]/$newrev/g;
  298. unshift @pages, {
  299. page => pagename($page),
  300. diffurl => $diffurl,
  301. } if length $page;
  302. }
  303. while ($line = <SPSVC>) {
  304. last if ($line =~ /^Log:$/);
  305. chomp $line;
  306. unshift @message, { line => $line };
  307. }
  308. $committype = "web";
  309. if (defined $message[0] &&
  310. $message[0]->{line}=~/$config{web_commit_regexp}/) {
  311. $user=defined $2 ? "$2" : "$3";
  312. $message[0]->{line}=$4;
  313. }
  314. else {
  315. $committype="cvs";
  316. }
  317. $line = <SPSVC>; # Tag
  318. $line = <SPSVC>; # Branch
  319. $line = <SPSVC>;
  320. if ($line =~ /^Author: (.*)$/) {
  321. $user = $1 unless defined $user && length $user;
  322. }
  323. else {
  324. error "expected Author, got $line";
  325. }
  326. $line = <SPSVC>;
  327. if ($line =~ /^Date: (.*)$/) {
  328. $when = str2time($1, 'UTC');
  329. }
  330. else {
  331. error "expected Date, got $line";
  332. }
  333. $line = <SPSVC>;
  334. if ($line =~ /^PatchSet (.*)$/) {
  335. $rev = $1;
  336. }
  337. else {
  338. error "expected PatchSet, got $line";
  339. }
  340. $line = <SPSVC>; # ---------------------
  341. push @ret, {
  342. rev => $rev,
  343. user => $user,
  344. committype => $committype,
  345. when => $when,
  346. message => [@message],
  347. pages => [@pages],
  348. } if @pages;
  349. last if @ret >= $num;
  350. }
  351. unlink($tmpfile) || error "couldn't unlink $tmpfile: $!\n";
  352. return @ret;
  353. }
  354. sub rcs_diff ($) {
  355. my $rev=IkiWiki::possibly_foolish_untaint(int(shift));
  356. chdir $config{srcdir} || error("Cannot chdir to $config{srcdir}: $!");
  357. # diff output is unavoidably preceded by the cvsps PatchSet entry
  358. my @cvsps = `env TZ=UTC cvsps -q --cvs-direct -z 30 -g -s $rev`;
  359. my $blank_lines_seen = 0;
  360. while (my $line = shift @cvsps) {
  361. $blank_lines_seen++ if ($line =~ /^$/);
  362. last if $blank_lines_seen == 2;
  363. }
  364. if (wantarray) {
  365. return @cvsps;
  366. }
  367. else {
  368. return join("", @cvsps);
  369. }
  370. }
  371. sub rcs_getctime ($) {
  372. my $file=shift;
  373. my $cvs_log_infoline=qr/^date: (.+);\s+author/;
  374. open CVSLOG, "cvs -Q log -r1.1 '$file' |"
  375. || error "couldn't get cvs log output: $!\n";
  376. my $date;
  377. while (<CVSLOG>) {
  378. if (/$cvs_log_infoline/) {
  379. $date=$1;
  380. }
  381. }
  382. close CVSLOG || warn "cvs log $file exited $?";
  383. if (! defined $date) {
  384. warn "failed to parse cvs log for $file\n";
  385. return 0;
  386. }
  387. eval q{use Date::Parse};
  388. error($@) if $@;
  389. $date=str2time($date, 'UTC');
  390. debug("found ctime ".localtime($date)." for $file");
  391. return $date;
  392. }
  393. 1