summaryrefslogtreecommitdiff
path: root/IkiWiki/Rcs/git.pm
blob: bcf3170020fb707c4b8f34245b7ee1421807e152 (plain)
  1. #!/usr/bin/perl
  2. package IkiWiki;
  3. use warnings;
  4. use strict;
  5. use IkiWiki;
  6. use Encode;
  7. use open qw{:utf8 :std};
  8. my $sha1_pattern = qr/[0-9a-fA-F]{40}/; # pattern to validate Git sha1sums
  9. my $dummy_commit_msg = 'dummy commit'; # message to skip in recent changes
  10. hook(type => "checkconfig", id => "git", call => sub { #{{{
  11. if (! defined $config{gitorigin_branch}) {
  12. $config{gitorigin_branch}="origin";
  13. }
  14. if (! defined $config{gitmaster_branch}) {
  15. $config{gitmaster_branch}="master";
  16. }
  17. }); #}}}
  18. hook(type => "getsetup", id => "git", call => sub { #{{{
  19. return
  20. historyurl => {
  21. type => "string",
  22. default => "",
  23. example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=history;f=[[file]]",
  24. description => "gitweb url to show file history ([[file]] substituted)",
  25. safe => 1,
  26. rebuild => 1,
  27. },
  28. diffurl => {
  29. type => "string",
  30. default => "",
  31. example => "http://git.example.com/gitweb.cgi?p=wiki.git;a=blobdiff;h=[[sha1_to]];hp=[[sha1_from]];hb=[[sha1_parent]];f=[[file]]",
  32. description => "gitweb url to show a diff ([[sha1_to]], [[sha1_from]], [[sha1_parent]], and [[file]] substituted)",
  33. safe => 1,
  34. rebuild => 1,
  35. },
  36. gitorigin_branch => {
  37. type => "string",
  38. default => "origin",
  39. description => "where to pull and push changes (unset to not pull/push)",
  40. safe => 0, # paranoia
  41. rebuild => 0,
  42. },
  43. gitmaster_branch => {
  44. type => "string",
  45. default => "master",
  46. description => "branch that the wiki is stored in",
  47. safe => 0, # paranoia
  48. rebuild => 0,
  49. },
  50. }); #}}}
  51. sub _safe_git (&@) { #{{{
  52. # Start a child process safely without resorting /bin/sh.
  53. # Return command output or success state (in scalar context).
  54. my ($error_handler, @cmdline) = @_;
  55. my $pid = open my $OUT, "-|";
  56. error("Cannot fork: $!") if !defined $pid;
  57. if (!$pid) {
  58. # In child.
  59. # Git commands want to be in wc.
  60. chdir $config{srcdir}
  61. or error("Cannot chdir to $config{srcdir}: $!");
  62. exec @cmdline or error("Cannot exec '@cmdline': $!");
  63. }
  64. # In parent.
  65. my @lines;
  66. while (<$OUT>) {
  67. chomp;
  68. push @lines, $_;
  69. }
  70. close $OUT;
  71. $error_handler->("'@cmdline' failed: $!") if $? && $error_handler;
  72. return wantarray ? @lines : ($? == 0);
  73. }
  74. # Convenient wrappers.
  75. sub run_or_die ($@) { _safe_git(\&error, @_) }
  76. sub run_or_cry ($@) { _safe_git(sub { warn @_ }, @_) }
  77. sub run_or_non ($@) { _safe_git(undef, @_) }
  78. #}}}
  79. sub _merge_past ($$$) { #{{{
  80. # Unlike with Subversion, Git cannot make a 'svn merge -rN:M file'.
  81. # Git merge commands work with the committed changes, except in the
  82. # implicit case of '-m' of git checkout(1). So we should invent a
  83. # kludge here. In principle, we need to create a throw-away branch
  84. # in preparing for the merge itself. Since branches are cheap (and
  85. # branching is fast), this shouldn't cost high.
  86. #
  87. # The main problem is the presence of _uncommitted_ local changes. One
  88. # possible approach to get rid of this situation could be that we first
  89. # make a temporary commit in the master branch and later restore the
  90. # initial state (this is possible since Git has the ability to undo a
  91. # commit, i.e. 'git reset --soft HEAD^'). The method can be summarized
  92. # as follows:
  93. #
  94. # - create a diff of HEAD:current-sha1
  95. # - dummy commit
  96. # - create a dummy branch and switch to it
  97. # - rewind to past (reset --hard to the current-sha1)
  98. # - apply the diff and commit
  99. # - switch to master and do the merge with the dummy branch
  100. # - make a soft reset (undo the last commit of master)
  101. #
  102. # The above method has some drawbacks: (1) it needs a redundant commit
  103. # just to get rid of local changes, (2) somewhat slow because of the
  104. # required system forks. Until someone points a more straight method
  105. # (which I would be grateful) I have implemented an alternative method.
  106. # In this approach, we hide all the modified files from Git by renaming
  107. # them (using the 'rename' builtin) and later restore those files in
  108. # the throw-away branch (that is, we put the files themselves instead
  109. # of applying a patch).
  110. my ($sha1, $file, $message) = @_;
  111. my @undo; # undo stack for cleanup in case of an error
  112. my $conflict; # file content with conflict markers
  113. eval {
  114. # Hide local changes from Git by renaming the modified file.
  115. # Relative paths must be converted to absolute for renaming.
  116. my ($target, $hidden) = (
  117. "$config{srcdir}/${file}", "$config{srcdir}/${file}.${sha1}"
  118. );
  119. rename($target, $hidden)
  120. or error("rename '$target' to '$hidden' failed: $!");
  121. # Ensure to restore the renamed file on error.
  122. push @undo, sub {
  123. return if ! -e "$hidden"; # already renamed
  124. rename($hidden, $target)
  125. or warn "rename '$hidden' to '$target' failed: $!";
  126. };
  127. my $branch = "throw_away_${sha1}"; # supposed to be unique
  128. # Create a throw-away branch and rewind backward.
  129. push @undo, sub { run_or_cry('git', 'branch', '-D', $branch) };
  130. run_or_die('git', 'branch', $branch, $sha1);
  131. # Switch to throw-away branch for the merge operation.
  132. push @undo, sub {
  133. if (!run_or_cry('git', 'checkout', $config{gitmaster_branch})) {
  134. run_or_cry('git', 'checkout','-f',$config{gitmaster_branch});
  135. }
  136. };
  137. run_or_die('git', 'checkout', $branch);
  138. # Put the modified file in _this_ branch.
  139. rename($hidden, $target)
  140. or error("rename '$hidden' to '$target' failed: $!");
  141. # _Silently_ commit all modifications in the current branch.
  142. run_or_non('git', 'commit', '-m', $message, '-a');
  143. # ... and re-switch to master.
  144. run_or_die('git', 'checkout', $config{gitmaster_branch});
  145. # Attempt to merge without complaining.
  146. if (!run_or_non('git', 'pull', '--no-commit', '.', $branch)) {
  147. $conflict = readfile($target);
  148. run_or_die('git', 'reset', '--hard');
  149. }
  150. };
  151. my $failure = $@;
  152. # Process undo stack (in reverse order). By policy cleanup
  153. # actions should normally print a warning on failure.
  154. while (my $handle = pop @undo) {
  155. $handle->();
  156. }
  157. error("Git merge failed!\n$failure\n") if $failure;
  158. return $conflict;
  159. } #}}}
  160. sub _parse_diff_tree ($@) { #{{{
  161. # Parse the raw diff tree chunk and return the info hash.
  162. # See git-diff-tree(1) for the syntax.
  163. my ($prefix, $dt_ref) = @_;
  164. # End of stream?
  165. return if !defined @{ $dt_ref } ||
  166. !defined @{ $dt_ref }[0] || !length @{ $dt_ref }[0];
  167. my %ci;
  168. # Header line.
  169. while (my $line = shift @{ $dt_ref }) {
  170. return if $line !~ m/^(.+) ($sha1_pattern)/;
  171. my $sha1 = $2;
  172. $ci{'sha1'} = $sha1;
  173. last;
  174. }
  175. # Identification lines for the commit.
  176. while (my $line = shift @{ $dt_ref }) {
  177. # Regexps are semi-stolen from gitweb.cgi.
  178. if ($line =~ m/^tree ([0-9a-fA-F]{40})$/) {
  179. $ci{'tree'} = $1;
  180. }
  181. elsif ($line =~ m/^parent ([0-9a-fA-F]{40})$/) {
  182. # XXX: collecting in reverse order
  183. push @{ $ci{'parents'} }, $1;
  184. }
  185. elsif ($line =~ m/^(author|committer) (.*) ([0-9]+) (.*)$/) {
  186. my ($who, $name, $epoch, $tz) =
  187. ($1, $2, $3, $4 );
  188. $ci{ $who } = $name;
  189. $ci{ "${who}_epoch" } = $epoch;
  190. $ci{ "${who}_tz" } = $tz;
  191. if ($name =~ m/^[^<]+\s+<([^@>]+)/) {
  192. $ci{"${who}_username"} = $1;
  193. }
  194. elsif ($name =~ m/^([^<]+)\s+<>$/) {
  195. $ci{"${who}_username"} = $1;
  196. }
  197. else {
  198. $ci{"${who}_username"} = $name;
  199. }
  200. }
  201. elsif ($line =~ m/^$/) {
  202. # Trailing empty line signals next section.
  203. last;
  204. }
  205. }
  206. debug("No 'tree' seen in diff-tree output") if !defined $ci{'tree'};
  207. if (defined $ci{'parents'}) {
  208. $ci{'parent'} = @{ $ci{'parents'} }[0];
  209. }
  210. else {
  211. $ci{'parent'} = 0 x 40;
  212. }
  213. # Commit message (optional).
  214. while ($dt_ref->[0] =~ /^ /) {
  215. my $line = shift @{ $dt_ref };
  216. $line =~ s/^ //;
  217. push @{ $ci{'comment'} }, $line;
  218. }
  219. shift @{ $dt_ref } if $dt_ref->[0] =~ /^$/;
  220. # Modified files.
  221. while (my $line = shift @{ $dt_ref }) {
  222. if ($line =~ m{^
  223. (:+) # number of parents
  224. ([^\t]+)\t # modes, sha1, status
  225. (.*) # file names
  226. $}xo) {
  227. my $num_parents = length $1;
  228. my @tmp = split(" ", $2);
  229. my ($file, $file_to) = split("\t", $3);
  230. my @mode_from = splice(@tmp, 0, $num_parents);
  231. my $mode_to = shift(@tmp);
  232. my @sha1_from = splice(@tmp, 0, $num_parents);
  233. my $sha1_to = shift(@tmp);
  234. my $status = shift(@tmp);
  235. if ($file =~ m/^"(.*)"$/) {
  236. ($file=$1) =~ s/\\([0-7]{1,3})/chr(oct($1))/eg;
  237. }
  238. $file =~ s/^\Q$prefix\E//;
  239. if (length $file) {
  240. push @{ $ci{'details'} }, {
  241. 'file' => decode_utf8($file),
  242. 'sha1_from' => $sha1_from[0],
  243. 'sha1_to' => $sha1_to,
  244. };
  245. }
  246. next;
  247. };
  248. last;
  249. }
  250. return \%ci;
  251. } #}}}
  252. sub git_commit_info ($;$) { #{{{
  253. # Return an array of commit info hashes of num commits (default: 1)
  254. # starting from the given sha1sum.
  255. my ($sha1, $num) = @_;
  256. $num ||= 1;
  257. my @raw_lines = run_or_die('git', 'log', "--max-count=$num",
  258. '--pretty=raw', '--raw', '--abbrev=40', '--always', '-c',
  259. '-r', $sha1, '--', '.');
  260. my ($prefix) = run_or_die('git', 'rev-parse', '--show-prefix');
  261. my @ci;
  262. while (my $parsed = _parse_diff_tree(($prefix or ""), \@raw_lines)) {
  263. push @ci, $parsed;
  264. }
  265. warn "Cannot parse commit info for '$sha1' commit" if !@ci;
  266. return wantarray ? @ci : $ci[0];
  267. } #}}}
  268. sub git_sha1 (;$) { #{{{
  269. # Return head sha1sum (of given file).
  270. my $file = shift || q{--};
  271. # Ignore error since a non-existing file might be given.
  272. my ($sha1) = run_or_non('git', 'rev-list', '--max-count=1', 'HEAD',
  273. '--', $file);
  274. if ($sha1) {
  275. ($sha1) = $sha1 =~ m/($sha1_pattern)/; # sha1 is untainted now
  276. } else { debug("Empty sha1sum for '$file'.") }
  277. return defined $sha1 ? $sha1 : q{};
  278. } #}}}
  279. sub rcs_update () { #{{{
  280. # Update working directory.
  281. if (length $config{gitorigin_branch}) {
  282. run_or_cry('git', 'pull', $config{gitorigin_branch});
  283. }
  284. } #}}}
  285. sub rcs_prepedit ($) { #{{{
  286. # Return the commit sha1sum of the file when editing begins.
  287. # This will be later used in rcs_commit if a merge is required.
  288. my ($file) = @_;
  289. return git_sha1($file);
  290. } #}}}
  291. sub rcs_commit ($$$;$$) { #{{{
  292. # Try to commit the page; returns undef on _success_ and
  293. # a version of the page with the rcs's conflict markers on
  294. # failure.
  295. my ($file, $message, $rcstoken, $user, $ipaddr) = @_;
  296. # Check to see if the page has been changed by someone else since
  297. # rcs_prepedit was called.
  298. my $cur = git_sha1($file);
  299. my ($prev) = $rcstoken =~ /^($sha1_pattern)$/; # untaint
  300. if (defined $cur && defined $prev && $cur ne $prev) {
  301. my $conflict = _merge_past($prev, $file, $dummy_commit_msg);
  302. return $conflict if defined $conflict;
  303. }
  304. rcs_add($file);
  305. return rcs_commit_staged($message, $user, $ipaddr);
  306. } #}}}
  307. sub rcs_commit_staged ($$$) {
  308. # Commits all staged changes. Changes can be staged using rcs_add,
  309. # rcs_remove, and rcs_rename.
  310. my ($message, $user, $ipaddr)=@_;
  311. # Set the commit author and email to the web committer.
  312. my %env=%ENV;
  313. if (defined $user || defined $ipaddr) {
  314. my $u=defined $user ? $user : $ipaddr;
  315. $ENV{GIT_AUTHOR_NAME}=$u;
  316. $ENV{GIT_AUTHOR_EMAIL}="$u\@web";
  317. }
  318. # git commit returns non-zero if file has not been really changed.
  319. # so we should ignore its exit status (hence run_or_non).
  320. $message = possibly_foolish_untaint($message);
  321. if (run_or_non('git', 'commit', '--cleanup=verbatim',
  322. '-q', '-m', $message)) {
  323. if (length $config{gitorigin_branch}) {
  324. run_or_cry('git', 'push', $config{gitorigin_branch});
  325. }
  326. }
  327. %ENV=%env;
  328. return undef; # success
  329. }
  330. sub rcs_add ($) { # {{{
  331. # Add file to archive.
  332. my ($file) = @_;
  333. run_or_cry('git', 'add', $file);
  334. } #}}}
  335. sub rcs_remove ($) { # {{{
  336. # Remove file from archive.
  337. my ($file) = @_;
  338. run_or_cry('git', 'rm', '-f', $file);
  339. } #}}}
  340. sub rcs_rename ($$) { # {{{
  341. my ($src, $dest) = @_;
  342. run_or_cry('git', 'mv', '-f', $src, $dest);
  343. } #}}}
  344. sub rcs_recentchanges ($) { #{{{
  345. # List of recent changes.
  346. my ($num) = @_;
  347. eval q{use Date::Parse};
  348. error($@) if $@;
  349. my @rets;
  350. foreach my $ci (git_commit_info('HEAD', $num)) {
  351. # Skip redundant commits.
  352. next if ($ci->{'comment'} && @{$ci->{'comment'}}[0] eq $dummy_commit_msg);
  353. my ($sha1, $when) = (
  354. $ci->{'sha1'},
  355. $ci->{'author_epoch'}
  356. );
  357. my @pages;
  358. foreach my $detail (@{ $ci->{'details'} }) {
  359. my $file = $detail->{'file'};
  360. my $diffurl = $config{'diffurl'};
  361. $diffurl =~ s/\[\[file\]\]/$file/go;
  362. $diffurl =~ s/\[\[sha1_parent\]\]/$ci->{'parent'}/go;
  363. $diffurl =~ s/\[\[sha1_from\]\]/$detail->{'sha1_from'}/go;
  364. $diffurl =~ s/\[\[sha1_to\]\]/$detail->{'sha1_to'}/go;
  365. push @pages, {
  366. page => pagename($file),
  367. diffurl => $diffurl,
  368. };
  369. }
  370. my @messages;
  371. my $pastblank=0;
  372. foreach my $line (@{$ci->{'comment'}}) {
  373. $pastblank=1 if $line eq '';
  374. next if $pastblank && $line=~m/^ *(signed[ \-]off[ \-]by[ :]|acked[ \-]by[ :]|cc[ :])/i;
  375. push @messages, { line => $line };
  376. }
  377. my $user=$ci->{'author_username'};
  378. my $web_commit = ($ci->{'author'} =~ /\@web>/);
  379. # compatability code for old web commit messages
  380. if (! $web_commit &&
  381. defined $messages[0] &&
  382. $messages[0]->{line} =~ m/$config{web_commit_regexp}/) {
  383. $user = defined $2 ? "$2" : "$3";
  384. $messages[0]->{line} = $4;
  385. $web_commit=1;
  386. }
  387. push @rets, {
  388. rev => $sha1,
  389. user => $user,
  390. committype => $web_commit ? "web" : "git",
  391. when => $when,
  392. message => [@messages],
  393. pages => [@pages],
  394. } if @pages;
  395. last if @rets >= $num;
  396. }
  397. return @rets;
  398. } #}}}
  399. sub rcs_diff ($) { #{{{
  400. my $rev=shift;
  401. my ($sha1) = $rev =~ /^($sha1_pattern)$/; # untaint
  402. my @lines;
  403. foreach my $line (run_or_non("git", "show", $sha1)) {
  404. if (@lines || $line=~/^diff --git/) {
  405. push @lines, $line."\n";
  406. }
  407. }
  408. if (wantarray) {
  409. return @lines;
  410. }
  411. else {
  412. return join("", @lines);
  413. }
  414. } #}}}
  415. sub rcs_getctime ($) { #{{{
  416. my $file=shift;
  417. # Remove srcdir prefix
  418. $file =~ s/^\Q$config{srcdir}\E\/?//;
  419. my $sha1 = git_sha1($file);
  420. my $ci = git_commit_info($sha1);
  421. my $ctime = $ci->{'author_epoch'};
  422. debug("ctime for '$file': ". localtime($ctime));
  423. return $ctime;
  424. } #}}}
  425. 1