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::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 
    489