Completed
Push — master ( 0b4cd8...472716 )
by Tomáš
15s queued 10s
created

GitWorkingCopy::isBehind()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 11
rs 9.9
c 0
b 0
f 0
cc 3
nc 3
nop 0
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
     * A boolean flagging whether the repository is cloned.
15
     *
16
     * If the variable is null, the a rudimentary check will be performed to see
17
     * if the directory looks like it is a working copy.
18
     *
19
     * @var bool|null
20
     */
21
    private $cloned;
22
23
    /**
24
     * Path to the directory containing the working copy.
25
     *
26
     * @var string
27
     */
28
    private $directory;
29
30
    /**
31
     * The GitWrapper object that likely instantiated this class.
32
     *
33
     * @var GitWrapper
34
     */
35
    private $gitWrapper;
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
83
    {
84
        $command = new GitCommand($command, ...$argsAndOptions);
85
        if ($setDirectory) {
86
            $command->setDirectory($this->directory);
87
        }
88
89
        return $this->gitWrapper->run($command);
90
    }
91
92
    /**
93
     * Returns the output of a `git status -s` command.
94
     */
95
    public function getStatus(): string
96
    {
97
        return $this->run('status', ['-s']);
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->ensureAddRemoteArgsAreValid($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
     * Find by binary search the change that introduced a bug.
385
     *
386
     * @code $git->bisect('good', '2.6.13-rc2');
387
     * $git->bisect('view', ['stat' => true]);
388
     *
389
     * @param mixed ...$argsAndOptions
390
     */
391
    public function bisect(...$argsAndOptions): string
392
    {
393
        return $this->run('bisect', $argsAndOptions);
394
    }
395
396
    /**
397
     * @code $git->branch('my2.6.14', 'v2.6.14');
398
     * $git->branch('origin/html', 'origin/man', ['d' => true, 'r' => 'origin/todo']);
399
     *
400
     * @param mixed ...$argsAndOptions
401
     */
402
    public function branch(...$argsAndOptions): string
403
    {
404
        return $this->run('branch', $argsAndOptions);
405
    }
406
407
    /**
408
     * @code $git->checkout('new-branch', ['b' => true]);
409
     *
410
     * @param mixed ...$argsAndOptions
411
     */
412
    public function checkout(...$argsAndOptions): string
413
    {
414
        return $this->run('checkout', $argsAndOptions);
415
    }
416
417
    /**
418
     * Executes a `git clone` command.
419
     *
420
     * @code $git->cloneRepository('git://github.com/cpliakas/git-wrapper.git');
421
     *
422
     * @param mixed[] $options
423
     */
424
    public function cloneRepository(string $repository, array $options = []): string
425
    {
426
        $argsAndOptions = [$repository, $this->directory, $options];
427
        return $this->run('clone', $argsAndOptions, false);
428
    }
429
430
    /**
431
     * Record changes to the repository. If only one argument is passed, it is  assumed to be the commit message.
432
     * Therefore `$git->commit('Message');` yields a `git commit -am "Message"` command.
433
     *
434
     * @code $git->commit('My commit message');
435
     * $git->commit('Makefile', ['m' => 'My commit message']);
436
     *
437
     * @param mixed ...$argsAndOptions
438
     */
439
    public function commit(...$argsAndOptions): string
440
    {
441
        if (isset($argsAndOptions[0]) && is_string($argsAndOptions[0]) && ! isset($argsAndOptions[1])) {
442
            $argsAndOptions[0] = [
443
                'm' => $argsAndOptions[0],
444
                'a' => true,
445
            ];
446
        }
447
448
        return $this->run('commit', $argsAndOptions);
449
    }
450
451
    /**
452
     * @code $git->config('user.email', '[email protected]');
453
     * $git->config('user.name', 'Chris Pliakas');
454
     *
455
     * @param mixed ...$argsAndOptions
456
     */
457
    public function config(...$argsAndOptions): string
458
    {
459
        return $this->run('config', $argsAndOptions);
460
    }
461
462
    /**
463
     * @code $git->diff();
464
     * $git->diff('topic', 'master');
465
     *
466
     * @param mixed ...$argsAndOptions
467
     */
468
    public function diff(...$argsAndOptions): string
469
    {
470
        return $this->run('diff', $argsAndOptions);
471
    }
472
473
    /**
474
     * @code $git->fetch('origin');
475
     * $git->fetch(['all' => true]);
476
     *
477
     * @param mixed ...$argsAndOptions
478
     */
479
    public function fetch(...$argsAndOptions): string
480
    {
481
        return $this->run('fetch', $argsAndOptions);
482
    }
483
484
    /**
485
     * Print lines matching a pattern.
486
     *
487
     * @code $git->grep('time_t', '--', '*.[ch]');
488
     *
489
     * @param mixed ...$argsAndOptions
490
     */
491
    public function grep(...$argsAndOptions): string
492
    {
493
        return $this->run('grep', $argsAndOptions);
494
    }
495
496
    /**
497
     * Create an empty git repository or reinitialize an existing one.
498
     *
499
     * @code $git->init(['bare' => true]);
500
     *
501
     * @param mixed[] $options
502
     */
503
    public function init(array $options = []): string
504
    {
505
        $argsAndOptions = [$this->directory, $options];
506
        return $this->run('init', $argsAndOptions, false);
507
    }
508
509
    /**
510
     * @code $git->log(['no-merges' => true]);
511
     * $git->log('v2.6.12..', 'include/scsi', 'drivers/scsi');
512
     *
513
     * @param mixed ...$argsAndOptions
514
     */
515
    public function log(...$argsAndOptions): string
516
    {
517
        return $this->run('log', $argsAndOptions);
518
    }
519
520
    /**
521
     * @code $git->merge('fixes', 'enhancements');
522
     *
523
     * @param mixed ...$argsAndOptions
524
     */
525
    public function merge(...$argsAndOptions): string
526
    {
527
        return $this->run('merge', $argsAndOptions);
528
    }
529
530
    /**
531
     * @code $git->mv('orig.txt', 'dest.txt');
532
     *
533
     * @param mixed[] $options
534
     */
535
    public function mv(string $source, string $destination, array $options = []): string
536
    {
537
        $argsAndOptions = [$source, $destination, $options];
538
        return $this->run('mv', $argsAndOptions);
539
    }
540
541
    /**
542
     * @code $git->pull('upstream', 'master');
543
     *
544
     * @param mixed ...$argsAndOptions
545
     */
546
    public function pull(...$argsAndOptions): string
547
    {
548
        return $this->run('pull', $argsAndOptions);
549
    }
550
551
    /**
552
     * @code $git->push('upstream', 'master');
553
     *
554
     * @param mixed ...$argsAndOptions
555
     */
556
    public function push(...$argsAndOptions): string
557
    {
558
        return $this->run('push', $argsAndOptions);
559
    }
560
561
    /**
562
     * @code $git->rebase('subsystem@{1}', ['onto' => 'subsystem']);
563
     *
564
     * @param mixed ...$argsAndOptions
565
     */
566
    public function rebase(...$argsAndOptions): string
567
    {
568
        return $this->run('rebase', $argsAndOptions);
569
    }
570
571
    /**
572
     * @code $git->remote('add', 'upstream', 'git://github.com/cpliakas/git-wrapper.git');
573
     *
574
     * @param mixed ...$argsAndOptions
575
     */
576
    public function remote(...$argsAndOptions): string
577
    {
578
        return $this->run('remote', $argsAndOptions);
579
    }
580
581
    /**
582
     * @code $git->reset(['hard' => true]);
583
     *
584
     * @param mixed ...$argsAndOptions
585
     */
586
    public function reset(...$argsAndOptions): string
587
    {
588
        return $this->run('reset', $argsAndOptions);
589
    }
590
591
    /**
592
     * @code $git->rm('oldfile.txt');
593
     *
594
     * @param mixed[] $options
595
     */
596
    public function rm(string $filepattern, array $options = []): string
597
    {
598
        $args = [$filepattern, $options];
599
        return $this->run('rm', $args);
600
    }
601
602
    /**
603
     * @code $git->show('v1.0.0');
604
     *
605
     * @param mixed[] $options
606
     */
607
    public function show(string $object, array $options = []): string
608
    {
609
        $args = [$object, $options];
610
        return $this->run('show', $args);
611
    }
612
613
    /**
614
     * @code $git->status(['s' => true]);
615
     *
616
     * @param mixed ...$argsAndOptions
617
     */
618
    public function status(...$argsAndOptions): string
619
    {
620
        return $this->run('status', $argsAndOptions);
621
    }
622
623
    /**
624
     * @code $git->tag('v1.0.0');
625
     *
626
     * @param mixed ...$argsAndOptions
627
     */
628
    public function tag(...$argsAndOptions): string
629
    {
630
        return $this->run('tag', $argsAndOptions);
631
    }
632
633
    /**
634
     * @code $git->clean('-d', '-f');
635
     *
636
     * @param mixed ...$argsAndOptions
637
     */
638
    public function clean(...$argsAndOptions): string
639
    {
640
        return $this->run('clean', $argsAndOptions);
641
    }
642
643
    /**
644
     * @code $git->archive('HEAD', ['o' => '/path/to/archive']);
645
     *
646
     * @param mixed ...$argsAndOptions
647
     */
648
    public function archive(...$argsAndOptions): string
649
    {
650
        return $this->run('archive', $argsAndOptions);
651
    }
652
653
    /**
654
     * Returns a GitTags object containing  information on the repository's tags.
655
     */
656
    public function tags(): GitTags
657
    {
658
        return new GitTags($this);
659
    }
660
661
    private function ensureAddRemoteArgsAreValid(string $name, string $url): void
662
    {
663
        if (empty($name)) {
664
            throw new GitException('Cannot add remote without a name.');
665
        }
666
667
        if (empty($url)) {
668
            throw new GitException('Cannot add remote without a URL.');
669
        }
670
    }
671
}
672