diff --git a/composer.json b/composer.json index b85a240..1d0afbc 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,5 @@ "preferred-install": "dist", "sort-packages": true }, - "minimum-stability": "dev", "prefer-stable": true } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1f818db..8b1e721 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -78,162 +78,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Blame/Line.php - - - message: '#^Method Gitonomy\\Git\\Blob\:\:getContent\(\) should return string but returns string\|null\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Blob.php - - - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:__construct\(\) has parameter \$data with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorDate\(\) should return DateTime but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorEmail\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getAuthorName\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getBodyMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterDate\(\) should return DateTime but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterEmail\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getCommitterName\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getParentHashes\(\) should return array\ but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getShortHash\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getShortMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getSubjectMessage\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getTree\(\) should return Gitonomy\\Git\\Tree but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:getTreeHash\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Method Gitonomy\\Git\\Commit\:\:setData\(\) has parameter \$data with no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getCommit\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$hash of method Gitonomy\\Git\\Repository\:\:getTree\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$haystack of static method Gitonomy\\Git\\Util\\StringHelper\:\:strpos\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$string of static method Gitonomy\\Git\\Util\\StringHelper\:\:strlen\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#1 \$string of static method Gitonomy\\Git\\Util\\StringHelper\:\:substr\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Commit.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Commit.php - - message: '#^Method Gitonomy\\Git\\Diff\\Diff\:\:fromArray\(\) has parameter \$array with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue @@ -336,72 +180,12 @@ parameters: count: 1 path: src/Gitonomy/Git/Parser/ParserBase.php - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/PushReference.php - - - - message: '#^Parameter \#1 \$string of function trim expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Reference.php - - message: '#^Method Gitonomy\\Git\\Reference\\Branch\:\:isLocal\(\) should return bool but returns bool\|null\.$#' identifier: return.type count: 1 path: src/Gitonomy/Git/Reference/Branch.php - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getBodyMessage\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getGPGSignature\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getMessage\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getSubjectMessage\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerDate\(\) should return DateTime\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerEmail\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Method Gitonomy\\Git\\Reference\\Tag\:\:getTaggerName\(\) should return string\|false but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Gitonomy/Git/Reference/Tag.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/Reference/Tag.php - - message: '#^Class Gitonomy\\Git\\ReferenceBag implements generic interface IteratorAggregate but does not specify its types\: TKey, TValue$#' identifier: missingType.generics @@ -468,12 +252,6 @@ parameters: count: 1 path: src/Gitonomy/Git/Repository.php - - - message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/Repository.php - - message: '#^Parameter \#1 \$string of function trim expects string, string\|false given\.$#' identifier: argument.type @@ -521,15 +299,3 @@ parameters: identifier: missingType.iterableValue count: 1 path: src/Gitonomy/Git/WorkingCopy.php - - - - message: '#^Parameter \#1 \$rawDiff of static method Gitonomy\\Git\\Diff\\Diff\:\:parse\(\) expects string, string\|null given\.$#' - identifier: argument.type - count: 2 - path: src/Gitonomy/Git/WorkingCopy.php - - - - message: '#^Parameter \#2 \$string of function explode expects string, string\|null given\.$#' - identifier: argument.type - count: 1 - path: src/Gitonomy/Git/WorkingCopy.php diff --git a/src/Gitonomy/Git/Admin.php b/src/Gitonomy/Git/Admin.php index 024acf4..35d4875 100644 --- a/src/Gitonomy/Git/Admin.php +++ b/src/Gitonomy/Git/Admin.php @@ -37,7 +37,7 @@ public static function init(string $path, bool $bare = true, array $options = [] $process->run(); - if (!$process->isSuccessFul()) { + if (!$process->isSuccessful()) { throw new RuntimeException(\sprintf("Error on repository initialization, command wasn't successful (%s). Error output:\n%s", $process->getCommandLine(), $process->getErrorOutput())); } @@ -62,7 +62,7 @@ public static function isValidRepository(string $url, array $options = []): bool $process->run(); - return $process->isSuccessFul(); + return $process->isSuccessful(); } /** @@ -86,7 +86,7 @@ public static function isValidRepositoryAndBranch(string $url, string $branchNam $process->run(); $processOutput = $process->getOutput(); - return $process->isSuccessFul() && str_contains($processOutput, $branchName); + return $process->isSuccessful() && str_contains($processOutput, $branchName); } /** @@ -149,7 +149,7 @@ public static function cloneRepository(string $path, string $url, array $args = $process->run(); - if (!$process->isSuccessFul()) { + if (!$process->isSuccessful()) { throw new RuntimeException(\sprintf('Error while initializing repository: %s', $process->getErrorOutput())); } diff --git a/src/Gitonomy/Git/Blob.php b/src/Gitonomy/Git/Blob.php index 54a9c22..60555cb 100644 --- a/src/Gitonomy/Git/Blob.php +++ b/src/Gitonomy/Git/Blob.php @@ -12,6 +12,8 @@ namespace Gitonomy\Git; +use Gitonomy\Git\Exception\ReferenceNotFoundException; + /** * Representation of a Blob commit. * @@ -53,7 +55,12 @@ public function getHash(): string public function getContent(): string { if (null === $this->content) { - $this->content = $this->repository->run('cat-file', ['-p', $this->hash]); + $content = $this->repository->run('cat-file', ['-p', $this->hash]); + if (null === $content) { + throw new ReferenceNotFoundException($this->hash); + } + + $this->content = $content; } return $this->content; diff --git a/src/Gitonomy/Git/Commit.php b/src/Gitonomy/Git/Commit.php index 671f467..2e0215f 100644 --- a/src/Gitonomy/Git/Commit.php +++ b/src/Gitonomy/Git/Commit.php @@ -27,15 +27,36 @@ final class Commit extends Revision { /** - * Associative array of commit data. - * - * @var array + * Whether the fields below have been populated (either lazily fetched + * from the repository, or provided upfront through setData()). + */ + private bool $loaded = false; + + private ?string $treeHash = null; + + /** + * @var string[]|null */ - private array $data = []; + private ?array $parentHashes = null; + + private ?string $authorName = null; + private ?string $authorEmail = null; + private ?\DateTime $authorDate = null; + private ?string $committerName = null; + private ?string $committerEmail = null; + private ?\DateTime $committerDate = null; + private ?string $message = null; + + // Values derived from the fields above, computed and cached independently. + private ?string $shortHash = null; + private ?Tree $tree = null; + private ?string $subjectMessage = null; + private ?string $bodyMessage = null; /** - * @param Repository $repository Repository of the commit - * @param string $hash Hash of the commit + * @param Repository $repository Repository of the commit + * @param string $hash Hash of the commit + * @param array $data */ public function __construct(Repository $repository, string $hash, array $data = []) { @@ -48,10 +69,28 @@ public function __construct(Repository $repository, string $hash, array $data = $this->setData($data); } + /** + * @param array $data + */ public function setData(array $data): void { foreach ($data as $name => $value) { - $this->data[$name] = $value; + match ($name) { + 'treeHash' => $this->treeHash = self::asString($name, $value), + 'parentHashes' => $this->parentHashes = self::asStringList($name, $value), + 'authorName' => $this->authorName = self::asString($name, $value), + 'authorEmail' => $this->authorEmail = self::asString($name, $value), + 'authorDate' => $this->authorDate = self::asDateTime($name, $value), + 'committerName' => $this->committerName = self::asString($name, $value), + 'committerEmail' => $this->committerEmail = self::asString($name, $value), + 'committerDate' => $this->committerDate = self::asDateTime($name, $value), + 'message' => $this->message = self::asString($name, $value), + default => throw new InvalidArgumentException(\sprintf('Unknown commit data "%s".', $name)), + }; + } + + if ([] !== $data) { + $this->loaded = true; } } @@ -59,7 +98,12 @@ public function getDiff(): Diff { $args = ['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index', $this->revision]; - $diff = Diff::parse($this->repository->run('diff-tree', $args)); + $result = $this->repository->run('diff-tree', $args); + if (null === $result) { + throw new ReferenceNotFoundException($this->revision); + } + + $diff = Diff::parse($result); $diff->setRepository($this->repository); return $diff; @@ -78,7 +122,16 @@ public function getHash(): string */ public function getShortHash(): string { - return $this->getData('shortHash'); + if (null === $this->shortHash) { + $result = $this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision]); + if (null === $result) { + throw new ReferenceNotFoundException($this->revision); + } + + $this->shortHash = trim($result); + } + + return $this->shortHash; } /** @@ -96,7 +149,9 @@ public function getFixedShortHash(int $length = 6): string */ public function getParentHashes(): array { - return $this->getData('parentHashes'); + $this->ensureLoaded(); + + return $this->parentHashes ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -107,7 +162,7 @@ public function getParentHashes(): array public function getParents(): array { $result = []; - foreach ($this->getData('parentHashes') as $parentHash) { + foreach ($this->getParentHashes() as $parentHash) { $result[] = $this->repository->getCommit($parentHash); } @@ -119,12 +174,18 @@ public function getParents(): array */ public function getTreeHash(): string { - return $this->getData('treeHash'); + $this->ensureLoaded(); + + return $this->treeHash ?? throw new ReferenceNotFoundException($this->revision); } public function getTree(): Tree { - return $this->getData('tree'); + if (null === $this->tree) { + $this->tree = $this->repository->getTree($this->getTreeHash()); + } + + return $this->tree; } public function getLastModification(?string $path = null): self @@ -139,6 +200,10 @@ public function getLastModification(?string $path = null): self $result = $this->repository->run('log', ['--format=%H', '-n', 1, $this->revision, '--', $path]); + if (null === $result) { + throw new ReferenceNotFoundException($this->revision); + } + return $this->repository->getCommit(trim($result)); } @@ -149,7 +214,7 @@ public function getLastModification(?string $path = null): self */ public function getShortMessage(int $length = 50, bool $preserve = false, string $separator = '...'): string { - $message = $this->getData('subjectMessage'); + $message = $this->getSubjectMessage(); if (StringHelper::strlen($message) > $length) { if ($preserve && false !== ($breakpoint = StringHelper::strpos($message, ' ', $length))) { @@ -229,7 +294,9 @@ public function getIncludingBranches(bool $local = true, bool $remote = true): a */ public function getAuthorName(): string { - return $this->getData('authorName'); + $this->ensureLoaded(); + + return $this->authorName ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -237,7 +304,9 @@ public function getAuthorName(): string */ public function getAuthorEmail(): string { - return $this->getData('authorEmail'); + $this->ensureLoaded(); + + return $this->authorEmail ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -245,7 +314,9 @@ public function getAuthorEmail(): string */ public function getAuthorDate(): \DateTime { - return $this->getData('authorDate'); + $this->ensureLoaded(); + + return $this->authorDate ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -253,7 +324,9 @@ public function getAuthorDate(): \DateTime */ public function getCommitterName(): string { - return $this->getData('committerName'); + $this->ensureLoaded(); + + return $this->committerName ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -261,7 +334,9 @@ public function getCommitterName(): string */ public function getCommitterEmail(): string { - return $this->getData('committerEmail'); + $this->ensureLoaded(); + + return $this->committerEmail ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -269,7 +344,9 @@ public function getCommitterEmail(): string */ public function getCommitterDate(): \DateTime { - return $this->getData('committerDate'); + $this->ensureLoaded(); + + return $this->committerDate ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -277,7 +354,9 @@ public function getCommitterDate(): \DateTime */ public function getMessage(): string { - return $this->getData('message'); + $this->ensureLoaded(); + + return $this->message ?? throw new ReferenceNotFoundException($this->revision); } /** @@ -285,7 +364,12 @@ public function getMessage(): string */ public function getSubjectMessage(): string { - return $this->getData('subjectMessage'); + if (null === $this->subjectMessage) { + $lines = explode("\n", $this->getMessage()); + $this->subjectMessage = reset($lines); + } + + return $this->subjectMessage; } /** @@ -293,7 +377,16 @@ public function getSubjectMessage(): string */ public function getBodyMessage(): string { - return $this->getData('bodyMessage'); + if (null === $this->bodyMessage) { + $lines = explode("\n", $this->getMessage()); + + array_shift($lines); + array_shift($lines); + + $this->bodyMessage = implode("\n", $lines); + } + + return $this->bodyMessage; } public function getCommit(): self @@ -301,68 +394,75 @@ public function getCommit(): self return $this; } - private function getData(string $name): mixed + private function ensureLoaded(): void { - if (isset($this->data[$name])) { - return $this->data[$name]; - } - - if ('shortHash' === $name) { - $this->data['shortHash'] = trim($this->repository->run('log', ['--abbrev-commit', '--format=%h', '-n', 1, $this->revision])); - - return $this->data['shortHash']; + if ($this->loaded) { + return; } - if ('tree' === $name) { - $this->data['tree'] = $this->repository->getTree($this->getData('treeHash')); + $parser = new Parser\CommitParser(); - return $this->data['tree']; + try { + $result = $this->repository->run('cat-file', ['commit', $this->revision]); + } catch (ProcessException $e) { + throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); } - if ('subjectMessage' === $name) { - $lines = explode("\n", $this->getData('message')); - $this->data['subjectMessage'] = reset($lines); - - return $this->data['subjectMessage']; + if (null === $result) { + throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); } - if ('bodyMessage' === $name) { - $message = $this->getData('message'); + $parser->parse($result); - $lines = explode("\n", $message); + $this->treeHash = $parser->tree; + $this->parentHashes = $parser->parents; + $this->authorName = $parser->authorName; + $this->authorEmail = $parser->authorEmail; + $this->authorDate = $parser->authorDate; + $this->committerName = $parser->committerName; + $this->committerEmail = $parser->committerEmail; + $this->committerDate = $parser->committerDate; + $this->message = $parser->message; + + $this->loaded = true; + } - array_shift($lines); - array_shift($lines); + private static function asString(string $name, mixed $value): string + { + if (!\is_string($value)) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a string.', $name)); + } - $this->data['bodyMessage'] = implode("\n", $lines); + return $value; + } - return $this->data['bodyMessage']; + private static function asDateTime(string $name, mixed $value): \DateTime + { + if (!$value instanceof \DateTime) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be a DateTime.', $name)); } - $parser = new Parser\CommitParser(); + return $value; + } - try { - $result = $this->repository->run('cat-file', ['commit', $this->revision]); - } catch (ProcessException $e) { - throw new ReferenceNotFoundException(\sprintf('Can not find reference "%s"', $this->revision)); + /** + * @return string[] + */ + private static function asStringList(string $name, mixed $value): array + { + if (!\is_array($value)) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array.', $name)); } - $parser->parse($result); + $result = []; + foreach ($value as $item) { + if (!\is_string($item)) { + throw new InvalidArgumentException(\sprintf('Commit data "%s" must be an array of strings.', $name)); + } - $this->data['treeHash'] = $parser->tree; - $this->data['parentHashes'] = $parser->parents; - $this->data['authorName'] = $parser->authorName; - $this->data['authorEmail'] = $parser->authorEmail; - $this->data['authorDate'] = $parser->authorDate; - $this->data['committerName'] = $parser->committerName; - $this->data['committerEmail'] = $parser->committerEmail; - $this->data['committerDate'] = $parser->committerDate; - $this->data['message'] = $parser->message; - - if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(\sprintf('No data named "%s" in Commit.', $name)); + $result[] = $item; } - return $this->data[$name]; + return $result; } } diff --git a/src/Gitonomy/Git/Hooks.php b/src/Gitonomy/Git/Hooks.php index f07bf3b..6b6835c 100644 --- a/src/Gitonomy/Git/Hooks.php +++ b/src/Gitonomy/Git/Hooks.php @@ -93,7 +93,7 @@ public function set(string $name, string $content): void $path = $this->getPath($name); file_put_contents($path, $content); - chmod($path, 0o777); + chmod($path, 0o700); } /** diff --git a/src/Gitonomy/Git/PushReference.php b/src/Gitonomy/Git/PushReference.php index dc3086e..e7ca21f 100644 --- a/src/Gitonomy/Git/PushReference.php +++ b/src/Gitonomy/Git/PushReference.php @@ -117,6 +117,11 @@ private function getForce(): bool $this->after, ]); + if (null === $result) { + // No common ancestor could be established: treat it as a force update. + return true; + } + return $this->before !== trim($result); } } diff --git a/src/Gitonomy/Git/Reference.php b/src/Gitonomy/Git/Reference.php index 885335a..869e5b9 100644 --- a/src/Gitonomy/Git/Reference.php +++ b/src/Gitonomy/Git/Reference.php @@ -54,6 +54,10 @@ public function getCommitHash(): string throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); } + if (null === $result) { + throw new ReferenceNotFoundException(\sprintf('Can not find revision "%s"', $this->revision)); + } + return $this->commitHash = trim($result); } diff --git a/src/Gitonomy/Git/Reference/Tag.php b/src/Gitonomy/Git/Reference/Tag.php index 2953938..b07076d 100644 --- a/src/Gitonomy/Git/Reference/Tag.php +++ b/src/Gitonomy/Git/Reference/Tag.php @@ -27,10 +27,17 @@ */ final class Tag extends Reference { - /** - * @var array|null - */ - private ?array $data = null; + private bool $loaded = false; + + private ?string $taggerName = null; + private ?string $taggerEmail = null; + private ?\DateTime $taggerDate = null; + private ?string $message = null; + private ?string $gpgSignature = null; + + // Values derived from the fields above, computed and cached independently. + private ?string $subjectMessage = null; + private ?string $bodyMessage = null; public function getName(): string { @@ -47,12 +54,13 @@ public function getName(): string public function isAnnotated(): bool { try { - $this->repository->run('cat-file', ['tag', $this->revision]); + $result = $this->repository->run('cat-file', ['tag', $this->revision]); } catch (ProcessException $e) { return false; // Is not an annotated tag } - return true; + // In non-debug mode, a failed command returns null instead of throwing. + return null !== $result; } /** @@ -87,7 +95,12 @@ public function getCommit(): Commit */ public function getTaggerName(): string|false { - return $this->getData('taggerName'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->taggerName ?? throw new \InvalidArgumentException('No data named "taggerName" in Tag.'); } /** @@ -95,7 +108,12 @@ public function getTaggerName(): string|false */ public function getTaggerEmail(): string|false { - return $this->getData('taggerEmail'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->taggerEmail ?? throw new \InvalidArgumentException('No data named "taggerEmail" in Tag.'); } /** @@ -103,7 +121,12 @@ public function getTaggerEmail(): string|false */ public function getTaggerDate(): \DateTime|false { - return $this->getData('taggerDate'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->taggerDate ?? throw new \InvalidArgumentException('No data named "taggerDate" in Tag.'); } /** @@ -111,7 +134,12 @@ public function getTaggerDate(): \DateTime|false */ public function getMessage(): string|false { - return $this->getData('message'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); } /** @@ -119,7 +147,16 @@ public function getMessage(): string|false */ public function getSubjectMessage(): string|false { - return $this->getData('subjectMessage'); + if (!$this->isAnnotated()) { + return false; + } + + if (null === $this->subjectMessage) { + $lines = explode("\n", $this->getMessageOrThrow()); + $this->subjectMessage = reset($lines); + } + + return $this->subjectMessage; } /** @@ -127,7 +164,27 @@ public function getSubjectMessage(): string|false */ public function getBodyMessage(): string|false { - return $this->getData('bodyMessage'); + if (!$this->isAnnotated()) { + return false; + } + + if (null === $this->bodyMessage) { + $lines = explode("\n", $this->getMessageOrThrow()); + + // Drop the subject line, then the blank separator line if the + // message follows the "subject\n\nbody" convention. + array_shift($lines); + if (isset($lines[0]) && '' === $lines[0]) { + array_shift($lines); + } + if ([] !== $lines && '' === end($lines)) { + array_pop($lines); + } + + $this->bodyMessage = implode("\n", $lines); + } + + return $this->bodyMessage; } /** @@ -135,7 +192,12 @@ public function getBodyMessage(): string|false */ public function getGPGSignature(): string|false { - return $this->getData('gpgSignature'); + if (!$this->isAnnotated()) { + return false; + } + $this->ensureLoaded(); + + return $this->gpgSignature ?? throw new \InvalidArgumentException('No data named "gpgSignature" in Tag.'); } /** @@ -152,51 +214,34 @@ public function isSigned(): bool } } - private function getData(string $name): mixed + private function getMessageOrThrow(): string { - if (!$this->isAnnotated()) { - return false; - } + $this->ensureLoaded(); - if (isset($this->data[$name])) { - return $this->data[$name]; - } - - if ('subjectMessage' === $name) { - $lines = explode("\n", $this->getData('message')); - $this->data['subjectMessage'] = reset($lines); - - return $this->data['subjectMessage']; - } - - if ('bodyMessage' === $name) { - $message = $this->getData('message'); - - $lines = explode("\n", $message); - - array_shift($lines); - array_pop($lines); - - $this->data['bodyMessage'] = implode("\n", $lines); + return $this->message ?? throw new \InvalidArgumentException('No data named "message" in Tag.'); + } - return $this->data['bodyMessage']; + private function ensureLoaded(): void + { + if ($this->loaded) { + return; } $parser = new TagParser(); $result = $this->repository->run('cat-file', ['tag', $this->revision]); - $parser->parse($result); + if (null === $result) { + throw new \InvalidArgumentException('Unable to read tag data.'); + } - $this->data['taggerName'] = $parser->taggerName; - $this->data['taggerEmail'] = $parser->taggerEmail; - $this->data['taggerDate'] = $parser->taggerDate; - $this->data['message'] = $parser->message; - $this->data['gpgSignature'] = $parser->gpgSignature; + $parser->parse($result); - if (!isset($this->data[$name])) { - throw new \InvalidArgumentException(\sprintf('No data named "%s" in Tag.', $name)); - } + $this->taggerName = $parser->taggerName; + $this->taggerEmail = $parser->taggerEmail; + $this->taggerDate = $parser->taggerDate; + $this->message = $parser->message; + $this->gpgSignature = $parser->gpgSignature; - return $this->data[$name]; + $this->loaded = true; } } diff --git a/src/Gitonomy/Git/ReferenceBag.php b/src/Gitonomy/Git/ReferenceBag.php index b1833b3..e5408ad 100644 --- a/src/Gitonomy/Git/ReferenceBag.php +++ b/src/Gitonomy/Git/ReferenceBag.php @@ -145,9 +145,8 @@ public function hasTag(string $name): bool public function getFirstBranch(): Reference|false { $this->initialize(); - reset($this->branches); - return current($this->references); + return reset($this->branches); } /** diff --git a/src/Gitonomy/Git/Repository.php b/src/Gitonomy/Git/Repository.php index ca480f3..3e49988 100644 --- a/src/Gitonomy/Git/Repository.php +++ b/src/Gitonomy/Git/Repository.php @@ -361,7 +361,12 @@ public function getDiff(RevisionList|Revision|string|array|null $revisions): Dif $args = array_merge(['-r', '-p', '--raw', '-m', '-M', '--no-commit-id', '--full-index'], $revisions->getAsTextArray()); - $diff = Diff::parse($this->run('diff', $args)); + $result = $this->run('diff', $args); + if (null === $result) { + throw new RuntimeException('Unable to compute diff.'); + } + + $diff = Diff::parse($result); $diff->setRepository($this); return $diff; @@ -393,7 +398,7 @@ public function getSize(): int */ public function shell(string $command, array $env = []): void { - $argument = \sprintf('%s \'%s\'', $command, $this->gitDir); + $argument = \sprintf('%s %s', $command, escapeshellarg($this->gitDir)); $prefix = ''; foreach ($env as $name => $value) { diff --git a/src/Gitonomy/Git/WorkingCopy.php b/src/Gitonomy/Git/WorkingCopy.php index e48acd9..aa96d52 100644 --- a/src/Gitonomy/Git/WorkingCopy.php +++ b/src/Gitonomy/Git/WorkingCopy.php @@ -15,6 +15,7 @@ use Gitonomy\Git\Diff\Diff; use Gitonomy\Git\Exception\InvalidArgumentException; use Gitonomy\Git\Exception\LogicException; +use Gitonomy\Git\Exception\RuntimeException; /** * @author Alexandre Salomé @@ -34,7 +35,12 @@ public function __construct( */ public function getUntrackedFiles(): array { - $lines = explode("\0", $this->run('status', ['--porcelain', '--untracked-files=all', '-z'])); + $output = $this->run('status', ['--porcelain', '--untracked-files=all', '-z']); + if (null === $output) { + return []; + } + + $lines = explode("\0", $output); $lines = array_filter($lines, static function ($l) { return '?? ' === substr($l, 0, 3); }); @@ -46,7 +52,12 @@ public function getUntrackedFiles(): array public function getDiffPending(): Diff { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index'])); + $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index']); + if (null === $result) { + throw new RuntimeException('Unable to compute pending diff.'); + } + + $diff = Diff::parse($result); $diff->setRepository($this->repository); return $diff; @@ -54,7 +65,12 @@ public function getDiffPending(): Diff public function getDiffStaged(): Diff { - $diff = Diff::parse($this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged'])); + $result = $this->run('diff', ['-r', '-p', '--raw', '-m', '-M', '--full-index', '--staged']); + if (null === $result) { + throw new RuntimeException('Unable to compute staged diff.'); + } + + $diff = Diff::parse($result); $diff->setRepository($this->repository); return $diff; diff --git a/tests/Gitonomy/Git/Tests/HooksTest.php b/tests/Gitonomy/Git/Tests/HooksTest.php index 7256db1..b1ab8be 100644 --- a/tests/Gitonomy/Git/Tests/HooksTest.php +++ b/tests/Gitonomy/Git/Tests/HooksTest.php @@ -134,7 +134,7 @@ public function testSet(Repository $repository): void $this->assertEquals('bar', file_get_contents($file), 'Hook content is correct'); $perms = fileperms($file); - $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o777, $perms & 0o777, 'Hook permissions are correct'); + $this->assertEquals(\defined('PHP_WINDOWS_VERSION_BUILD') ? 0o666 : 0o700, $perms & 0o777, 'Hook permissions are correct'); } #[DataProvider('provideFoobar')] diff --git a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php index a626663..7278b19 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceBagTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceBagTest.php @@ -12,11 +12,35 @@ namespace Gitonomy\Git\Tests; +use Gitonomy\Git\Reference\Branch; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; class ReferenceBagTest extends AbstractTestCase { + #[DataProvider('provideFoobar')] + public function testGetFirstBranch(Repository $repository): void + { + $branch = $repository->getReferences()->getFirstBranch(); + + $this->assertInstanceOf(Branch::class, $branch); + } + + public function testGetFirstBranchWithNoBranches(): void + { + $repository = self::createFoobarRepository(false); + $wc = $repository->getWorkingCopy(); + $wc->checkout('0.1'); + + foreach ($repository->getReferences()->getBranches() as $branch) { + $branch->delete(); + } + + $refs = $repository->getReferences(true); + $this->assertFalse($refs->hasBranches()); + $this->assertFalse($refs->getFirstBranch(), 'getFirstBranch() must not return a non-branch reference'); + } + #[DataProvider('provideFoobar')] public function testUnknownReference(Repository $repository): void { diff --git a/tests/Gitonomy/Git/Tests/ReferenceTest.php b/tests/Gitonomy/Git/Tests/ReferenceTest.php index f164c34..55da2a5 100644 --- a/tests/Gitonomy/Git/Tests/ReferenceTest.php +++ b/tests/Gitonomy/Git/Tests/ReferenceTest.php @@ -70,6 +70,16 @@ public function testGetTag(Repository $repository): void $this->assertEquals(self::LONGFILE_COMMIT, $tag->getCommit()->getHash(), 'Commit hash is correct'); } + #[DataProvider('provideFoobar')] + public function testIsAnnotatedIsFalseForLightweightTagWhenDebugIsFalse(Repository $repository): void + { + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $tag = $repository->getReferences()->getTag('0.1'); + + $this->assertFalse($tag->isAnnotated(), 'Lightweight tag must not be reported as annotated'); + } + #[DataProvider('provideFoobar')] public function testAnnotatedTag(Repository $repository): void { @@ -94,6 +104,21 @@ public function testAnnotatedTag(Repository $repository): void $this->assertEquals('fbde681b329a39e08b63dc54b341a3274c0380c0', $tag->getCommit()->getHash(), 'Tag commit is correct'); } + #[DataProvider('provideFoobar')] + public function testAnnotatedTagBodyMessageWithBlankLineSeparator(Repository $repository): void + { + $repository->run('config', ['user.email', 'test@example.com']); + $repository->run('config', ['user.name', 'Test']); + + $hash = $repository->getLog()->getSingleCommit()->getHash(); + $repository->run('tag', ['-a', 'blank-sep', '-m', 'Subject line', '-m', "Body line 1\nBody line 2", $hash]); + + $tag = $repository->getReferences()->getTag('blank-sep'); + + $this->assertEquals('Subject line', $tag->getSubjectMessage(), 'Message subject is correct'); + $this->assertEquals("Body line 1\nBody line 2", $tag->getBodyMessage(), 'Message body has no leading blank line'); + } + #[DataProvider('provideFoobar')] public function testGetTagNotExistingError(Repository $repository): void { diff --git a/tests/Gitonomy/Git/Tests/RepositoryTest.php b/tests/Gitonomy/Git/Tests/RepositoryTest.php index c0fee88..e7bc480 100644 --- a/tests/Gitonomy/Git/Tests/RepositoryTest.php +++ b/tests/Gitonomy/Git/Tests/RepositoryTest.php @@ -13,6 +13,8 @@ namespace Gitonomy\Git\Tests; use Gitonomy\Git\Blob; +use Gitonomy\Git\Commit; +use Gitonomy\Git\Exception\ReferenceNotFoundException; use Gitonomy\Git\Exception\RuntimeException; use Gitonomy\Git\Repository; use PHPUnit\Framework\Attributes\DataProvider; @@ -20,6 +22,26 @@ class RepositoryTest extends AbstractTestCase { + public function testRunReturnsNullInsteadOfThrowingWhenDebugIsFalse(): void + { + $repository = self::createFoobarRepository(true); + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $this->assertNull($repository->run('not-a-command')); + } + + public function testGetShortHashThrowsCleanExceptionWhenDebugIsFalse(): void + { + $repository = self::createFoobarRepository(true); + $repository = new Repository($repository->getPath(), array_merge(self::getOptions(), ['debug' => false])); + + $commit = new Commit($repository, str_repeat('a', 40)); + + $this->expectException(ReferenceNotFoundException::class); + + $commit->getShortHash(); + } + #[DataProvider('provideFoobar')] public function testGetBlobWithExistingWorks(Repository $repository): void {