GitWorkingCopy::show()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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