Completed
Push — 2.x ( 8cf368 )
by Fabian
13:26
created

GitMerge::fixLastLine()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 0
Metric Value
dl 0
loc 9
ccs 0
cts 6
cp 0
rs 9.9666
c 0
b 0
f 0
cc 4
nc 2
nop 2
crap 20
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) {
0 ignored issues
show
Bug introduced by
The class GitWrapper\GitException does not exist. Did you forget a USE statement, or did you not list all dependencies?

Scrutinizer analyzes your composer.json/composer.lock file if available to determine the classes, and functions that are defined by your dependencies.

It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.

Loading history...
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);
0 ignored issues
show
Bug introduced by
It seems like $merged defined by self::postMergeAlter($merged) on line 94 can also be of type boolean; however, PhpMerge\MergeException::__construct() does only seem to accept string|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
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