3 # Copyright (C) 2007, 2008, 2009 Apple Inc. All rights reserved.
5 # Redistribution and use in source and binary forms, with or without
6 # modification, are permitted provided that the following conditions
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.
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.
29 # Merge and resolve ChangeLog conflicts for svn and git repositories
34 use lib $FindBin::Bin;
44 sub canonicalRelativePath($);
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($);
66 my $gitRebaseContinue = 0;
68 my $printWarnings = 1;
73 print STDERR <<__END__;
74 Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...]
75 -c|--[no-]continue run "git rebase --continue" after fixing ChangeLog
76 entries (default: --no-continue)
77 -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range
78 is specified, run git filter-branch on the range
79 -m|--merge-driver %O %A %B act as a git merge-driver on files %O %A %B
80 -h|--help show this help message
81 -w|--[no-]warnings show or suppress warnings (default: show warnings)
86 my $getOptionsResult = GetOptions(
87 'c|continue!' => \$gitRebaseContinue,
88 'f|fix-merged:s' => \&parseFixMerged,
89 'm|merge-driver!' => \$mergeDriver,
90 'h|help' => \$showHelp,
91 'w|warnings!' => \$printWarnings,
94 if (!$getOptionsResult || $showHelp) {
98 my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
100 my @changeLogFiles = removeChangeLogArguments($relativePath);
102 if (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
103 @changeLogFiles = findUnmergedChangeLogs();
106 if (!$mergeDriver && scalar(@ARGV) > 0) {
107 print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
108 undef $getOptionsResult;
109 } elsif (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
110 print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n";
111 undef $getOptionsResult;
112 } elsif ($gitRebaseContinue && !$isGit) {
113 print STDERR "ERROR: --continue may only be used with a git repository\n";
114 undef $getOptionsResult;
115 } elsif (defined $fixMerged && !$isGit) {
116 print STDERR "ERROR: --fix-merged may only be used with a git repository\n";
117 undef $getOptionsResult;
118 } elsif ($mergeDriver && !$isGit) {
119 print STDERR "ERROR: --merge-driver may only be used with a git repository\n";
120 undef $getOptionsResult;
121 } elsif ($mergeDriver && scalar(@ARGV) < 3) {
122 print STDERR "ERROR: --merge-driver expects %O %A %B as arguments\n";
123 undef $getOptionsResult;
126 if (!$getOptionsResult) {
130 if (defined $fixMerged && length($fixMerged) > 0) {
131 my $commitRange = $fixMerged;
132 $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0;
133 fixMergedChangeLogs($commitRange, @changeLogFiles);
134 } elsif ($mergeDriver) {
135 my ($base, $theirs, $ours) = @ARGV;
136 if (mergeChangeLogs($ours, $base, $theirs)) {
138 copy($theirs, $ours) or die $!;
140 exec qw(git merge-file -L THEIRS -L BASE -L OURS), $theirs, $base, $ours;
142 } elsif (@changeLogFiles) {
143 for my $file (@changeLogFiles) {
144 if (defined $fixMerged) {
145 fixOneMergedChangeLog($file);
147 resolveChangeLog($file);
151 print STDERR "ERROR: Unknown combination of switches and arguments.\n";
155 if ($gitRebaseContinue) {
156 if (hasGitUnmergedFiles()) {
157 print "Unmerged files; skipping '$GIT rebase --continue'.\n";
159 print "Running '$GIT rebase --continue'...\n";
160 print `$GIT rebase --continue`;
166 sub canonicalRelativePath($)
168 my ($originalPath) = @_;
169 my $absolutePath = Cwd::abs_path($originalPath);
170 return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
180 if (-e $file && -e "$file.orig" && -e "$file.rej") {
181 return ("$file.rej", "$file.orig", $file);
185 my $escapedFile = escapeSubversionPath($file);
186 open STAT, "-|", $SVN, "status", $escapedFile or die $!;
189 if (!$status || $status !~ m/^C\s+/) {
190 print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings;
194 $fileMine = "${file}.mine" if -e "${file}.mine";
197 open INFO, "-|", $SVN, "info", $escapedFile or die $!;
198 while (my $line = <INFO>) {
199 if ($line =~ m/^Revision: ([0-9]+)/) {
200 $currentRevision = $1;
201 { local $/ = undef; <INFO>; } # Consume rest of input.
205 $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}";
207 my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*");
208 if (scalar(@matchingFiles) > 1) {
209 print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings;
211 $fileOlder = shift @matchingFiles;
214 my $gitPrefix = `$GIT rev-parse --show-prefix`;
216 open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!;
217 while (my $line = <GIT>) {
218 my ($mode, $hash, $stage, $fileName) = split(' ', $line);
221 $fileOlder = "${file}.BASE.$$";
222 $outputFile = $fileOlder;
223 } elsif ($stage == 2) {
224 $fileNewer = "${file}.LOCAL.$$";
225 $outputFile = $fileNewer;
226 } elsif ($stage == 3) {
227 $fileMine = "${file}.REMOTE.$$";
228 $outputFile = $fileMine;
230 die "Unknown file stage: $stage";
232 system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile");
233 die $! if WEXITSTATUS($?);
237 die "Unknown version control system";
240 if (!$fileMine && !$fileOlder && !$fileNewer) {
241 print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings;
242 } elsif (!$fileMine || !$fileOlder || !$fileNewer) {
243 print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings;
246 return ($fileMine, $fileOlder, $fileNewer);
251 my $changeLogFileName = changeLogFileName();
252 return $_[0] if basename($_[0]) eq $changeLogFileName;
254 my $file = File::Spec->catfile($_[0], $changeLogFileName);
255 return $file if -d $_[0] and -e $file;
260 sub findUnmergedChangeLogs()
262 my $statCommand = "";
265 $statCommand = "$SVN stat | grep '^C'";
267 $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M";
273 open STAT, "-|", $statCommand or die "The status failed: $!.\n";
278 if (isSVNVersion16OrNewer()) {
279 $matches = /^([C]).{6} (.+?)[\r\n]*$/;
282 $matches = /^([C]).{5} (.+?)[\r\n]*$/;
286 $file = findChangeLog(normalizePath($file));
287 push @results, $file if $file;
289 print; # error output from svn stat
292 if (/^([U])\t(.+)$/) {
293 my $file = findChangeLog(normalizePath($2));
294 push @results, $file if $file;
296 print; # error output from git diff
305 sub fixMergedChangeLogs($;@)
307 my $revisionRange = shift;
308 my @changedFiles = @_;
310 if (scalar(@changedFiles) < 1) {
311 # Read in list of files changed in $revisionRange
312 open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!;
313 push @changedFiles, <GIT>;
315 die "No changed files in $revisionRange" if scalar(@changedFiles) < 1;
319 my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles;
320 die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1;
322 system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange");
324 # On success, remove the backup refs directory
325 if (WEXITSTATUS($?) == 0) {
326 rmtree(qw(.git/refs/original));
330 sub fixOneMergedChangeLog($)
335 # Read in patch for incorrectly merged ChangeLog entry
338 open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!;
343 # Always checkout the previous commit's copy of the ChangeLog
344 system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file);
345 die $! if WEXITSTATUS($?);
347 # The patch must have 0 or more lines of context, then 1 or more lines
348 # of additions, and then 1 or more lines of context. If not, we skip it.
349 if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) {
350 # Copy the header from the original patch.
351 my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@"));
353 # Generate a new set of line numbers and patch lengths. Our new
354 # patch will start with the lines for the fixed ChangeLog entry,
355 # then have 3 lines of context from the top of the current file to
356 # make the patch apply cleanly.
357 $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n";
359 # We assume that top few lines of the ChangeLog entry are actually
360 # at the bottom of the list of added lines (due to the way the patch
361 # algorithm works), so we simply search through the lines until we
362 # find the date line, then move the rest of the lines to the top.
363 my @patchLines = map { $_ . "\n" } split(/\n/, $6);
364 foreach my $i (0 .. $#patchLines) {
365 if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2} /) {
366 unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i));
371 $newPatch .= join("", @patchLines);
373 # Add 3 lines of context to the end
374 open FILE, "<", $file or die $!;
375 for (my $i = 0; $i < 3; $i++) {
376 $newPatch .= " " . <FILE>;
380 # Apply the new patch
381 open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!;
382 print PATCH $newPatch;
383 close(PATCH) or die $!;
385 # Run "git add" on the fixed ChangeLog file
386 system($GIT, "add", $file);
387 die $! if WEXITSTATUS($?);
389 showStatus($file, 1);
391 # Restore the current copy of the ChangeLog file since we can't repatch it
392 system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file);
393 die $! if WEXITSTATUS($?);
394 print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings;
398 sub hasGitUnmergedFiles()
400 my $output = `$GIT ls-files --unmerged`;
401 return $output ne "";
404 sub isInGitFilterBranch()
406 return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT};
409 sub parseFixMerged($$;$)
411 my ($switchName, $key, $value) = @_;
413 if (defined findChangeLog($key)) {
414 unshift(@ARGV, $key);
424 sub removeChangeLogArguments($)
429 for (my $i = 0; $i < scalar(@ARGV); ) {
430 my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i])));
432 splice(@ARGV, $i, 1);
433 push @results, $file;
442 sub resolveChangeLog($)
446 my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file);
448 return unless $fileMine && $fileOlder && $fileNewer;
450 if (mergeChangeLogs($fileMine, $fileOlder, $fileNewer)) {
451 if ($file ne $fileNewer) {
453 rename($fileNewer, $file) or die $!;
455 unlink($fileMine, $fileOlder);
456 resolveConflict($file);
457 showStatus($file, 1);
460 print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings;
461 unlink($fileMine, $fileOlder, $fileNewer) if $isGit;
465 sub resolveConflict($)
470 my $escapedFile = escapeSubversionPath($file);
471 system($SVN, "resolved", $escapedFile);
472 die $! if WEXITSTATUS($?);
474 system($GIT, "add", $file);
475 die $! if WEXITSTATUS($?);
477 die "Unknown version control system";
483 my ($file, $isConflictResolved) = @_;
486 my $escapedFile = escapeSubversionPath($file);
487 system($SVN, "status", $escapedFile);
489 my @args = qw(--name-status);
490 unshift @args, qw(--cached) if $isConflictResolved;
491 system($GIT, "diff", @args, $file);
493 die "Unknown version control system";