• 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 fixMergedChangeLogs($;@);
48sub fixOneMergedChangeLog($);
49sub hasGitUnmergedFiles();
50sub isInGitFilterBranch();
51sub mergeChanges($$$);
52sub parseFixMerged($$;$);
53sub removeChangeLogArguments($);
54sub resolveChangeLog($);
55sub resolveConflict($);
56sub showStatus($;$);
57sub usageAndExit();
58
59my $isGit = isGit();
60my $isSVN = isSVN();
61
62my $SVN = "svn";
63my $GIT = "git";
64
65my $fixMerged;
66my $gitRebaseContinue = 0;
67my $printWarnings = 1;
68my $showHelp;
69
70my $getOptionsResult = GetOptions(
71    'c|continue!'    => \$gitRebaseContinue,
72    'f|fix-merged:s' => \&parseFixMerged,
73    'h|help'         => \$showHelp,
74    'w|warnings!'    => \$printWarnings,
75);
76
77my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
78
79my @changeLogFiles = removeChangeLogArguments($relativePath);
80
81if (!defined $fixMerged && scalar(@changeLogFiles) == 0) {
82    @changeLogFiles = findUnmergedChangeLogs();
83}
84
85if (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
99sub usageAndExit()
100{
101    print STDERR <<__END__;
102Usage: @{[ 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
113if (!$getOptionsResult || $showHelp) {
114    usageAndExit();
115}
116
117if (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
134if ($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
143exit 0;
144
145sub canonicalRelativePath($)
146{
147    my ($originalPath) = @_;
148    my $absolutePath = Cwd::abs_path($originalPath);
149    return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
150}
151
152sub 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
227sub 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
237sub 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
282sub 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
307sub 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
375sub hasGitUnmergedFiles()
376{
377    my $output = `$GIT ls-files --unmerged`;
378    return $output ne "";
379}
380
381sub isInGitFilterBranch()
382{
383    return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT};
384}
385
386sub 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
435sub 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
450sub 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
468sub 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
491sub 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
506sub 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