|
1 #!/usr/bin/perl -w |
|
2 |
|
3 # Copyright (C) 2007, 2008, 2009 Apple Inc. All rights reserved. |
|
4 # |
|
5 # Redistribution and use in source and binary forms, with or without |
|
6 # modification, are permitted provided that the following conditions |
|
7 # are met: |
|
8 # |
|
9 # 1. Redistributions of source code must retain the above copyright |
|
10 # notice, this list of conditions and the following disclaimer. |
|
11 # 2. Redistributions in binary form must reproduce the above copyright |
|
12 # notice, this list of conditions and the following disclaimer in the |
|
13 # documentation and/or other materials provided with the distribution. |
|
14 # 3. Neither the name of Apple Computer, Inc. ("Apple") nor the names of |
|
15 # its contributors may be used to endorse or promote products derived |
|
16 # from this software without specific prior written permission. |
|
17 # |
|
18 # THIS SOFTWARE IS PROVIDED BY APPLE AND ITS CONTRIBUTORS "AS IS" AND ANY |
|
19 # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
|
20 # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
|
21 # DISCLAIMED. IN NO EVENT SHALL APPLE OR ITS CONTRIBUTORS BE LIABLE FOR ANY |
|
22 # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES |
|
23 # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
|
24 # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND |
|
25 # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
|
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF |
|
27 # THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|
28 |
|
29 # Merge and resolve ChangeLog conflicts for svn and git repositories |
|
30 |
|
31 use strict; |
|
32 |
|
33 use FindBin; |
|
34 use lib $FindBin::Bin; |
|
35 |
|
36 use File::Basename; |
|
37 use File::Copy; |
|
38 use File::Path; |
|
39 use File::Spec; |
|
40 use Getopt::Long; |
|
41 use POSIX; |
|
42 use VCSUtils; |
|
43 |
|
44 sub canonicalRelativePath($); |
|
45 sub conflictFiles($); |
|
46 sub findChangeLog($); |
|
47 sub findUnmergedChangeLogs(); |
|
48 sub fixMergedChangeLogs($;@); |
|
49 sub fixOneMergedChangeLog($); |
|
50 sub hasGitUnmergedFiles(); |
|
51 sub isInGitFilterBranch(); |
|
52 sub parseFixMerged($$;$); |
|
53 sub removeChangeLogArguments($); |
|
54 sub resolveChangeLog($); |
|
55 sub resolveConflict($); |
|
56 sub showStatus($;$); |
|
57 sub usageAndExit(); |
|
58 |
|
59 my $isGit = isGit(); |
|
60 my $isSVN = isSVN(); |
|
61 |
|
62 my $SVN = "svn"; |
|
63 my $GIT = "git"; |
|
64 |
|
65 my $fixMerged; |
|
66 my $gitRebaseContinue = 0; |
|
67 my $mergeDriver = 0; |
|
68 my $printWarnings = 1; |
|
69 my $showHelp; |
|
70 |
|
71 my $getOptionsResult = GetOptions( |
|
72 'c|continue!' => \$gitRebaseContinue, |
|
73 'f|fix-merged:s' => \&parseFixMerged, |
|
74 'm|merge-driver!' => \$mergeDriver, |
|
75 'h|help' => \$showHelp, |
|
76 'w|warnings!' => \$printWarnings, |
|
77 ); |
|
78 |
|
79 my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot()); |
|
80 |
|
81 my @changeLogFiles = removeChangeLogArguments($relativePath); |
|
82 |
|
83 if (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) { |
|
84 @changeLogFiles = findUnmergedChangeLogs(); |
|
85 } |
|
86 |
|
87 if (!$mergeDriver && scalar(@ARGV) > 0) { |
|
88 print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n"; |
|
89 undef $getOptionsResult; |
|
90 } elsif (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) { |
|
91 print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n"; |
|
92 undef $getOptionsResult; |
|
93 } elsif ($gitRebaseContinue && !$isGit) { |
|
94 print STDERR "ERROR: --continue may only be used with a git repository\n"; |
|
95 undef $getOptionsResult; |
|
96 } elsif (defined $fixMerged && !$isGit) { |
|
97 print STDERR "ERROR: --fix-merged may only be used with a git repository\n"; |
|
98 undef $getOptionsResult; |
|
99 } elsif ($mergeDriver && !$isGit) { |
|
100 print STDERR "ERROR: --merge-driver may only be used with a git repository\n"; |
|
101 undef $getOptionsResult; |
|
102 } elsif ($mergeDriver && scalar(@ARGV) < 3) { |
|
103 print STDERR "ERROR: --merge-driver expects %O %A %B as arguments\n"; |
|
104 undef $getOptionsResult; |
|
105 } |
|
106 |
|
107 sub usageAndExit() |
|
108 { |
|
109 print STDERR <<__END__; |
|
110 Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...] |
|
111 -c|--[no-]continue run "git rebase --continue" after fixing ChangeLog |
|
112 entries (default: --no-continue) |
|
113 -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range |
|
114 is specified, run git filter-branch on the range |
|
115 -m|--merge-driver %O %A %B act as a git merge-driver on files %O %A %B |
|
116 -h|--help show this help message |
|
117 -w|--[no-]warnings show or suppress warnings (default: show warnings) |
|
118 __END__ |
|
119 exit 1; |
|
120 } |
|
121 |
|
122 if (!$getOptionsResult || $showHelp) { |
|
123 usageAndExit(); |
|
124 } |
|
125 |
|
126 if (defined $fixMerged && length($fixMerged) > 0) { |
|
127 my $commitRange = $fixMerged; |
|
128 $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0; |
|
129 fixMergedChangeLogs($commitRange, @changeLogFiles); |
|
130 } elsif ($mergeDriver) { |
|
131 my ($base, $theirs, $ours) = @ARGV; |
|
132 if (mergeChangeLogs($ours, $base, $theirs)) { |
|
133 unlink($ours); |
|
134 copy($theirs, $ours) or die $!; |
|
135 } else { |
|
136 exec qw(git merge-file -L THEIRS -L BASE -L OURS), $theirs, $base, $ours; |
|
137 } |
|
138 } elsif (@changeLogFiles) { |
|
139 for my $file (@changeLogFiles) { |
|
140 if (defined $fixMerged) { |
|
141 fixOneMergedChangeLog($file); |
|
142 } else { |
|
143 resolveChangeLog($file); |
|
144 } |
|
145 } |
|
146 } else { |
|
147 print STDERR "ERROR: Unknown combination of switches and arguments.\n"; |
|
148 usageAndExit(); |
|
149 } |
|
150 |
|
151 if ($gitRebaseContinue) { |
|
152 if (hasGitUnmergedFiles()) { |
|
153 print "Unmerged files; skipping '$GIT rebase --continue'.\n"; |
|
154 } else { |
|
155 print "Running '$GIT rebase --continue'...\n"; |
|
156 print `$GIT rebase --continue`; |
|
157 } |
|
158 } |
|
159 |
|
160 exit 0; |
|
161 |
|
162 sub canonicalRelativePath($) |
|
163 { |
|
164 my ($originalPath) = @_; |
|
165 my $absolutePath = Cwd::abs_path($originalPath); |
|
166 return File::Spec->abs2rel($absolutePath, Cwd::getcwd()); |
|
167 } |
|
168 |
|
169 sub conflictFiles($) |
|
170 { |
|
171 my ($file) = @_; |
|
172 my $fileMine; |
|
173 my $fileOlder; |
|
174 my $fileNewer; |
|
175 |
|
176 if (-e $file && -e "$file.orig" && -e "$file.rej") { |
|
177 return ("$file.rej", "$file.orig", $file); |
|
178 } |
|
179 |
|
180 if ($isSVN) { |
|
181 open STAT, "-|", $SVN, "status", $file or die $!; |
|
182 my $status = <STAT>; |
|
183 close STAT; |
|
184 if (!$status || $status !~ m/^C\s+/) { |
|
185 print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings; |
|
186 return (); |
|
187 } |
|
188 |
|
189 $fileMine = "${file}.mine" if -e "${file}.mine"; |
|
190 |
|
191 my $currentRevision; |
|
192 open INFO, "-|", $SVN, "info", $file or die $!; |
|
193 while (my $line = <INFO>) { |
|
194 if ($line =~ m/^Revision: ([0-9]+)/) { |
|
195 $currentRevision = $1; |
|
196 { local $/ = undef; <INFO>; } # Consume rest of input. |
|
197 } |
|
198 } |
|
199 close INFO; |
|
200 $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}"; |
|
201 |
|
202 my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*"); |
|
203 if (scalar(@matchingFiles) > 1) { |
|
204 print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings; |
|
205 } else { |
|
206 $fileOlder = shift @matchingFiles; |
|
207 } |
|
208 } elsif ($isGit) { |
|
209 my $gitPrefix = `$GIT rev-parse --show-prefix`; |
|
210 chomp $gitPrefix; |
|
211 open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!; |
|
212 while (my $line = <GIT>) { |
|
213 my ($mode, $hash, $stage, $fileName) = split(' ', $line); |
|
214 my $outputFile; |
|
215 if ($stage == 1) { |
|
216 $fileOlder = "${file}.BASE.$$"; |
|
217 $outputFile = $fileOlder; |
|
218 } elsif ($stage == 2) { |
|
219 $fileNewer = "${file}.LOCAL.$$"; |
|
220 $outputFile = $fileNewer; |
|
221 } elsif ($stage == 3) { |
|
222 $fileMine = "${file}.REMOTE.$$"; |
|
223 $outputFile = $fileMine; |
|
224 } else { |
|
225 die "Unknown file stage: $stage"; |
|
226 } |
|
227 system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile"); |
|
228 die $! if WEXITSTATUS($?); |
|
229 } |
|
230 close GIT or die $!; |
|
231 } else { |
|
232 die "Unknown version control system"; |
|
233 } |
|
234 |
|
235 if (!$fileMine && !$fileOlder && !$fileNewer) { |
|
236 print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings; |
|
237 } elsif (!$fileMine || !$fileOlder || !$fileNewer) { |
|
238 print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings; |
|
239 } |
|
240 |
|
241 return ($fileMine, $fileOlder, $fileNewer); |
|
242 } |
|
243 |
|
244 sub findChangeLog($) |
|
245 { |
|
246 return $_[0] if basename($_[0]) eq "ChangeLog"; |
|
247 |
|
248 my $file = File::Spec->catfile($_[0], "ChangeLog"); |
|
249 return $file if -d $_[0] and -e $file; |
|
250 |
|
251 return undef; |
|
252 } |
|
253 |
|
254 sub findUnmergedChangeLogs() |
|
255 { |
|
256 my $statCommand = ""; |
|
257 |
|
258 if ($isSVN) { |
|
259 $statCommand = "$SVN stat | grep '^C'"; |
|
260 } elsif ($isGit) { |
|
261 $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M"; |
|
262 } else { |
|
263 return (); |
|
264 } |
|
265 |
|
266 my @results = (); |
|
267 open STAT, "-|", $statCommand or die "The status failed: $!.\n"; |
|
268 while (<STAT>) { |
|
269 if ($isSVN) { |
|
270 my $matches; |
|
271 my $file; |
|
272 if (isSVNVersion16OrNewer()) { |
|
273 $matches = /^([C]).{6} (.+?)[\r\n]*$/; |
|
274 $file = $2; |
|
275 } else { |
|
276 $matches = /^([C]).{5} (.+?)[\r\n]*$/; |
|
277 $file = $2; |
|
278 } |
|
279 if ($matches) { |
|
280 $file = findChangeLog(normalizePath($file)); |
|
281 push @results, $file if $file; |
|
282 } else { |
|
283 print; # error output from svn stat |
|
284 } |
|
285 } elsif ($isGit) { |
|
286 if (/^([U])\t(.+)$/) { |
|
287 my $file = findChangeLog(normalizePath($2)); |
|
288 push @results, $file if $file; |
|
289 } else { |
|
290 print; # error output from git diff |
|
291 } |
|
292 } |
|
293 } |
|
294 close STAT; |
|
295 |
|
296 return @results; |
|
297 } |
|
298 |
|
299 sub fixMergedChangeLogs($;@) |
|
300 { |
|
301 my $revisionRange = shift; |
|
302 my @changedFiles = @_; |
|
303 |
|
304 if (scalar(@changedFiles) < 1) { |
|
305 # Read in list of files changed in $revisionRange |
|
306 open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!; |
|
307 push @changedFiles, <GIT>; |
|
308 close GIT or die $!; |
|
309 die "No changed files in $revisionRange" if scalar(@changedFiles) < 1; |
|
310 chomp @changedFiles; |
|
311 } |
|
312 |
|
313 my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles; |
|
314 die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1; |
|
315 |
|
316 system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange"); |
|
317 |
|
318 # On success, remove the backup refs directory |
|
319 if (WEXITSTATUS($?) == 0) { |
|
320 rmtree(qw(.git/refs/original)); |
|
321 } |
|
322 } |
|
323 |
|
324 sub fixOneMergedChangeLog($) |
|
325 { |
|
326 my $file = shift; |
|
327 my $patch; |
|
328 |
|
329 # Read in patch for incorrectly merged ChangeLog entry |
|
330 { |
|
331 local $/ = undef; |
|
332 open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!; |
|
333 $patch = <GIT>; |
|
334 close GIT or die $!; |
|
335 } |
|
336 |
|
337 # Always checkout the previous commit's copy of the ChangeLog |
|
338 system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file); |
|
339 die $! if WEXITSTATUS($?); |
|
340 |
|
341 # The patch must have 0 or more lines of context, then 1 or more lines |
|
342 # of additions, and then 1 or more lines of context. If not, we skip it. |
|
343 if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) { |
|
344 # Copy the header from the original patch. |
|
345 my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@")); |
|
346 |
|
347 # Generate a new set of line numbers and patch lengths. Our new |
|
348 # patch will start with the lines for the fixed ChangeLog entry, |
|
349 # then have 3 lines of context from the top of the current file to |
|
350 # make the patch apply cleanly. |
|
351 $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n"; |
|
352 |
|
353 # We assume that top few lines of the ChangeLog entry are actually |
|
354 # at the bottom of the list of added lines (due to the way the patch |
|
355 # algorithm works), so we simply search through the lines until we |
|
356 # find the date line, then move the rest of the lines to the top. |
|
357 my @patchLines = map { $_ . "\n" } split(/\n/, $6); |
|
358 foreach my $i (0 .. $#patchLines) { |
|
359 if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2} /) { |
|
360 unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i)); |
|
361 last; |
|
362 } |
|
363 } |
|
364 |
|
365 $newPatch .= join("", @patchLines); |
|
366 |
|
367 # Add 3 lines of context to the end |
|
368 open FILE, "<", $file or die $!; |
|
369 for (my $i = 0; $i < 3; $i++) { |
|
370 $newPatch .= " " . <FILE>; |
|
371 } |
|
372 close FILE; |
|
373 |
|
374 # Apply the new patch |
|
375 open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!; |
|
376 print PATCH $newPatch; |
|
377 close(PATCH) or die $!; |
|
378 |
|
379 # Run "git add" on the fixed ChangeLog file |
|
380 system($GIT, "add", $file); |
|
381 die $! if WEXITSTATUS($?); |
|
382 |
|
383 showStatus($file, 1); |
|
384 } elsif ($patch) { |
|
385 # Restore the current copy of the ChangeLog file since we can't repatch it |
|
386 system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file); |
|
387 die $! if WEXITSTATUS($?); |
|
388 print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings; |
|
389 } |
|
390 } |
|
391 |
|
392 sub hasGitUnmergedFiles() |
|
393 { |
|
394 my $output = `$GIT ls-files --unmerged`; |
|
395 return $output ne ""; |
|
396 } |
|
397 |
|
398 sub isInGitFilterBranch() |
|
399 { |
|
400 return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT}; |
|
401 } |
|
402 |
|
403 sub parseFixMerged($$;$) |
|
404 { |
|
405 my ($switchName, $key, $value) = @_; |
|
406 if (defined $key) { |
|
407 if (defined findChangeLog($key)) { |
|
408 unshift(@ARGV, $key); |
|
409 $fixMerged = ""; |
|
410 } else { |
|
411 $fixMerged = $key; |
|
412 } |
|
413 } else { |
|
414 $fixMerged = ""; |
|
415 } |
|
416 } |
|
417 |
|
418 sub removeChangeLogArguments($) |
|
419 { |
|
420 my ($baseDir) = @_; |
|
421 my @results = (); |
|
422 |
|
423 for (my $i = 0; $i < scalar(@ARGV); ) { |
|
424 my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i]))); |
|
425 if (defined $file) { |
|
426 splice(@ARGV, $i, 1); |
|
427 push @results, $file; |
|
428 } else { |
|
429 $i++; |
|
430 } |
|
431 } |
|
432 |
|
433 return @results; |
|
434 } |
|
435 |
|
436 sub resolveChangeLog($) |
|
437 { |
|
438 my ($file) = @_; |
|
439 |
|
440 my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file); |
|
441 |
|
442 return unless $fileMine && $fileOlder && $fileNewer; |
|
443 |
|
444 if (mergeChangeLogs($fileMine, $fileOlder, $fileNewer)) { |
|
445 if ($file ne $fileNewer) { |
|
446 unlink($file); |
|
447 rename($fileNewer, $file) or die $!; |
|
448 } |
|
449 unlink($fileMine, $fileOlder); |
|
450 resolveConflict($file); |
|
451 showStatus($file, 1); |
|
452 } else { |
|
453 showStatus($file); |
|
454 print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings; |
|
455 unlink($fileMine, $fileOlder, $fileNewer) if $isGit; |
|
456 } |
|
457 } |
|
458 |
|
459 sub resolveConflict($) |
|
460 { |
|
461 my ($file) = @_; |
|
462 |
|
463 if ($isSVN) { |
|
464 system($SVN, "resolved", $file); |
|
465 die $! if WEXITSTATUS($?); |
|
466 } elsif ($isGit) { |
|
467 system($GIT, "add", $file); |
|
468 die $! if WEXITSTATUS($?); |
|
469 } else { |
|
470 die "Unknown version control system"; |
|
471 } |
|
472 } |
|
473 |
|
474 sub showStatus($;$) |
|
475 { |
|
476 my ($file, $isConflictResolved) = @_; |
|
477 |
|
478 if ($isSVN) { |
|
479 system($SVN, "status", $file); |
|
480 } elsif ($isGit) { |
|
481 my @args = qw(--name-status); |
|
482 unshift @args, qw(--cached) if $isConflictResolved; |
|
483 system($GIT, "diff", @args, $file); |
|
484 } else { |
|
485 die "Unknown version control system"; |
|
486 } |
|
487 } |
|
488 |