Home | History | Annotate | Download | only in Scripts
      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::Path;
     38 use File::Spec;
     39 use Getopt::Long;
     40 use POSIX;
     41 use VCSUtils;
     42 
     43 sub canonicalRelativePath($);
     44 sub conflictFiles($);
     45 sub findChangeLog($);
     46 sub findUnmergedChangeLogs();
     47 sub fixMergedChangeLogs($;@);
     48 sub fixOneMergedChangeLog($);
     49 sub hasGitUnmergedFiles();
     50 sub isInGitFilterBranch();
     51 sub mergeChanges($$$);
     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 $printWarnings = 1;
     68 my $showHelp;
     69 
     70 my $getOptionsResult = GetOptions(
     71     'c|continue!'    => \$gitRebaseContinue,
     72     'f|fix-merged:s' => \&parseFixMerged,
     73     'h|help'         => \$showHelp,
     74     'w|warnings!'    => \$printWarnings,
     75 );
     76 
     77 my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
     78 
     79 my @changeLogFiles = removeChangeLogArguments($relativePath);
     80 
     81 if (!defined $fixMerged && scalar(@changeLogFiles) == 0) {
     82     @changeLogFiles = findUnmergedChangeLogs();
     83 }
     84 
     85 if (scalar(@ARGV) > 0) {
     86     print STDERR "ERROR: Files listed on command-line that are not ChangeLogs.\n";
     87     undef $getOptionsResult;
     88 } elsif (!defined $fixMerged && scalar(@changeLogFiles) == 0) {
     89     print STDERR "ERROR: No ChangeLog files listed on command-line or found unmerged.\n";
     90     undef $getOptionsResult;
     91 } elsif ($gitRebaseContinue && !$isGit) {
     92     print STDERR "ERROR: --continue may only be used with a git repository\n";
     93     undef $getOptionsResult;
     94 } elsif (defined $fixMerged && !$isGit) {
     95     print STDERR "ERROR: --fix-merged may only be used with a git repository\n";
     96     undef $getOptionsResult;
     97 }
     98 
     99 sub usageAndExit()
    100 {
    101     print STDERR <<__END__;
    102 Usage: @{[ basename($0) ]} [options] [path/to/ChangeLog] [path/to/another/ChangeLog ...]
    103   -c|--[no-]continue               run "git rebase --continue" after fixing ChangeLog
    104                                    entries (default: --no-continue)
    105   -f|--fix-merged [revision-range] fix git-merged ChangeLog entries; if a revision-range
    106                                    is specified, run git filter-branch on the range
    107   -h|--help                        show this help message
    108   -w|--[no-]warnings               show or suppress warnings (default: show warnings)
    109 __END__
    110     exit 1;
    111 }
    112 
    113 if (!$getOptionsResult || $showHelp) {
    114     usageAndExit();
    115 }
    116 
    117 if (defined $fixMerged && length($fixMerged) > 0) {
    118     my $commitRange = $fixMerged;
    119     $commitRange = $commitRange . "..HEAD" if index($commitRange, "..") < 0;
    120     fixMergedChangeLogs($commitRange, @changeLogFiles);
    121 } elsif (@changeLogFiles) {
    122     for my $file (@changeLogFiles) {
    123         if (defined $fixMerged) {
    124             fixOneMergedChangeLog($file);
    125         } else {
    126             resolveChangeLog($file);
    127         }
    128     }
    129 } else {
    130     print STDERR "ERROR: Unknown combination of switches and arguments.\n";
    131     usageAndExit();
    132 }
    133 
    134 if ($gitRebaseContinue) {
    135     if (hasGitUnmergedFiles()) {
    136         print "Unmerged files; skipping '$GIT rebase --continue'.\n";
    137     } else {
    138         print "Running '$GIT rebase --continue'...\n";
    139         print `$GIT rebase --continue`;
    140     }
    141 }
    142 
    143 exit 0;
    144 
    145 sub canonicalRelativePath($)
    146 {
    147     my ($originalPath) = @_;
    148     my $absolutePath = Cwd::abs_path($originalPath);
    149     return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
    150 }
    151 
    152 sub conflictFiles($)
    153 {
    154     my ($file) = @_;
    155     my $fileMine;
    156     my $fileOlder;
    157     my $fileNewer;
    158 
    159     if (-e $file && -e "$file.orig" && -e "$file.rej") {
    160         return ("$file.rej", "$file.orig", $file);
    161     }
    162 
    163     if ($isSVN) {
    164         open STAT, "-|", $SVN, "status", $file or die $!;
    165         my $status = <STAT>;
    166         close STAT;
    167         if (!$status || $status !~ m/^C\s+/) {
    168             print STDERR "WARNING: ${file} is not in a conflicted state.\n" if $printWarnings;
    169             return ();
    170         }
    171 
    172         $fileMine = "${file}.mine" if -e "${file}.mine";
    173 
    174         my $currentRevision;
    175         open INFO, "-|", $SVN, "info", $file or die $!;
    176         while (my $line = <INFO>) {
    177             if ($line =~ m/^Revision: ([0-9]+)/) {
    178                 $currentRevision = $1;
    179                 { local $/ = undef; <INFO>; }  # Consume rest of input.
    180             }
    181         }
    182         close INFO;
    183         $fileNewer = "${file}.r${currentRevision}" if -e "${file}.r${currentRevision}";
    184 
    185         my @matchingFiles = grep { $_ ne $fileNewer } glob("${file}.r[0-9][0-9]*");
    186         if (scalar(@matchingFiles) > 1) {
    187             print STDERR "WARNING: Too many conflict files exist for ${file}!\n" if $printWarnings;
    188         } else {
    189             $fileOlder = shift @matchingFiles;
    190         }
    191     } elsif ($isGit) {
    192         my $gitPrefix = `$GIT rev-parse --show-prefix`;
    193         chomp $gitPrefix;
    194         open GIT, "-|", $GIT, "ls-files", "--unmerged", $file or die $!;
    195         while (my $line = <GIT>) {
    196             my ($mode, $hash, $stage, $fileName) = split(' ', $line);
    197             my $outputFile;
    198             if ($stage == 1) {
    199                 $fileOlder = "${file}.BASE.$$";
    200                 $outputFile = $fileOlder;
    201             } elsif ($stage == 2) {
    202                 $fileNewer = "${file}.LOCAL.$$";
    203                 $outputFile = $fileNewer;
    204             } elsif ($stage == 3) {
    205                 $fileMine = "${file}.REMOTE.$$";
    206                 $outputFile = $fileMine;
    207             } else {
    208                 die "Unknown file stage: $stage";
    209             }
    210             system("$GIT cat-file blob :${stage}:${gitPrefix}${file} > $outputFile");
    211             die $! if WEXITSTATUS($?);
    212         }
    213         close GIT or die $!;
    214     } else {
    215         die "Unknown version control system";
    216     }
    217 
    218     if (!$fileMine && !$fileOlder && !$fileNewer) {
    219         print STDERR "WARNING: ${file} does not need merging.\n" if $printWarnings;
    220     } elsif (!$fileMine || !$fileOlder || !$fileNewer) {
    221         print STDERR "WARNING: ${file} is missing some conflict files.\n" if $printWarnings;
    222     }
    223 
    224     return ($fileMine, $fileOlder, $fileNewer);
    225 }
    226 
    227 sub findChangeLog($)
    228 {
    229     return $_[0] if basename($_[0]) eq "ChangeLog";
    230 
    231     my $file = File::Spec->catfile($_[0], "ChangeLog");
    232     return $file if -d $_[0] and -e $file;
    233 
    234     return undef;
    235 }
    236 
    237 sub findUnmergedChangeLogs()
    238 {
    239     my $statCommand = "";
    240 
    241     if ($isSVN) {
    242         $statCommand = "$SVN stat | grep '^C'";
    243     } elsif ($isGit) {
    244         $statCommand = "$GIT diff -r --name-status --diff-filter=U -C -C -M";
    245     } else {
    246         return ();
    247     }
    248 
    249     my @results = ();
    250     open STAT, "-|", $statCommand or die "The status failed: $!.\n";
    251     while (<STAT>) {
    252         if ($isSVN) {
    253             my $matches;
    254             my $file;
    255             if (isSVNVersion16OrNewer()) {
    256                 $matches = /^([C]).{6} (.+?)[\r\n]*$/;
    257                 $file = $2;
    258             } else {
    259                 $matches = /^([C]).{5} (.+?)[\r\n]*$/;
    260                 $file = $2;
    261             }
    262             if ($matches) {
    263                 $file = findChangeLog(normalizePath($file));
    264                 push @results, $file if $file;
    265             } else {
    266                 print;  # error output from svn stat
    267             }
    268         } elsif ($isGit) {
    269             if (/^([U])\t(.+)$/) {
    270                 my $file = findChangeLog(normalizePath($2));
    271                 push @results, $file if $file;
    272             } else {
    273                 print;  # error output from git diff
    274             }
    275         }
    276     }
    277     close STAT;
    278 
    279     return @results;
    280 }
    281 
    282 sub fixMergedChangeLogs($;@)
    283 {
    284     my $revisionRange = shift;
    285     my @changedFiles = @_;
    286 
    287     if (scalar(@changedFiles) < 1) {
    288         # Read in list of files changed in $revisionRange
    289         open GIT, "-|", $GIT, "diff", "--name-only", $revisionRange or die $!;
    290         push @changedFiles, <GIT>;
    291         close GIT or die $!;
    292         die "No changed files in $revisionRange" if scalar(@changedFiles) < 1;
    293         chomp @changedFiles;
    294     }
    295 
    296     my @changeLogs = grep { defined $_ } map { findChangeLog($_) } @changedFiles;
    297     die "No changed ChangeLog files in $revisionRange" if scalar(@changeLogs) < 1;
    298 
    299     system("$GIT filter-branch --tree-filter 'PREVIOUS_COMMIT=\`$GIT rev-parse \$GIT_COMMIT^\` && MAPPED_PREVIOUS_COMMIT=\`map \$PREVIOUS_COMMIT\` \"$0\" -f \"" . join('" "', @changeLogs) . "\"' $revisionRange");
    300 
    301     # On success, remove the backup refs directory
    302     if (WEXITSTATUS($?) == 0) {
    303         rmtree(qw(.git/refs/original));
    304     }
    305 }
    306 
    307 sub fixOneMergedChangeLog($)
    308 {
    309     my $file = shift;
    310     my $patch;
    311 
    312     # Read in patch for incorrectly merged ChangeLog entry
    313     {
    314         local $/ = undef;
    315         open GIT, "-|", $GIT, "diff", ($ENV{GIT_COMMIT} || "HEAD") . "^", $file or die $!;
    316         $patch = <GIT>;
    317         close GIT or die $!;
    318     }
    319 
    320     # Always checkout the previous commit's copy of the ChangeLog
    321     system($GIT, "checkout", $ENV{MAPPED_PREVIOUS_COMMIT} || "HEAD^", $file);
    322     die $! if WEXITSTATUS($?);
    323 
    324     # The patch must have 0 or more lines of context, then 1 or more lines
    325     # of additions, and then 1 or more lines of context.  If not, we skip it.
    326     if ($patch =~ /\n@@ -(\d+),(\d+) \+(\d+),(\d+) @@\n( .*\n)*((\+.*\n)+)( .*\n)+$/m) {
    327         # Copy the header from the original patch.
    328         my $newPatch = substr($patch, 0, index($patch, "@@ -${1},${2} +${3},${4} @@"));
    329 
    330         # Generate a new set of line numbers and patch lengths.  Our new
    331         # patch will start with the lines for the fixed ChangeLog entry,
    332         # then have 3 lines of context from the top of the current file to
    333         # make the patch apply cleanly.
    334         $newPatch .= "@@ -1,3 +1," . ($4 - $2 + 3) . " @@\n";
    335 
    336         # We assume that top few lines of the ChangeLog entry are actually
    337         # at the bottom of the list of added lines (due to the way the patch
    338         # algorithm works), so we simply search through the lines until we
    339         # find the date line, then move the rest of the lines to the top.
    340         my @patchLines = map { $_ . "\n" } split(/\n/, $6);
    341         foreach my $i (0 .. $#patchLines) {
    342             if ($patchLines[$i] =~ /^\+\d{4}-\d{2}-\d{2}  /) {
    343                 unshift(@patchLines, splice(@patchLines, $i, scalar(@patchLines) - $i));
    344                 last;
    345             }
    346         }
    347 
    348         $newPatch .= join("", @patchLines);
    349 
    350         # Add 3 lines of context to the end
    351         open FILE, "<", $file or die $!;
    352         for (my $i = 0; $i < 3; $i++) {
    353             $newPatch .= " " . <FILE>;
    354         }
    355         close FILE;
    356 
    357         # Apply the new patch
    358         open(PATCH, "| patch -p1 $file > " . File::Spec->devnull()) or die $!;
    359         print PATCH $newPatch;
    360         close(PATCH) or die $!;
    361 
    362         # Run "git add" on the fixed ChangeLog file
    363         system($GIT, "add", $file);
    364         die $! if WEXITSTATUS($?);
    365 
    366         showStatus($file, 1);
    367     } elsif ($patch) {
    368         # Restore the current copy of the ChangeLog file since we can't repatch it
    369         system($GIT, "checkout", $ENV{GIT_COMMIT} || "HEAD", $file);
    370         die $! if WEXITSTATUS($?);
    371         print STDERR "WARNING: Last change to ${file} could not be fixed and re-merged.\n" if $printWarnings;
    372     }
    373 }
    374 
    375 sub hasGitUnmergedFiles()
    376 {
    377     my $output = `$GIT ls-files --unmerged`;
    378     return $output ne "";
    379 }
    380 
    381 sub isInGitFilterBranch()
    382 {
    383     return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT};
    384 }
    385 
    386 sub mergeChanges($$$)
    387 {
    388     my ($fileMine, $fileOlder, $fileNewer) = @_;
    389 
    390     my $traditionalReject = $fileMine =~ /\.rej$/ ? 1 : 0;
    391 
    392     local $/ = undef;
    393 
    394     my $patch;
    395     if ($traditionalReject) {
    396         open(DIFF, "<", $fileMine) or die $!;
    397         $patch = <DIFF>;
    398         close(DIFF);
    399         rename($fileMine, "$fileMine.save");
    400         rename($fileOlder, "$fileOlder.save");
    401     } else {
    402         open(DIFF, "-|", qw(diff -u -a --binary), $fileOlder, $fileMine) or die $!;
    403         $patch = <DIFF>;
    404         close(DIFF);
    405     }
    406 
    407     unlink("${fileNewer}.orig");
    408     unlink("${fileNewer}.rej");
    409 
    410     open(PATCH, "| patch --fuzz=3 --binary $fileNewer > " . File::Spec->devnull()) or die $!;
    411     print PATCH fixChangeLogPatch($patch);
    412     close(PATCH);
    413 
    414     my $result;
    415 
    416     # Refuse to merge the patch if it did not apply cleanly
    417     if (-e "${fileNewer}.rej") {
    418         unlink("${fileNewer}.rej");
    419         unlink($fileNewer);
    420         rename("${fileNewer}.orig", $fileNewer);
    421         $result = 0;
    422     } else {
    423         unlink("${fileNewer}.orig");
    424         $result = 1;
    425     }
    426 
    427     if ($traditionalReject) {
    428         rename("$fileMine.save", $fileMine);
    429         rename("$fileOlder.save", $fileOlder);
    430     }
    431 
    432     return $result;
    433 }
    434 
    435 sub parseFixMerged($$;$)
    436 {
    437     my ($switchName, $key, $value) = @_;
    438     if (defined $key) {
    439         if (defined findChangeLog($key)) {
    440             unshift(@ARGV, $key);
    441             $fixMerged = "";
    442         } else {
    443             $fixMerged = $key;
    444         }
    445     } else {
    446         $fixMerged = "";
    447     }
    448 }
    449 
    450 sub removeChangeLogArguments($)
    451 {
    452     my ($baseDir) = @_;
    453     my @results = ();
    454 
    455     for (my $i = 0; $i < scalar(@ARGV); ) {
    456         my $file = findChangeLog(canonicalRelativePath(File::Spec->catfile($baseDir, $ARGV[$i])));
    457         if (defined $file) {
    458             splice(@ARGV, $i, 1);
    459             push @results, $file;
    460         } else {
    461             $i++;
    462         }
    463     }
    464 
    465     return @results;
    466 }
    467 
    468 sub resolveChangeLog($)
    469 {
    470     my ($file) = @_;
    471 
    472     my ($fileMine, $fileOlder, $fileNewer) = conflictFiles($file);
    473 
    474     return unless $fileMine && $fileOlder && $fileNewer;
    475 
    476     if (mergeChanges($fileMine, $fileOlder, $fileNewer)) {
    477         if ($file ne $fileNewer) {
    478             unlink($file);
    479             rename($fileNewer, $file) or die $!;
    480         }
    481         unlink($fileMine, $fileOlder);
    482         resolveConflict($file);
    483         showStatus($file, 1);
    484     } else {
    485         showStatus($file);
    486         print STDERR "WARNING: ${file} could not be merged using fuzz level 3.\n" if $printWarnings;
    487         unlink($fileMine, $fileOlder, $fileNewer) if $isGit;
    488     }
    489 }
    490 
    491 sub resolveConflict($)
    492 {
    493     my ($file) = @_;
    494 
    495     if ($isSVN) {
    496         system($SVN, "resolved", $file);
    497         die $! if WEXITSTATUS($?);
    498     } elsif ($isGit) {
    499         system($GIT, "add", $file);
    500         die $! if WEXITSTATUS($?);
    501     } else {
    502         die "Unknown version control system";
    503     }
    504 }
    505 
    506 sub showStatus($;$)
    507 {
    508     my ($file, $isConflictResolved) = @_;
    509 
    510     if ($isSVN) {
    511         system($SVN, "status", $file);
    512     } elsif ($isGit) {
    513         my @args = qw(--name-status);
    514         unshift @args, qw(--cached) if $isConflictResolved;
    515         system($GIT, "diff", @args, $file);
    516     } else {
    517         die "Unknown version control system";
    518     }
    519 }
    520 
    521