Passed
Pull Request — master (#4)
by Fabian
14:31
created

GitMerge::merge()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 27

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 27
ccs 17
cts 17
cp 1
rs 9.488
c 0
b 0
f 0
cc 3
nc 3
nop 3
crap 3
1
<?php
2
/**
3
 * This file is part of the php-merge package.
4
 *
5
 * (c) Fabian Bircher <[email protected]>
6
 *
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 */
10
11
namespace PhpMerge;
12
13
use GitWrapper\GitWrapper;
14
use GitWrapper\GitException;
15
use PhpMerge\internal\Line;
16
use PhpMerge\internal\Hunk;
17
use PhpMerge\internal\PhpMergeBase;
18
use SebastianBergmann\Diff\Differ;
19
20
/**
21
 * Class GitMerge merges three strings with git as the backend.
22
 *
23
 * A temporary directory is created and a git repository is initialised in it,
24
 * then a file is created within the directory containing the string to merge.
25
 * This was the original merge class but while it is nice not to have to deal
26
 * with merging, it has a considerable performance implication. So now this
27
 * implementation serves as a reference to make sure the other classes behave.
28
 *
29
 * @package   PhpMerge
30
 * @author    Fabian Bircher <[email protected]>
31
 * @copyright 2015 Fabian Bircher <[email protected]>
32
 * @license   https://opensource.org/licenses/MIT
33
 * @version   Release: @package_version@
34
 * @link      http://github.com/bircher/php-merge
35
 * @category  library
36
 */
37
final class GitMerge extends PhpMergeBase implements PhpMergeInterface
38
{
39
40
    /**
41
     * The git working directory.
42
     *
43
     * @var \GitWrapper\GitWorkingCopy
44
     */
45
    protected $git;
46
47
    /**
48
     * The git wrapper to use for merging.
49
     *
50
     * @var \GitWrapper\GitWrapper
51
     */
52
    protected $wrapper;
53
54
    /**
55
     * The temporary directory in which git can work.
56
     * @var string
57
     */
58
    protected $dir;
59
60
    /**
61
     * The text of the last conflict
62
     * @var string
63
     */
64
    protected $conflict;
65
66
    /**
67
     * {@inheritdoc}
68
     */
69 44
    public function merge(string $base, string $remote, string $local) : string
70
    {
71
72
        // Skip merging if there is nothing to do.
73 44
        if ($merged = PhpMergeBase::simpleMerge($base, $remote, $local)) {
74 12
            return $merged;
75
        }
76
77
        // Only set up the git wrapper if we really merge something.
78 32
        $this->setup();
79
80 32
        $file = tempnam($this->dir, '');
81 32
        try {
82 32
            return $this->mergeFile($file, $base, $remote, $local);
83 32
        } catch (GitException $e) {
84
            // Get conflicts by reading from the file.
85 32
            $conflicts = [];
86 16
            $merged = [];
87 20
            self::getConflicts($file, $base, $remote, $local, $conflicts, $merged);
88
            $merged = implode("", $merged);
89 20
            // Set the file to the merged one with the first text for conflicts.
90 20
            file_put_contents($file, $merged);
91 20
            $this->git->add($file);
92 20
            $this->git->commit('Resolve merge conflict.');
93 20
            throw new MergeException('A merge conflict has occurred.', $conflicts, $merged, 0, $e);
94
        }
95 20
    }
96 20
97 20
    /**
98 20
     * Merge three strings in a specified file.
99
     *
100
     * @param string $file
101
     *   The file name in the git repository to which the content is written.
102
     * @param string $base
103
     *   The common base text.
104
     * @param string $remote
105
     *   The first changed text.
106
     * @param string $local
107
     *   The second changed text
108
     *
109
     * @return string
110
     *   The merged text.
111
     */
112
    protected function mergeFile(string $file, string $base, string $remote, string $local) : string
113
    {
114
        file_put_contents($file, $base);
115
        $this->git->add($file);
116
        $this->git->commit('Add base.');
117 32
118
        if (!in_array('original', $this->git->getBranches()->all())) {
119 32
            $this->git->checkoutNewBranch('original');
120 32
        } else {
121 32
            $this->git->checkout('original');
122
            $this->git->rebase('master');
123 32
        }
124 32
125
        file_put_contents($file, $remote);
126 28
        $this->git->add($file);
127 28
        $this->git->commit('Add remote.');
128
129
        $this->git->checkout('master');
130 32
131 32
        file_put_contents($file, $local);
132 32
        $this->git->add($file);
133
        $this->git->commit('Add local.');
134 32
135
        $this->git->merge('original');
136 32
        return file_get_contents($file);
137 32
    }
138 32
139
    /**
140 32
     * Get the conflicts from a file which is left with merge conflicts.
141 16
     *
142
     * @param string $file
143
     *   The file name.
144
     * @param string $baseText
145
     *   The original text used for merging.
146
     * @param string $remoteText
147
     *   The first chaned text.
148
     * @param string $localText
149
     *   The second changed text.
150
     * @param MergeConflict[] $conflicts
151
     *   The merge conflicts will be apended to this array.
152
     * @param string[] $merged
153
     *   The merged text resolving conflicts by using the first set of changes.
154
     */
155
    protected static function getConflicts($file, $baseText, $remoteText, $localText, &$conflicts, &$merged)
156
    {
157
        $raw = new \ArrayObject(self::splitStringByLines(file_get_contents($file)));
158
        $lineIterator = $raw->getIterator();
159
        $state = 'unchanged';
160 20
        $conflictIndicator = [
161
            '<<<<<<< HEAD' => 'local',
162 20
            '||||||| merged common ancestors' => 'base',
163 20
            '=======' => 'remote',
164 20
            '>>>>>>> original' => 'end conflict',
165
        ];
166 20
167
        // Create hunks from the text diff.
168
        $differ = new Differ();
169
        $remoteDiff = Line::createArray($differ->diffToArray($baseText, $remoteText));
170
        $localDiff = Line::createArray($differ->diffToArray($baseText, $localText));
171
172
        $remote_hunks = new \ArrayObject(Hunk::createArray($remoteDiff));
173 20
        $local_hunks = new \ArrayObject(Hunk::createArray($localDiff));
174 20
175 20
        $remoteIterator = $remote_hunks->getIterator();
176
        $localIterator = $local_hunks->getIterator();
177 20
178 20
        $base = [];
179
        $remote = [];
180 20
        $local = [];
181 20
        $lineNumber = -1;
182
        $newLine = 0;
183 20
        $skipedLines = 0;
184 20
        $addingConflict = false;
185 20
        // Loop over all the lines in the file.
186 20
        while ($lineIterator->valid()) {
187 20
            $line = $lineIterator->current();
188 20
            if (array_key_exists(trim($line), $conflictIndicator)) {
189 20
                // Check for a line matching a conflict indicator.
190
                $state = $conflictIndicator[trim($line)];
191 20
                $skipedLines++;
192 20
                if ($state == 'end conflict') {
193 20
                    // We just treated a merge conflict.
194
                    $conflicts[] = new MergeConflict($base, $remote, $local, $lineNumber, $newLine);
195 20
                    if ($lineNumber == -1) {
196 20
                        $lineNumber = 0;
197 20
                    }
198
                    $lineNumber += count($base);
199 20
                    $newLine += count($remote);
200 20
                    $base = [];
201 4
                    $remote = [];
202
                    $local = [];
203 20
                    $remoteIterator->next();
204 20
                    $localIterator->next();
205 20
206 20
                    if ($addingConflict) {
207 20
                        // Advance the counter for conflicts with adding.
208 20
                        $lineNumber++;
209 20
                        $newLine++;
210
                        $addingConflict = false;
211 20
                    }
212
                    $state = 'unchanged';
213 8
                }
214 8
            } else {
215 8
                switch ($state) {
216
                    case 'local':
217 20
                        $local[] = $line;
218
                        $skipedLines++;
219
                        break;
220
                    case 'base':
221 20
                        $base[] = $line;
222 20
                        $skipedLines++;
223 20
                        if ($lineNumber == -1) {
224 20
                            $lineNumber = 0;
225 20
                        }
226 20
                        break;
227 20
                    case 'remote':
228 20
                        $remote[] = $line;
229 4
                        $merged[] = $line;
230
                        break;
231 20
                    case 'unchanged':
232 20
                        if ($lineNumber == -1) {
233 20
                            $lineNumber = 0;
234 20
                        }
235 20
                        $merged[] = $line;
236 20
237 20
                        /** @var Hunk $r */
238 16
                        $r = $remoteIterator->current();
239
                        /** @var Hunk $l */
240 20
                        $l = $localIterator->current();
241
242
                        if ($r == $l) {
243 20
                            // If they are the same, treat only one.
244
                            $localIterator->next();
245 20
                            $l = $localIterator->current();
246
                        }
247 20
248
                        // A hunk has been successfully merged, so we can just
249 20
                        // tally the lines added and removed and skip forward.
250 20
                        if ($r && $r->getStart() == $lineNumber) {
251
                            if (!$r->hasIntersection($l)) {
252
                                $lineNumber += count($r->getRemovedLines());
253
                                $newLine += count($r->getAddedLines());
254
                                $lineIterator->seek($newLine + $skipedLines - 1);
255 20
                                $remoteIterator->next();
256 16
                            } else {
257 8
                                // If the conflict occurs on added lines, the
258 8
                                // next line in the merge will deal with it.
259 8
                                if ($r->getType() == Hunk::ADDED && $l->getType() == Hunk::ADDED) {
260 8
                                    $addingConflict = true;
261
                                } else {
262
                                    $lineNumber++;
263
                                    $newLine++;
264 12
                                }
265 8
                            }
266
                        } elseif ($l && $l->getStart() == $lineNumber) {
267 4
                            if (!$l->hasIntersection($r)) {
268 16
                                $lineNumber += count($l->getRemovedLines());
269
                                $newLine += count($l->getAddedLines());
270
                                $lineIterator->seek($newLine + $skipedLines - 1);
271 20
                                $localIterator->next();
272 8
                            } else {
273 4
                                $lineNumber++;
274 4
                                $newLine++;
275 4
                            }
276 4
                        } else {
277
                            $lineNumber++;
278 4
                            $newLine++;
279 8
                        }
280
                        break;
281
                }
282 20
            }
283 20
            $lineIterator->next();
284
        }
285 20
286
        $rawBase = self::splitStringByLines($baseText);
287
        $lastConflict = end($conflicts);
288 20
        // Check if the last conflict was at the end of the text.
289
        if ($lastConflict->getBaseLine() + count($lastConflict->getBase()) == count($rawBase)) {
290 20
            // Fix the last lines of all the texts as we can not know from
291
            // the merged text if there was a new line at the end or not.
292
            $base = self::fixLastLine($lastConflict->getBase(), $rawBase);
293
            $remote = self::fixLastLine($lastConflict->getRemote(), self::splitStringByLines($remoteText));
294
            $local = self::fixLastLine($lastConflict->getLocal(), self::splitStringByLines($localText));
295
296 32
            $newConflict = new MergeConflict($base, $remote, $local, $lastConflict->getBaseLine(), $lastConflict->getMergedLine());
297
            $conflicts[key($conflicts)] = $newConflict;
298
299 32
            $lastMerged = end($merged);
300
            $lastRemote = end($remote);
301
            if ($lastMerged !== $lastRemote && rtrim($lastMerged) === $lastRemote) {
302
                $merged[key($merged)] = $lastRemote;
303
            }
304
        }
305
    }
306 32
307
    /**
308
     * @param array $lines
309 32
     * @param array $all
310
     *
311
     * @return array
312
     */
313
    protected static function fixLastLine(array $lines, array $all): array
314
    {
315
        $last = end($all);
316
        $lastLine = end($lines);
317 44
        if ($lastLine !== false && $last !== $lastLine && rtrim($lastLine) === $last) {
318
            $lines[key($lines)] = $last;
319 44
        }
320 44
        return $lines;
321
    }
322 44
323 44
    /**
324 44
     * Constructor, not setting anything up.
325 44
     *
326 44
     * @param \GitWrapper\GitWrapper $wrapper
327
     */
328
    public function __construct(GitWrapper $wrapper = null)
329
    {
330
        if (!$wrapper) {
331 32
            $wrapper = new GitWrapper();
332
        }
333 32
        $this->wrapper = $wrapper;
334
        $this->conflict = '';
335 32
        $this->git = null;
336 32
        $this->dir = null;
337 32
    }
338 32
339
    /**
340 32
     * Set up the git wrapper and the temporary directory.
341 32
     */
342
    protected function setup()
343 32
    {
344 32
        if (!$this->dir) {
345 32
            // Greate a temporary directory.
346 32
            $tempfile = tempnam(sys_get_temp_dir(), '');
347
            mkdir($tempfile . '.git');
348
            if (file_exists($tempfile)) {
349
                unlink($tempfile);
350
            }
351 4
            $this->dir = $tempfile . '.git';
352
            $this->git = $this->wrapper->init($this->dir);
353 4
        }
354
        if ($this->git) {
355 4
            $this->git->config('user.name', 'GitMerge');
356 4
            $this->git->config('user.email', '[email protected]');
357 4
            $this->git->config('merge.conflictStyle', 'diff3');
358
        }
359
    }
360 4
361 4
    /**
362 4
     * Clean the temporary directory used for merging.
363
     */
364 4
    protected function cleanup()
365
    {
366
        if (is_dir($this->dir)) {
367 4
            // Recursively delete all files and folders.
368 4
            $files = new \RecursiveIteratorIterator(
369
                new \RecursiveDirectoryIterator($this->dir, \RecursiveDirectoryIterator::SKIP_DOTS),
370 4
                \RecursiveIteratorIterator::CHILD_FIRST
371
            );
372
373
            foreach ($files as $fileinfo) {
374
                if ($fileinfo->isDir()) {
375 4
                    rmdir($fileinfo->getRealPath());
376
                } else {
377 4
                    unlink($fileinfo->getRealPath());
378 4
                }
379
            }
380
            rmdir($this->dir);
381
            unset($this->git);
382
        }
383
    }
384
385
    /**
386
     * Clean up the temporary git directory.
387
     */
388
    public function __destruct()
389
    {
390
        $this->cleanup();
391
    }
392
}
393