Test Failed
Push — upgrade ( c5515b...0503d2 )
by Fabian
13:33
created

GitMerge::__destruct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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