Completed
Push — master ( 24fe5a...5a2b0a )
by Tomáš
13s
created

GitWorkingCopy::ensureAddRemoveArgsAreValid()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 5
nc 3
nop 2
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
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->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
        $remotes = [];
334
        foreach (explode(PHP_EOL, rtrim($this->remote())) as $remote) {
335
            $remotes[$remote]['fetch'] = $this->getRemoteUrl($remote);
336
            $remotes[$remote]['push'] = $this->getRemoteUrl($remote, 'push');
337
        }
338
339
        return $remotes;
340
    }
341
342
    /**
343
     * Returns the fetch or push URL of a given remote.
344
     *
345
     * @param string $operation The operation for which to return the remote. Can be either 'fetch' or 'push'.
346
     */
347
    public function getRemoteUrl(string $remote, string $operation = 'fetch'): string
348
    {
349
        $argsAndOptions = ['get-url', $remote];
350
351
        if ($operation === 'push') {
352
            $argsAndOptions[] = '--push';
353
        }
354
355
        return rtrim($this->remote(...$argsAndOptions));
356
    }
357
358
    /**
359
     * @code $git->add('some/file.txt');
360
     *
361
     * @param mixed[] $options
362
     */
363
    public function add(string $filepattern, array $options = []): string
364
    {
365
        return $this->run('add', [$filepattern, $options]);
366
    }
367
368
    /**
369
     * @code $git->apply('the/file/to/read/the/patch/from');
370
     *
371
     * @param mixed ...$argsAndOptions
372
     */
373
    public function apply(...$argsAndOptions): string
374
    {
375
        return $this->run('apply', $argsAndOptions);
376
    }
377
378
    /**
379
     * Find by binary search the change that introduced a bug.
380
     *
381
     * @code $git->bisect('good', '2.6.13-rc2');
382
     * $git->bisect('view', ['stat' => true]);
383
     *
384
     * @param mixed ...$argsAndOptions
385
     */
386
    public function bisect(...$argsAndOptions): string
387
    {
388
        return $this->run('bisect', $argsAndOptions);
389
    }
390
391
    /**
392
     * @code $git->branch('my2.6.14', 'v2.6.14');
393
     * $git->branch('origin/html', 'origin/man', ['d' => true, 'r' => 'origin/todo']);
394
     *
395
     * @param mixed ...$argsAndOptions
396
     */
397
    public function branch(...$argsAndOptions): string
398
    {
399
        return $this->run('branch', $argsAndOptions);
400
    }
401
402
    /**
403
     * @code $git->checkout('new-branch', ['b' => true]);
404
     *
405
     * @param mixed ...$argsAndOptions
406
     */
407
    public function checkout(...$argsAndOptions): string
408
    {
409
        return $this->run('checkout', $argsAndOptions);
410
    }
411
412
    /**
413
     * Executes a `git clone` command.
414
     *
415
     * @code $git->cloneRepository('git://github.com/cpliakas/git-wrapper.git');
416
     *
417
     * @param mixed[] $options
418
     */
419
    public function cloneRepository(string $repository, array $options = []): string
420
    {
421
        $argsAndOptions = [$repository, $this->directory, $options];
422
        return $this->run('clone', $argsAndOptions, false);
423
    }
424
425
    /**
426
     * Record changes to the repository. If only one argument is passed, it is  assumed to be the commit message.
427
     * Therefore `$git->commit('Message');` yields a `git commit -am "Message"` command.
428
     *
429
     * @code $git->commit('My commit message');
430
     * $git->commit('Makefile', ['m' => 'My commit message']);
431
     *
432
     * @param mixed ...$argsAndOptions
433
     */
434
    public function commit(...$argsAndOptions): string
435
    {
436
        if (isset($argsAndOptions[0]) && is_string($argsAndOptions[0]) && ! isset($argsAndOptions[1])) {
437
            $argsAndOptions[0] = [
438
                'm' => $argsAndOptions[0],
439
                'a' => true,
440
            ];
441
        }
442
443
        return $this->run('commit', $argsAndOptions);
444
    }
445
446
    /**
447
     * @code $git->config('user.email', '[email protected]');
448
     * $git->config('user.name', 'Chris Pliakas');
449
     *
450
     * @param mixed ...$argsAndOptions
451
     */
452
    public function config(...$argsAndOptions): string
453
    {
454
        return $this->run('config', $argsAndOptions);
455
    }
456
457
    /**
458
     * @code $git->diff();
459
     * $git->diff('topic', 'master');
460
     *
461
     * @param mixed ...$argsAndOptions
462
     */
463
    public function diff(...$argsAndOptions): string
464
    {
465
        return $this->run('diff', $argsAndOptions);
466
    }
467
468
    /**
469
     * @code $git->fetch('origin');
470
     * $git->fetch(['all' => true]);
471
     *
472
     * @param mixed ...$argsAndOptions
473
     */
474
    public function fetch(...$argsAndOptions): string
475
    {
476
        return $this->run('fetch', $argsAndOptions);
477
    }
478
479
    /**
480
     * Print lines matching a pattern.
481
     *
482
     * @code $git->grep('time_t', '--', '*.[ch]');
483
     *
484
     * @param mixed ...$argsAndOptions
485
     */
486
    public function grep(...$argsAndOptions): string
487
    {
488
        return $this->run('grep', $argsAndOptions);
489
    }
490
491
    /**
492
     * Create an empty git repository or reinitialize an existing one.
493
     *
494
     * @code $git->init(['bare' => true]);
495
     *
496
     * @param mixed[] $options
497
     */
498
    public function init(array $options = []): string
499
    {
500
        $argsAndOptions = [$this->directory, $options];
501
        return $this->run('init', $argsAndOptions, false);
502
    }
503
504
    /**
505
     * @code $git->log(['no-merges' => true]);
506
     * $git->log('v2.6.12..', 'include/scsi', 'drivers/scsi');
507
     *
508
     * @param mixed ...$argsAndOptions
509
     */
510
    public function log(...$argsAndOptions): string
511
    {
512
        return $this->run('log', $argsAndOptions);
513
    }
514
515
    /**
516
     * @code $git->merge('fixes', 'enhancements');
517
     *
518
     * @param mixed ...$argsAndOptions
519
     */
520
    public function merge(...$argsAndOptions): string
521
    {
522
        return $this->run('merge', $argsAndOptions);
523
    }
524
525
    /**
526
     * @code $git->mv('orig.txt', 'dest.txt');
527
     *
528
     * @param mixed[] $options
529
     */
530
    public function mv(string $source, string $destination, array $options = []): string
531
    {
532
        $argsAndOptions = [$source, $destination, $options];
533
        return $this->run('mv', $argsAndOptions);
534
    }
535
536
    /**
537
     * @code $git->pull('upstream', 'master');
538
     *
539
     * @param mixed ...$argsAndOptions
540
     */
541
    public function pull(...$argsAndOptions): string
542
    {
543
        return $this->run('pull', $argsAndOptions);
544
    }
545
546
    /**
547
     * @code $git->push('upstream', 'master');
548
     *
549
     * @param mixed ...$argsAndOptions
550
     */
551
    public function push(...$argsAndOptions): string
552
    {
553
        return $this->run('push', $argsAndOptions);
554
    }
555
556
    /**
557
     * @code $git->rebase('subsystem@{1}', ['onto' => 'subsystem']);
558
     *
559
     * @param mixed ...$argsAndOptions
560
     */
561
    public function rebase(...$argsAndOptions): string
562
    {
563
        return $this->run('rebase', $argsAndOptions);
564
    }
565
566
    /**
567
     * @code $git->remote('add', 'upstream', 'git://github.com/cpliakas/git-wrapper.git');
568
     *
569
     * @param mixed ...$argsAndOptions
570
     */
571
    public function remote(...$argsAndOptions): string
572
    {
573
        return $this->run('remote', $argsAndOptions);
574
    }
575
576
    /**
577
     * @code $git->reset(['hard' => true]);
578
     *
579
     * @param mixed ...$argsAndOptions
580
     */
581
    public function reset(...$argsAndOptions): string
582
    {
583
        return $this->run('reset', $argsAndOptions);
584
    }
585
586
    /**
587
     * @code $git->rm('oldfile.txt');
588
     *
589
     * @param mixed[] $options
590
     */
591
    public function rm(string $filepattern, array $options = []): string
592
    {
593
        $args = [$filepattern, $options];
594
        return $this->run('rm', $args);
595
    }
596
597
    /**
598
     * @code $git->show('v1.0.0');
599
     *
600
     * @param mixed[] $options
601
     */
602
    public function show(string $object, array $options = []): string
603
    {
604
        $args = [$object, $options];
605
        return $this->run('show', $args);
606
    }
607
608
    /**
609
     * @code $git->status(['s' => true]);
610
     *
611
     * @param mixed ...$argsAndOptions
612
     */
613
    public function status(...$argsAndOptions): string
614
    {
615
        return $this->run('status', $argsAndOptions);
616
    }
617
618
    /**
619
     * @code $git->tag('v1.0.0');
620
     *
621
     * @param mixed ...$argsAndOptions
622
     */
623
    public function tag(...$argsAndOptions): string
624
    {
625
        return $this->run('tag', $argsAndOptions);
626
    }
627
628
    /**
629
     * @code $git->clean('-d', '-f');
630
     *
631
     * @param mixed ...$argsAndOptions
632
     */
633
    public function clean(...$argsAndOptions): string
634
    {
635
        return $this->run('clean', $argsAndOptions);
636
    }
637
638
    /**
639
     * @code $git->archive('HEAD', ['o' => '/path/to/archive']);
640
     *
641
     * @param mixed ...$argsAndOptions
642
     */
643
    public function archive(...$argsAndOptions): string
644
    {
645
        return $this->run('archive', $argsAndOptions);
646
    }
647
648
    /**
649
     * Returns a GitTags object containing  information on the repository's tags.
650
     */
651
    public function tags(): GitTags
652
    {
653
        return new GitTags($this);
654
    }
655
656
    private function ensureAddRemoveArgsAreValid(string $name, string $url): void
657
    {
658
        if (empty($name)) {
659
            throw new GitException('Cannot add remote without a name.');
660
        }
661
662
        if (empty($url)) {
663
            throw new GitException('Cannot add remote without a URL.');
664
        }
665
    }
666
}
667