Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
n/a
0 / 0
n/a
0 / 0
CRAP
n/a
0 / 0
Repository
n/a
0 / 0
n/a
0 / 0
110
n/a
0 / 0
 __construct
n/a
0 / 0
n/a
0 / 0
2
 setPath
n/a
0 / 0
n/a
0 / 0
7
 getPath
n/a
0 / 0
n/a
0 / 0
1
 getActiveBranch
n/a
0 / 0
n/a
0 / 0
2
 getBranches
n/a
0 / 0
n/a
0 / 0
3
 run
n/a
0 / 0
n/a
0 / 0
6
 parseLines
n/a
0 / 0
n/a
0 / 0
4
 create
n/a
0 / 0
n/a
0 / 0
5
 status
n/a
0 / 0
n/a
0 / 0
1
 add
n/a
0 / 0
n/a
0 / 0
1
 rm
n/a
0 / 0
n/a
0 / 0
2
 parseFileList
n/a
0 / 0
n/a
0 / 0
2
 commit
n/a
0 / 0
n/a
0 / 0
2
 cloneTo
n/a
0 / 0
n/a
0 / 0
2
 cloneFrom
n/a
0 / 0
n/a
0 / 0
2
 cloneRemote
n/a
0 / 0
n/a
0 / 0
1
 clean
n/a
0 / 0
n/a
0 / 0
3
 createBranch
n/a
0 / 0
n/a
0 / 0
2
 getName
n/a
0 / 0
n/a
0 / 0
3
 getDirectoryPath
n/a
0 / 0
n/a
0 / 0
2
 getBranchesRemote
n/a
0 / 0
n/a
0 / 0
3
 checkout
n/a
0 / 0
n/a
0 / 0
1
 merge
n/a
0 / 0
n/a
0 / 0
1
 fetch
n/a
0 / 0
n/a
0 / 0
1
 createTag
n/a
0 / 0
n/a
0 / 0
1
 getTags
n/a
0 / 0
n/a
0 / 0
3
 push
n/a
0 / 0
n/a
0 / 0
1
 pull
n/a
0 / 0
n/a
0 / 0
1
 setDescription
n/a
0 / 0
n/a
0 / 0
1
 getDescription
n/a
0 / 0
n/a
0 / 0
1
 countFiles
n/a
0 / 0
n/a
0 / 0
1
 getLoc
n/a
0 / 0
n/a
0 / 0
8
 getContributors
n/a
0 / 0
n/a
0 / 0
5
 getCommitsCount
n/a
0 / 0
n/a
0 / 0
5
 getAdditionsRemovalsByContributor
n/a
0 / 0
n/a
0 / 0
4
 getRemote
n/a
0 / 0
n/a
0 / 0
1
 getCommitsBy
n/a
0 / 0
n/a
0 / 0
7
 getCommit
n/a
0 / 0
n/a
0 / 0
8
 getNewest
n/a
0 / 0
n/a
0 / 0
4
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Utils\Git
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\Utils\Git;
16
17use phpOMS\System\File\PathException;
18use phpOMS\Utils\StringUtils;
19
20/**
21 * Repository class
22 *
23 * @package phpOMS\Utils\Git
24 * @license OMS License 2.0
25 * @link    https://jingga.app
26 * @since   1.0.0
27 * @codeCoverageIgnore
28 */
29class Repository
30{
31    /**
32     * Repository path.
33     *
34     * @var string
35     * @since 1.0.0
36     */
37    private string $path = '';
38
39    /**
40     * Repository name.
41     *
42     * @var string
43     * @since 1.0.0
44     */
45    public string $name = '';
46
47    /**
48     * Bare repository.
49     *
50     * @var bool
51     * @since 1.0.0
52     */
53    private bool $bare = false;
54
55    /**
56     * Current branch.
57     *
58     * @var Branch
59     * @since 1.0.0
60     */
61    private Branch $branch;
62
63    /**
64     * Constructor
65     *
66     * @param string $path Repository path
67     *
68     * @since 1.0.0
69     */
70    public function __construct(string $path = '')
71    {
72        if (\is_dir($path)) {
73            $this->setPath($path);
74        }
75
76        $this->branch = new Branch();
77    }
78
79    /**
80     * Set repository path.
81     *
82     * @param string $path Path to repository
83     *
84     * @return void
85     *
86     * @throws PathException
87     *
88     * @since 1.0.0
89     */
90    private function setPath(string $path) : void
91    {
92        if (!\is_dir($path) || \realpath($path) === false) {
93            throw new PathException($path);
94        }
95
96        $this->path = \realpath($path);
97
98        if (\is_dir($this->path . '/.git')) {
99            $this->bare = false;
100        } elseif (\is_file($this->path . '/config')) { // Is this a bare repo?
101            $parseIni = \parse_ini_file($this->path . '/config');
102
103            if ($parseIni !== false && $parseIni['bare']) {
104                $this->bare = true;
105            }
106        }
107    }
108
109    /**
110     * Get repository path.
111     *
112     * @return string
113     *
114     * @since 1.0.0
115     */
116    public function getPath() : string
117    {
118        return $this->path;
119    }
120
121    /**
122     * Get active Branch.
123     *
124     * @return Branch
125     *
126     * @since 1.0.0
127     */
128    public function getActiveBranch() : Branch
129    {
130        $branches = $this->getBranches();
131        $active   = \preg_grep('/^\*/', $branches);
132
133        if (!\is_array($active)) {
134            return new Branch();
135        }
136
137        \reset($active);
138
139        return new Branch(\current($active));
140    }
141
142    /**
143     * Get all branches.
144     *
145     * @return string[]
146     *
147     * @since 1.0.0
148     */
149    public function getBranches() : array
150    {
151        $branches = $this->run('branch');
152        $result   = [];
153
154        foreach ($branches as $branch) {
155            $branch = \trim($branch, '* ');
156
157            if ($branch !== '') {
158                $result[] = $branch;
159            }
160        }
161
162        return $result;
163    }
164
165    /**
166     * Run git command.
167     *
168     * @param string $cmd Command to run
169     *
170     * @return string[]
171     *
172     * @throws \Exception
173     *
174     * @since 1.0.0
175     */
176    private function run(string $cmd) : array
177    {
178        if (\strtolower((string) \substr(\PHP_OS, 0, 3)) === 'win') {
179            $cmd = 'cd ' . \escapeshellarg(\dirname(Git::getBin()))
180                . ' && ' . \basename(Git::getBin())
181                . ' -C ' . \escapeshellarg($this->path) . ' '
182                . $cmd;
183        } else {
184            $cmd = \escapeshellarg(Git::getBin())
185                . ' -C ' . \escapeshellarg($this->path) . ' '
186                . $cmd;
187        }
188
189        $pipes = [];
190        $desc  = [
191            1 => ['pipe', 'w'],
192            2 => ['pipe', 'w'],
193        ];
194
195        $resource = \proc_open($cmd, $desc, $pipes, $this->path, null);
196
197        if ($resource === false) {
198            throw new \Exception();
199        }
200
201        $stdout = \stream_get_contents($pipes[1]);
202        $stderr = \stream_get_contents($pipes[2]);
203
204        foreach ($pipes as $pipe) {
205            \fclose($pipe);
206        }
207
208        $status = \proc_close($resource);
209
210        if ($status == -1) {
211            throw new \Exception((string) $stderr);
212        }
213
214        return $this->parseLines(\trim($stdout === false ? '' : $stdout));
215    }
216
217    /**
218     * Parse lines.
219     *
220     * @param string $lines Result of git command
221     *
222     * @return string[]
223     *
224     * @since 1.0.0
225     */
226    private function parseLines(string $lines) : array
227    {
228        $lineArray = \preg_split('/\r\n|\n|\r/', $lines);
229        $lines     = [];
230
231        if ($lineArray === false) {
232            return $lines;
233        }
234
235        foreach ($lineArray as $line) {
236            $temp = \preg_replace('/\s+/', ' ', \trim($line, ' '));
237
238            if (!empty($temp)) {
239                $lines[] = $temp;
240            }
241        }
242
243        return $lines;
244    }
245
246    /**
247     * Create repository
248     *
249     * @param string $source Create repository from source (optional, can be remote)
250     *
251     * @return void
252     *
253     * @throws \Exception
254     *
255     * @since 1.0.0
256     */
257    public function create(string $source = null) : void
258    {
259        if (!\is_dir($this->path) || \is_dir($this->path . '/.git')) {
260            throw new \Exception('Already repository');
261        }
262
263        if ($source !== null) {
264            \stripos($source, '//') !== false ? $this->cloneRemote($source) : $this->cloneFrom($source);
265
266            return;
267        }
268
269        $this->run('init');
270    }
271
272    /**
273     * Get status.
274     *
275     * @return string
276     *
277     * @since 1.0.0
278     */
279    public function status() : string
280    {
281        return \implode("\n", $this->run('status'));
282    }
283
284    /**
285     * Files to add to commit.
286     *
287     * @param array|string $files Files to commit
288     *
289     * @return string
290     *
291     * @since 1.0.0
292     */
293    public function add(string | array $files = '*') : string
294    {
295        $files = $this->parseFileList($files);
296
297        return \implode("\n", $this->run('add ' . $files . ' -v'));
298    }
299
300    /**
301     * Remove file(s) from repository
302     *
303     * @param array|string $files  Files to remove
304     * @param bool         $cached ?
305     *
306     * @return string
307     *
308     * @since 1.0.0
309     */
310    public function rm(string | array $files = '*', bool $cached = false) : string
311    {
312        $files = $this->parseFileList($files);
313
314        return \implode("\n", $this->run('rm ' . ($cached ? '--cached ' : '') . $files));
315    }
316
317    /**
318     * Remove file(s) from repository
319     *
320     * @param array|string $files Files to remove
321     *
322     * @return string
323     *
324     * @throws \InvalidArgumentException
325     *
326     * @since 1.0.0
327     */
328    private function parseFileList(string | array $files) : string
329    {
330        if (\is_array($files)) {
331            return '"' . \implode('" "', $files) . '"';
332        }
333
334        return $files;
335    }
336
337    /**
338     * Commit files.
339     *
340     * @param Commit $commit Commit to commit
341     * @param bool   $all    Commit all
342     *
343     * @return string
344     *
345     * @since 1.0.0
346     */
347    public function commit(Commit $commit, bool $all = true) : string
348    {
349        return \implode("\n", $this->run('commit ' . ($all ? '-av' : '-v') . ' -m ' . \escapeshellarg($commit->getMessage())));
350    }
351
352    /**
353     * Clone repository to different directory
354     *
355     * @param string $target Target clone directory
356     *
357     * @return string
358     *
359     * @throws PathException in case the target is not a valid directory
360     *
361     * @since 1.0.0
362     */
363    public function cloneTo(string $target) : string
364    {
365        if (!\is_dir($target)) {
366            throw new PathException($target);
367        }
368
369        return \implode("\n", $this->run('clone --local ' . $this->path . ' ' . $target));
370    }
371
372    /**
373     * Clone repository to current directory
374     *
375     * @param string $source Source repository to clone
376     *
377     * @return string
378     *
379     * @throws PathException in case the source repository is not valid
380     *
381     * @since 1.0.0
382     */
383    public function cloneFrom(string $source) : string
384    {
385        if (!\is_dir($source)) {
386            throw new PathException($source);
387        }
388
389        return \implode("\n", $this->run('clone --local ' . $source . ' ' . $this->path));
390    }
391
392    /**
393     * Clone remote repository to current directory
394     *
395     * @param string $source Source repository to clone
396     *
397     * @return string
398     *
399     * @since 1.0.0
400     */
401    public function cloneRemote(string $source) : string
402    {
403        return \implode("\n", $this->run('clone ' . $source . ' ' . $this->path));
404    }
405
406    /**
407     * Clean.
408     *
409     * @param bool $dirs  Directories?
410     * @param bool $force Force?
411     *
412     * @return string
413     *
414     * @since 1.0.0
415     */
416    public function clean(bool $dirs = false, bool $force = false) : string
417    {
418        return \implode("\n", $this->run('clean' . ($force ? ' -f' : '') . ($dirs ? ' -d' : '')));
419    }
420
421    /**
422     * Create local branch.
423     *
424     * @param Branch $branch Branch
425     * @param bool   $force  Force?
426     *
427     * @return string
428     *
429     * @since 1.0.0
430     */
431    public function createBranch(Branch $branch, bool $force = false) : string
432    {
433        return \implode("\n", $this->run('branch ' . ($force ? '-D' : '-d') . ' ' . $branch->name));
434    }
435
436    /**
437     * Get repository name.
438     *
439     * @return string
440     *
441     * @since 1.0.0
442     */
443    public function getName() : string
444    {
445        if (empty($this->name)) {
446            $path       = $this->getDirectoryPath();
447            $path       = \strtr($path, '\\', '/');
448            $path       = \explode('/', $path);
449            $this->name = $path[\count($path) - ($this->bare ? 1 : 2)];
450        }
451
452        return $this->name;
453    }
454
455    /**
456     * Get directory path.
457     *
458     * @return string
459     *
460     * @since 1.0.0
461     */
462    public function getDirectoryPath() : string
463    {
464        return $this->bare ? $this->path : $this->path . '/.git';
465    }
466
467    /**
468     * Get all remote branches.
469     *
470     * @return string[]
471     *
472     * @since 1.0.0
473     */
474    public function getBranchesRemote() : array
475    {
476        $branches = $this->run('branch -r');
477        $result   = [];
478
479        foreach ($branches as $key => $branch) {
480            $branch = \trim($branch, '* ');
481
482            if ($branch !== '') {
483                $result[] = $branch;
484            }
485        }
486
487        return $result;
488    }
489
490    /**
491     * Checkout.
492     *
493     * @param Branch $branch Branch to checkout
494     *
495     * @return string
496     *
497     * @since 1.0.0
498     */
499    public function checkout(Branch $branch) : string
500    {
501        $result       = \implode("\n", $this->run('checkout ' . $branch->name));
502        $this->branch = $branch;
503
504        return $result;
505    }
506
507    /**
508     * Merge with branch.
509     *
510     * @param Branch $branch Branch to merge from
511     *
512     * @return string
513     *
514     * @since 1.0.0
515     */
516    public function merge(Branch $branch) : string
517    {
518        return \implode("\n", $this->run('merge ' . $branch->name . ' --no-ff'));
519    }
520
521    /**
522     * Fetch.
523     *
524     * @return string
525     *
526     * @since 1.0.0
527     */
528    public function fetch() : string
529    {
530        return \implode("\n", $this->run('fetch'));
531    }
532
533    /**
534     * Create tag.
535     *
536     * @param Tag $tag Tag to create
537     *
538     * @return string
539     *
540     * @since 1.0.0
541     */
542    public function createTag(Tag $tag) : string
543    {
544        return \implode("\n", $this->run('tag -a ' . $tag->getName() . ' -m ' . \escapeshellarg($tag->getMessage())));
545    }
546
547    /**
548     * Get all tags.
549     *
550     * @param string $pattern Tag pattern
551     *
552     * @return Tag[]
553     *
554     * @since 1.0.0
555     */
556    public function getTags(string $pattern = '') : array
557    {
558        $pattern = empty($pattern) ? ' -l ' . $pattern : '';
559        $lines   = $this->run('tag' . $pattern);
560        $tags    = [];
561
562        foreach ($lines as $key => $tag) {
563            $tags[$tag] = new Tag($tag);
564        }
565
566        return $tags;
567    }
568
569    /**
570     * Push.
571     *
572     * @param string $remote Remote repository
573     * @param Branch $branch Branch to pull
574     *
575     * @return string
576     *
577     * @since 1.0.0
578     */
579    public function push(string $remote, Branch $branch) : string
580    {
581        $remote = \escapeshellarg($remote);
582
583        return \implode("\n", $this->run('push --tags ' . $remote . ' ' . $branch->name));
584    }
585
586    /**
587     * Pull.
588     *
589     * @param string $remote Remote repository
590     * @param Branch $branch Branch to pull
591     *
592     * @return string
593     *
594     * @since 1.0.0
595     */
596    public function pull(string $remote, Branch $branch) : string
597    {
598        $remote = \escapeshellarg($remote);
599
600        return \implode("\n", $this->run('pull ' . $remote . ' ' . $branch->name));
601    }
602
603    /**
604     * Set repository description.
605     *
606     * @param string $description Repository description
607     *
608     * @return void
609     *
610     * @since 1.0.0
611     */
612    public function setDescription(string $description) : void
613    {
614        \file_put_contents($this->getDirectoryPath(), $description);
615    }
616
617    /**
618     * Get repository description.
619     *
620     * @return string
621     *
622     * @since 1.0.0
623     */
624    public function getDescription() : string
625    {
626        return (string) \file_get_contents($this->getDirectoryPath() . '/description');
627    }
628
629    /**
630     * Count files in repository.
631     *
632     * @return int
633     *
634     * @since 1.0.0
635     */
636    public function countFiles() : int
637    {
638        $lines = $this->run('ls-files');
639
640        return \count($lines);
641    }
642
643    /**
644     * Get LOC.
645     *
646     * @param string[] $extensions Extensions whitelist
647     *
648     * @return int
649     *
650     * @since 1.0.0
651     */
652    public function getLoc(array $extensions = ['*']) : int
653    {
654        $lines = $this->run('ls-files');
655        $loc   = 0;
656
657        foreach ($lines as $line) {
658            if ($extensions[0] !== '*' && !StringUtils::endsWith($line, $extensions)) {
659                continue;
660            }
661
662            if (!\is_dir($path = $this->getDirectoryPath() . ($this->bare ? '/' : '/../') . $line)) {
663                return 0;
664            }
665
666            $fh = \fopen($path, 'r');
667
668            if (!$fh) {
669                return 0;
670            }
671
672            while (!\feof($fh)) {
673                \fgets($fh);
674                ++$loc;
675            }
676
677            \fclose($fh);
678        }
679
680        return $loc;
681    }
682
683    /**
684     * Get contributors.
685     *
686     * @param \DateTime $start Start date
687     * @param \DateTime $end   End date
688     *
689     * @return array<Author>
690     *
691     * @since 1.0.0
692     */
693    public function getContributors(\DateTime $start = null, \DateTime $end = null) : array
694    {
695        if ($start === null) {
696            $start = new \DateTime('1970-12-31');
697        }
698
699        if ($end === null) {
700            $end = new \DateTime('now');
701        }
702
703        $lines        = $this->run('shortlog -s -n --since="' . $start->format('Y-m-d') . '" --before="' . $end->format('Y-m-d') . '" --all');
704        $contributors = [];
705
706        foreach ($lines as $line) {
707            \preg_match('/^[0-9]*/', $line, $matches);
708
709            $author      = \substr($line, \strlen($matches[0]) + 1);
710            $contributor = new Author($author === false ? '' : $author);
711            $contributor->setCommitCount($this->getCommitsCount($start, $end)[$contributor->name]);
712
713            $addremove = $this->getAdditionsRemovalsByContributor($contributor, $start, $end);
714            $contributor->setAdditionCount($addremove['added']);
715            $contributor->setRemovalCount($addremove['removed']);
716
717            $contributors[] = $contributor;
718        }
719
720        return $contributors;
721    }
722
723    /**
724     * Count commits.
725     *
726     * @param \DateTime $start Start date
727     * @param \DateTime $end   End date
728     *
729     * @return array<string, int>
730     *
731     * @since 1.0.0
732     */
733    public function getCommitsCount(\DateTime $start = null, \DateTime $end = null) : array
734    {
735        if ($start === null) {
736            $start = new \DateTime('1970-12-31');
737        }
738
739        if ($end === null) {
740            $end = new \DateTime('now');
741        }
742
743        $lines   = $this->run('shortlog -s -n --since="' . $start->format('Y-m-d') . '" --before="' . $end->format('Y-m-d') . '" --all');
744        $commits = [];
745
746        foreach ($lines as $line) {
747            \preg_match('/^[0-9]*/', $line, $matches);
748
749            $temp = \substr($line, \strlen($matches[0]) + 1);
750            if ($temp !== false) {
751                $commits[$temp] = (int) $matches[0];
752            }
753        }
754
755        return $commits;
756    }
757
758    /**
759     * Get additions and removals from contributor.
760     *
761     * @param Author    $author Author
762     * @param \DateTime $start  Start date
763     * @param \DateTime $end    End date
764     *
765     * @return array ['added' => ?, 'removed'=> ?]
766     *
767     * @since 1.0.0
768     */
769    public function getAdditionsRemovalsByContributor(Author $author, \DateTime $start = null, \DateTime $end = null) : array
770    {
771        if ($start === null) {
772            $start = new \DateTime('1900-01-01');
773        }
774
775        if ($end === null) {
776            $end = new \DateTime('now');
777        }
778
779        $addremove = ['added' => 0, 'removed' => 0];
780        $lines     = $this->run(
781            'log --author=' . \escapeshellarg($author->name)
782            . ' --since="' . $start->format('Y-m-d')
783            . '" --before="' . $end->format('Y-m-d')
784            . '" --pretty=tformat: --numstat'
785        );
786
787        foreach ($lines as $line) {
788            $nums = \explode(' ', $line);
789
790            $addremove['added']   += $nums[0];
791            $addremove['removed'] += $nums[1];
792        }
793
794        return $addremove;
795    }
796
797    /**
798     * Get remote.
799     *
800     * @return string
801     *
802     * @since 1.0.0
803     */
804    public function getRemote() : string
805    {
806        return \implode("\n", $this->run('config --get remote.origin.url'));
807    }
808
809    /**
810     * Get commits by author.
811     *
812     * @param \DateTime $start  Commits from
813     * @param \DateTime $end    Commits to
814     * @param Author    $author Commits by author
815     *
816     * @return Commit[]
817     *
818     * @since 1.0.0
819     */
820    public function getCommitsBy(\DateTime $start = null, \DateTime $end = null, Author $author = null) : array
821    {
822        if ($start === null) {
823            $start = new \DateTime('1970-12-31');
824        }
825
826        if ($end === null) {
827            $end = new \DateTime('now');
828        }
829
830        $author = $author === null ? '' : ' --author=' . \escapeshellarg($author->name) . '';
831
832        $lines = $this->run(
833            'git log --before="' . $end->format('Y-m-d')
834            . '" --after="' . $start->format('Y-m-d') . '"'
835            . $author . ' --reverse --date=short');
836
837        $count   = \count($lines);
838        $commits = [];
839
840        for ($i = 0; $i < $count; ++$i) {
841            $match = \preg_match('/[0-9ABCDEFabcdef]{40}/', $lines[$i], $matches);
842
843            if ($match !== false && $match !== 0) {
844                $commit                    = $this->getCommit($matches[0]);
845                $commits[$commit->getId()] = $commit;
846            }
847        }
848
849        return $commits;
850    }
851
852    /**
853     * Get commit by id.
854     *
855     * @param string $commit Commit id
856     *
857     * @return Commit
858     *
859     * @throws \Exception
860     *
861     * @since 1.0.0
862     */
863    public function getCommit(string $commit) : Commit
864    {
865        $lines = $this->run('show --name-only ' . \escapeshellarg($commit));
866        $count = \count($lines);
867
868        if (empty($lines)) {
869            return new NullCommit();
870        }
871
872        \preg_match('/[0-9ABCDEFabcdef]{40}/', $lines[0], $matches);
873
874        if (!isset($matches[0]) || \strlen($matches[0]) !== 40) {
875            throw new \Exception('Invalid commit id');
876        }
877
878        if (StringUtils::startsWith($lines[1], 'Merge')) {
879            return new Commit();
880        }
881
882        $author = \explode(':', $lines[1] ?? '');
883        $author = \count($author) < 2 ? ['none', 'none'] : \explode('<', \trim($author[1] ?? ''));
884
885        $date = \substr($lines[2] ?? '', 6);
886        if ($date === false) {
887            $date = 'now';
888        }
889
890        $commit = new Commit($matches[0]);
891        $commit->setAuthor(new Author(\trim($author[0] ?? ''), \rtrim($author[1] ?? '', '>')));
892        $commit->setDate(new \DateTime(\trim($date)));
893        $commit->setMessage($lines[3]);
894        $commit->setTag(new Tag());
895        $commit->setRepository($this);
896        $commit->setBranch($this->branch);
897
898        for ($i = 4; $i < $count; ++$i) {
899            $commit->addFile($lines[$i]);
900        }
901
902        return $commit;
903    }
904
905    /**
906     * Get newest commit.
907     *
908     * @param int $limit Limit of commits
909     *
910     * @return Commit
911     *
912     * @throws \Exception
913     *
914     * @since 1.0.0
915     */
916    public function getNewest(int $limit = 1) : Commit
917    {
918        $lines = $this->run('log -n ' . $limit);
919
920        if (empty($lines)) {
921            return new NullCommit();
922        }
923
924        \preg_match('/[0-9ABCDEFabcdef]{40}/', $lines[0], $matches);
925
926        if (!isset($matches[0]) || \strlen($matches[0]) !== 40) {
927            throw new \Exception('Invalid commit id');
928        }
929
930        return $this->getCommit($matches[0]);
931    }
932}