Completed
Pull Request — master (#4)
by Fabian
36:38 queued 21:45
created

GitMerge::mergeFile()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 26
ccs 17
cts 17
cp 1
rs 9.504
c 0
b 0
f 0
cc 2
nc 2
nop 4
crap 2
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) {
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...
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 7
            $this->git->checkout('original');
122 7
            $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
            $base = self::fixLastLine($lastConflict->getBase(), $rawBase);
293 1
            $remote = self::fixLastLine($lastConflict->getRemote(), self::splitStringByLines($remoteText));
294 1
            $local = self::fixLastLine($lastConflict->getLocal(), self::splitStringByLines($localText));
295
296 1
            $newConflict = new MergeConflict(
297 1
                $base,
298 1
                $remote,
299 1
                $local,
300 1
                $lastConflict->getBaseLine(),
301 1
                $lastConflict->getMergedLine()
302
            );
303 1
            $conflicts[key($conflicts)] = $newConflict;
304
305 1
            $lastMerged = end($merged);
306 1
            $lastRemote = end($remote);
307 1
            if ($lastMerged !== $lastRemote && rtrim($lastMerged) === $lastRemote) {
308 1
                $merged[key($merged)] = $lastRemote;
309
            }
310
        }
311 5
    }
312
313
    /**
314
     * @param array $lines
315
     * @param array $all
316
     *
317
     * @return array
318
     */
319 1
    protected static function fixLastLine(array $lines, array $all): array
320
    {
321 1
        $last = end($all);
322 1
        $lastLine = end($lines);
323 1
        if ($lastLine !== false && $last !== $lastLine && rtrim($lastLine) === $last) {
324 1
            $lines[key($lines)] = $last;
325
        }
326 1
        return $lines;
327
    }
328
329
    /**
330
     * Constructor, not setting anything up.
331
     *
332
     * @param \GitWrapper\GitWrapper $wrapper
333
     */
334 11
    public function __construct(GitWrapper $wrapper = null)
335
    {
336 11
        if (!$wrapper) {
337 11
            $wrapper = new GitWrapper();
338
        }
339 11
        $this->wrapper = $wrapper;
340 11
        $this->conflict = '';
341 11
        $this->git = null;
342 11
        $this->dir = null;
343 11
    }
344
345
    /**
346
     * Set up the git wrapper and the temporary directory.
347
     */
348 8
    protected function setup()
349
    {
350 8
        if (!$this->dir) {
351
            // Greate a temporary directory.
352 8
            $tempfile = tempnam(sys_get_temp_dir(), '');
353 8
            mkdir($tempfile . '.git');
354 8
            if (file_exists($tempfile)) {
355 8
                unlink($tempfile);
356
            }
357 8
            $this->dir = $tempfile . '.git';
358 8
            $this->git = $this->wrapper->init($this->dir);
359
        }
360 8
        if ($this->git) {
361 8
            $this->git->config('user.name', 'GitMerge');
362 8
            $this->git->config('user.email', '[email protected]');
363 8
            $this->git->config('merge.conflictStyle', 'diff3');
364
        }
365 8
    }
366
367
    /**
368
     * Clean the temporary directory used for merging.
369
     */
370 1
    protected function cleanup()
371
    {
372 1
        if (is_dir($this->dir)) {
373
            // Recursively delete all files and folders.
374 1
            $files = new \RecursiveIteratorIterator(
375 1
                new \RecursiveDirectoryIterator($this->dir, \RecursiveDirectoryIterator::SKIP_DOTS),
376 1
                \RecursiveIteratorIterator::CHILD_FIRST
377
            );
378
379 1
            foreach ($files as $fileinfo) {
380 1
                if ($fileinfo->isDir()) {
381 1
                    rmdir($fileinfo->getRealPath());
382
                } else {
383 1
                    unlink($fileinfo->getRealPath());
384
                }
385
            }
386 1
            rmdir($this->dir);
387 1
            unset($this->git);
388
        }
389 1
    }
390
391
    /**
392
     * Clean up the temporary git directory.
393
     */
394 1
    public function __destruct()
395
    {
396 1
        $this->cleanup();
397 1
    }
398
}
399