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