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::Copy;
38use File::Path;
39use File::Spec;
40use Getopt::Long;
41use POSIX;
42use VCSUtils;
43
44sub canonicalRelativePath($);
45sub conflictFiles($);
46sub findChangeLog($);
47sub findUnmergedChangeLogs();
48sub fixMergedChangeLogs($;@);
49sub fixOneMergedChangeLog($);
50sub hasGitUnmergedFiles();
51sub isInGitFilterBranch();
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 $mergeDriver = 0;
68my $printWarnings = 1;
69my $showHelp;
70
71my $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
79my $relativePath = isInGitFilterBranch() ? '.' : chdirReturningRelativePath(determineVCSRoot());
80
81my @changeLogFiles = removeChangeLogArguments($relativePath);
82
83if (!defined $fixMerged && !$mergeDriver && scalar(@changeLogFiles) == 0) {
84    @changeLogFiles = findUnmergedChangeLogs();
85}
86
87if (!$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
107sub usageAndExit()
108{
109    print STDERR <<__END__;
110Usage: @{[ 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
122if (!$getOptionsResult || $showHelp) {
123    usageAndExit();
124}
125
126if (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
151if ($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
160exit 0;
161
162sub canonicalRelativePath($)
163{
164    my ($originalPath) = @_;
165    my $absolutePath = Cwd::abs_path($originalPath);
166    return File::Spec->abs2rel($absolutePath, Cwd::getcwd());
167}
168
169sub 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
244sub 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
254sub 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
299sub 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
324sub 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
392sub hasGitUnmergedFiles()
393{
394    my $output = `$GIT ls-files --unmerged`;
395    return $output ne "";
396}
397
398sub isInGitFilterBranch()
399{
400    return exists $ENV{MAPPED_PREVIOUS_COMMIT} && $ENV{MAPPED_PREVIOUS_COMMIT};
401}
402
403sub 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
418sub 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
436sub 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
459sub 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
474sub 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