Completed
Pull Request — master (#153)
by Graham
09:38
created

GitWorkingCopy::apply()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 4
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 1
1
<?php declare(strict_types=1);
2
3
namespace GitWrapper;
4
5
/**
6
 * Interacts with a working copy.
7
 *
8
 * All commands executed via an instance of this class act on the working copy
9
 * that is set through the constructor.
10
 */
11
final class GitWorkingCopy
12
{
13
    /**
14
     * The GitWrapper object that likely instantiated this class.
15
     *
16
     * @var GitWrapper
17
     */
18
    private $gitWrapper;
19
20
    /**
21
     * Path to the directory containing the working copy.
22
     *
23
     * @var string
24
     */
25
    private $directory;
26
27
    /**
28
     * A boolean flagging whether the repository is cloned.
29
     *
30
     * If the variable is null, the a rudimentary check will be performed to see
31
     * if the directory looks like it is a working copy.
32
     *
33
     * @var bool|null
34
     */
35
    private $cloned;
36
37
    public function __construct(GitWrapper $gitWrapper, string $directory)
38
    {
39
        $this->gitWrapper = $gitWrapper;
40
        $this->directory = $directory;
41
    }
42
43
    public function getWrapper(): GitWrapper
44
    {
45
        return $this->gitWrapper;
46
    }
47
48
    public function getDirectory(): string
49
    {
50
        return $this->directory;
51
    }
52
53
    public function setCloned(bool $cloned): void
54
    {
55
        $this->cloned = $cloned;
56
    }
57
58
    /**
59
     * Checks whether a repository has already been cloned to this directory.
60
     *
61
     * If the flag is not set, test if it looks like we're at a git directory.
62
     */
63
    public function isCloned(): bool
64
    {
65
        if ($this->cloned === null) {
66
            $gitDir = $this->directory;
67
            if (is_dir($gitDir . '/.git')) {
68
                $gitDir .= '/.git';
69
            }
70
71
            $this->cloned = is_dir($gitDir . '/objects') && is_dir($gitDir . '/refs') && is_file($gitDir . '/HEAD');
72
        }
73
74
        return $this->cloned;
75
    }
76
77
    /**
78
     * Runs a Git command and returns the output.
79
     *
80
     * @param mixed[] $argsAndOptions
81
     */
82
    public function run(string $command, array $argsAndOptions = [], bool $setDirectory = true, ?string $input = null): string
83
    {
84
        $command = new GitCommand($command, ...$argsAndOptions);
85
        if ($setDirectory) {
86
            $command->setDirectory($this->directory);
87
        }
88
89
        return $this->gitWrapper->run($command, null, $input);
90
    }
91
92
    /**
93
     * Returns the output of a `git status -s` command.
94
     */
95
    public function getStatus(): string
96
    {
97
        return $this->gitWrapper->git('status -s', $this->directory);
98
    }
99
100
    /**
101
     * Returns true if there are changes to commit.
102
     */
103
    public function hasChanges(): bool
104
    {
105
        $output = $this->getStatus();
106
        return ! empty($output);
107
    }
108
109
    /**
110
     * Returns whether HEAD has a remote tracking branch.
111
     */
112
    public function isTracking(): bool
113
    {
114
        try {
115
            $this->run('rev-parse', ['@{u}']);
116
        } catch (GitException $gitException) {
117
            return false;
118
        }
119
120
        return true;
121
    }
122
123
    /**
124
     * Returns whether HEAD is up-to-date with its remote tracking branch.
125
     */
126
    public function isUpToDate(): bool
127
    {
128
        if (! $this->isTracking()) {
129
            throw new GitException(
130
                'Error: HEAD does not have a remote tracking branch. Cannot check if it is up-to-date.'
131
            );
132
        }
133
134
        $mergeBase = $this->run('merge-base', ['@', '@{u}']);
135
        $remoteSha = $this->run('rev-parse', ['@{u}']);
136
        return $mergeBase === $remoteSha;
137
    }
138
139
    /**
140
     * Returns whether HEAD is ahead of its remote tracking branch.
141
     *
142
     * If this returns true it means that commits are present locally which have
143
     * not yet been pushed to the remote.
144
     */
145
    public function isAhead(): bool
146
    {
147
        if (! $this->isTracking()) {
148
            throw new GitException('Error: HEAD does not have a remote tracking branch. Cannot check if it is ahead.');
149
        }
150
151
        $mergeBase = $this->run('merge-base', ['@', '@{u}']);
152
        $localSha = $this->run('rev-parse', ['@']);
153
        $remoteSha = $this->run('rev-parse', ['@{u}']);
154
        return $mergeBase === $remoteSha && $localSha !== $remoteSha;
155
    }
156
157
    /**
158
     * Returns whether HEAD is behind its remote tracking branch.
159
     *
160
     * If this returns true it means that a pull is needed to bring the branch
161
     * up-to-date with the remote.
162
     */
163
    public function isBehind(): bool
164
    {
165
        if (! $this->isTracking()) {
166
            throw new GitException('Error: HEAD does not have a remote tracking branch. Cannot check if it is behind.');
167
        }
168
169
        $mergeBase = $this->run('merge-base', ['@', '@{u}']);
170
        $localSha = $this->run('rev-parse', ['@']);
171
        $remoteSha = $this->run('rev-parse', ['@{u}']);
172
        return $mergeBase === $localSha && $localSha !== $remoteSha;
173
    }
174
175
    /**
176
     * Returns whether HEAD needs to be merged with its remote tracking branch.
177
     *
178
     * If this returns true it means that HEAD has diverged from its remote
179
     * tracking branch; new commits are present locally as well as on the
180
     * remote.
181
     */
182
    public function needsMerge(): bool
183
    {
184
        if (! $this->isTracking()) {
185
            throw new GitException('Error: HEAD does not have a remote tracking branch. Cannot check if it is behind.');
186
        }
187
188
        $mergeBase = $this->run('merge-base', ['@', '@{u}']);
189
        $localSha = $this->run('rev-parse', ['@']);
190
        $remoteSha = $this->run('rev-parse', ['@{u}']);
191
        return $mergeBase !== $localSha && $mergeBase !== $remoteSha;
192
    }
193
194
    /**
195
     * Returns a GitBranches object containing information on the repository's
196
     * branches.
197
     */
198
    public function getBranches(): GitBranches
199
    {
200
        return new GitBranches($this);
201
    }
202
203
    /**
204
     * This is synonymous with `git push origin tag v1.2.3`.
205
     *
206
     * @param string $repository The destination of the push operation, which is either a URL or name of
207
     *   the remote. Defaults to "origin".
208
     * @param mixed[] $options
209
     */
210
    public function pushTag(string $tag, string $repository = 'origin', array $options = []): string
211
    {
212
        return $this->push($repository, 'tag', $tag, $options);
213
    }
214
215
    /**
216
     * This is synonymous with `git push --tags origin`.
217
     *
218
     * @param string $repository The destination of the push operation, which is either a URL or name of the remote.
219
     * @param mixed[] $options
220
     */
221
    public function pushTags(string $repository = 'origin', array $options = []): string
222
    {
223
        $options['tags'] = true;
224
        return $this->push($repository, $options);
225
    }
226
227
    /**
228
     * Fetches all remotes.
229
     *
230
     * This is synonymous with `git fetch --all`.
231
     *
232
     * @param mixed[] $options
233
     */
234
    public function fetchAll(array $options = []): string
235
    {
236
        $options['all'] = true;
237
        return $this->fetch($options);
238
    }
239
240
    /**
241
     * Create a new branch and check it out.
242
     *
243
     * This is synonymous with `git checkout -b`.
244
     *
245
     * @param mixed[] $options
246
     */
247
    public function checkoutNewBranch(string $branch, array $options = []): string
248
    {
249
        $options['b'] = true;
250
        return $this->checkout($branch, $options);
251
    }
252
253
    /**
254
     * Adds a remote to the repository.
255
     *
256
     * @param mixed[] $options An associative array of options, with the following keys:
257
     *   - -f: Boolean, set to true to run git fetch immediately after the
258
     *     remote is set up. Defaults to false.
259
     *   - --tags: Boolean. By default only the tags from the fetched branches
260
     *     are imported when git fetch is run. Set this to true to import every
261
     *     tag from the remote repository. Defaults to false.
262
     *   - --no-tags: Boolean, when set to true, git fetch does not import tags
263
     *     from the remote repository. Defaults to false.
264
     *   - -t: Optional array of branch names to track. If left empty, all
265
     *     branches will be tracked.
266
     *   - -m: Optional name of the master branch to track. This will set up a
267
     *     symbolic ref 'refs/remotes/<name>/HEAD which points at the specified
268
     *     master branch on the remote. When omitted, no symbolic ref will be
269
     *     created.
270
     */
271
    public function addRemote(string $name, string $url, array $options = []): string
272
    {
273
        $this->ensureAddRemoveArgsAreValid($name, $url);
274
275
        $args = ['add'];
276
277
        // Add boolean options.
278
        foreach (['-f', '--tags', '--no-tags'] as $option) {
279
            if (! empty($options[$option])) {
280
                $args[] = $option;
281
            }
282
        }
283
284
        // Add tracking branches.
285
        if (! empty($options['-t'])) {
286
            foreach ($options['-t'] as $branch) {
287
                array_push($args, '-t', $branch);
288
            }
289
        }
290
291
        // Add master branch.
292
        if (! empty($options['-m'])) {
293
            array_push($args, '-m', $options['-m']);
294
        }
295
296
        // Add remote name and URL.
297
        array_push($args, $name, $url);
298
299
        return $this->remote(...$args);
300
    }
301
302
    public function removeRemote(string $name): string
303
    {
304
        return $this->remote('rm', $name);
305
    }
306
307
    public function hasRemote(string $name): bool
308
    {
309
        return array_key_exists($name, $this->getRemotes());
310
    }
311
312
    /**
313
     * @return string[] An associative array with the following keys:
314
     *  - fetch: the fetch URL.
315
     *  - push: the push URL.
316
     */
317
    public function getRemote(string $name): array
318
    {
319
        if (! $this->hasRemote($name)) {
320
            throw new GitException(sprintf('The remote "%s" does not exist.', $name));
321
        }
322
323
        return $this->getRemotes()[$name];
324
    }
325
326
    /**
327
     * @return mixed[] An associative array, keyed by remote name, containing an associative array with keys:
328
     *  - fetch: the fetch URL.
329
     *  - push: the push URL.
330
     */
331
    public function getRemotes(): array
332
    {
333
        $result = rtrim($this->remote());
334
        if (empty($result)) {
335
            return [];
336
        }
337
338
        $remotes = [];
339
        foreach (explode(PHP_EOL, $result) as $remote) {
340
            $remotes[$remote]['fetch'] = $this->getRemoteUrl($remote);
341
            $remotes[$remote]['push'] = $this->getRemoteUrl($remote, 'push');
342
        }
343
344
        return $remotes;
345
    }
346
347
    /**
348
     * Returns the fetch or push URL of a given remote.
349
     *
350
     * @param string $operation The operation for which to return the remote. Can be either 'fetch' or 'push'.
351
     */
352
    public function getRemoteUrl(string $remote, string $operation = 'fetch'): string
353
    {
354
        $argsAndOptions = ['get-url', $remote];
355
356
        if ($operation === 'push') {
357
            $argsAndOptions[] = '--push';
358
        }
359
360
        return rtrim($this->remote(...$argsAndOptions));
361
    }
362
363
    /**
364
     * @code $git->add('some/file.txt');
365
     *
366
     * @param mixed[] $options
367
     */
368
    public function add(string $filepattern, array $options = []): string
369
    {
370
        return $this->run('add', [$filepattern, $options]);
371
    }
372
373
    /**
374
     * @code $git->apply('the/file/to/read/the/patch/from');
375
     *
376
     * @param mixed ...$argsAndOptions
377
     */
378
    public function apply(...$argsAndOptions): string
379
    {
380
        return $this->run('apply', $argsAndOptions);
381
    }
382
383
    /**
384
     * @code $git->applyRaw('raw-diff-content');
385
     *
386
     * @param mixed ...$argsAndOptions
387
     */
388
    public function applyRaw(string $patch, ...$argsAndOptions): string
389
    {
390
        return $this->run('apply', $argsAndOptions, true, $patch);
391
    }
392
393
    /**
394
     * Find by binary search the change that introduced a bug.
395
     *
396
     * @code $git->bisect('good', '2.6.13-rc2');
397
     * $git->bisect('view', ['stat' => true]);
398
     *
399
     * @param mixed ...$argsAndOptions
400
     */
401
    public function bisect(...$argsAndOptions): string
402
    {
403
        return $this->run('bisect', $argsAndOptions);
404
    }
405
406
    /**
407
     * @code $git->branch('my2.6.14', 'v2.6.14');
408
     * $git->branch('origin/html', 'origin/man', ['d' => true, 'r' => 'origin/todo']);
409
     *
410
     * @param mixed ...$argsAndOptions
411
     */
412
    public function branch(...$argsAndOptions): string
413
    {
414
        return $this->run('branch', $argsAndOptions);
415
    }
416
417
    /**
418
     * @code $git->checkout('new-branch', ['b' => true]);
419
     *
420
     * @param mixed ...$argsAndOptions
421
     */
422
    public function checkout(...$argsAndOptions): string
423
    {
424
        return $this->run('checkout', $argsAndOptions);
425
    }
426
427
    /**
428
     * Executes a `git clone` command.
429
     *
430
     * @code $git->cloneRepository('git://github.com/cpliakas/git-wrapper.git');
431
     *
432
     * @param mixed[] $options
433
     */
434
    public function cloneRepository(string $repository, array $options = []): string
435
    {
436
        $argsAndOptions = [$repository, $this->directory, $options];
437
        return $this->run('clone', $argsAndOptions, false);
438
    }
439
440
    /**
441
     * Record changes to the repository. If only one argument is passed, it is  assumed to be the commit message.
442
     * Therefore `$git->commit('Message');` yields a `git commit -am "Message"` command.
443
     *
444
     * @code $git->commit('My commit message');
445
     * $git->commit('Makefile', ['m' => 'My commit message']);
446
     *
447
     * @param mixed ...$argsAndOptions
448
     */
449
    public function commit(...$argsAndOptions): string
450
    {
451
        if (isset($argsAndOptions[0]) && is_string($argsAndOptions[0]) && ! isset($argsAndOptions[1])) {
452
            $argsAndOptions[0] = [
453
                'm' => $argsAndOptions[0],
454
                'a' => true,
455
            ];
456
        }
457
458
        return $this->run('commit', $argsAndOptions);
459
    }
460
461
    /**
462
     * @code $git->config('user.email', '[email protected]');
463
     * $git->config('user.name', 'Chris Pliakas');
464
     *
465
     * @param mixed ...$argsAndOptions
466
     */
467
    public function config(...$argsAndOptions): string
468
    {
469
        return $this->run('config', $argsAndOptions);
470
    }
471
472
    /**
473
     * @code $git->diff();
474
     * $git->diff('topic', 'master');
475
     *
476
     * @param mixed ...$argsAndOptions
477
     */
478
    public function diff(...$argsAndOptions): string
479
    {
480
        return $this->run('diff', $argsAndOptions);
481
    }
482
483
    /**
484
     * @code $git->fetch('origin');
485
     * $git->fetch(['all' => true]);
486
     *
487
     * @param mixed ...$argsAndOptions
488
     */
489
    public function fetch(...$argsAndOptions): string
490
    {
491
        return $this->run('fetch', $argsAndOptions);
492
    }
493
494
    /**
495
     * Print lines matching a pattern.
496
     *
497
     * @code $git->grep('time_t', '--', '*.[ch]');
498
     *
499
     * @param mixed ...$argsAndOptions
500
     */
501
    public function grep(...$argsAndOptions): string
502
    {
503
        return $this->run('grep', $argsAndOptions);
504
    }
505
506
    /**
507
     * Create an empty git repository or reinitialize an existing one.
508
     *
509
     * @code $git->init(['bare' => true]);
510
     *
511
     * @param mixed[] $options
512
     */
513
    public function init(array $options = []): string
514
    {
515
        $argsAndOptions = [$this->directory, $options];
516
        return $this->run('init', $argsAndOptions, false);
517
    }
518
519
    /**
520
     * @code $git->log(['no-merges' => true]);
521
     * $git->log('v2.6.12..', 'include/scsi', 'drivers/scsi');
522
     *
523
     * @param mixed ...$argsAndOptions
524
     */
525
    public function log(...$argsAndOptions): string
526
    {
527
        return $this->run('log', $argsAndOptions);
528
    }
529
530
    /**
531
     * @code $git->merge('fixes', 'enhancements');
532
     *
533
     * @param mixed ...$argsAndOptions
534
     */
535
    public function merge(...$argsAndOptions): string
536
    {
537
        return $this->run('merge', $argsAndOptions);
538
    }
539
540
    /**
541
     * @code $git->mv('orig.txt', 'dest.txt');
542
     *
543
     * @param mixed[] $options
544
     */
545
    public function mv(string $source, string $destination, array $options = []): string
546
    {
547
        $argsAndOptions = [$source, $destination, $options];
548
        return $this->run('mv', $argsAndOptions);
549
    }
550
551
    /**
552
     * @code $git->pull('upstream', 'master');
553
     *
554
     * @param mixed ...$argsAndOptions
555
     */
556
    public function pull(...$argsAndOptions): string
557
    {
558
        return $this->run('pull', $argsAndOptions);
559
    }
560
561
    /**
562
     * @code $git->push('upstream', 'master');
563
     *
564
     * @param mixed ...$argsAndOptions
565
     */
566
    public function push(...$argsAndOptions): string
567
    {
568
        return $this->run('push', $argsAndOptions);
569
    }
570
571
    /**
572
     * @code $git->rebase('subsystem@{1}', ['onto' => 'subsystem']);
573
     *
574
     * @param mixed ...$argsAndOptions
575
     */
576
    public function rebase(...$argsAndOptions): string
577
    {
578
        return $this->run('rebase', $argsAndOptions);
579
    }
580
581
    /**
582
     * @code $git->remote('add', 'upstream', 'git://github.com/cpliakas/git-wrapper.git');
583
     *
584
     * @param mixed ...$argsAndOptions
585
     */
586
    public function remote(...$argsAndOptions): string
587
    {
588
        return $this->run('remote', $argsAndOptions);
589
    }
590
591
    /**
592
     * @code $git->reset(['hard' => true]);
593
     *
594
     * @param mixed ...$argsAndOptions
595
     */
596
    public function reset(...$argsAndOptions): string
597
    {
598
        return $this->run('reset', $argsAndOptions);
599
    }
600
601
    /**
602
     * @code $git->rm('oldfile.txt');
603
     *
604
     * @param mixed[] $options
605
     */
606
    public function rm(string $filepattern, array $options = []): string
607
    {
608
        $args = [$filepattern, $options];
609
        return $this->run('rm', $args);
610
    }
611
612
    /**
613
     * @code $git->show('v1.0.0');
614
     *
615
     * @param mixed[] $options
616
     */
617
    public function show(string $object, array $options = []): string
618
    {
619
        $args = [$object, $options];
620
        return $this->run('show', $args);
621
    }
622
623
    /**
624
     * @code $git->status(['s' => true]);
625
     *
626
     * @param mixed ...$argsAndOptions
627
     */
628
    public function status(...$argsAndOptions): string
629
    {
630
        return $this->run('status', $argsAndOptions);
631
    }
632
633
    /**
634
     * @code $git->tag('v1.0.0');
635
     *
636
     * @param mixed ...$argsAndOptions
637
     */
638
    public function tag(...$argsAndOptions): string
639
    {
640
        return $this->run('tag', $argsAndOptions);
641
    }
642
643
    /**
644
     * @code $git->clean('-d', '-f');
645
     *
646
     * @param mixed ...$argsAndOptions
647
     */
648
    public function clean(...$argsAndOptions): string
649
    {
650
        return $this->run('clean', $argsAndOptions);
651
    }
652
653
    /**
654
     * @code $git->archive('HEAD', ['o' => '/path/to/archive']);
655
     *
656
     * @param mixed ...$argsAndOptions
657
     */
658
    public function archive(...$argsAndOptions): string
659
    {
660
        return $this->run('archive', $argsAndOptions);
661
    }
662
663
    /**
664
     * Returns a GitTags object containing  information on the repository's tags.
665
     */
666
    public function tags(): GitTags
667
    {
668
        return new GitTags($this);
669
    }
670
671
    private function ensureAddRemoveArgsAreValid(string $name, string $url): void
672
    {
673
        if (empty($name)) {
674
            throw new GitException('Cannot add remote without a name.');
675
        }
676
677
        if (empty($url)) {
678
            throw new GitException('Cannot add remote without a URL.');
679
        }
680
    }
681
}
682