Completed
Push — 2.x ( 8cf368...66b52d )
by Fabian
25:37 queued 10:39
created

GitMerge::fixLastLine()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

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