diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 26697bef..24c2c5c7 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - level: 5 + level: 10 treatPhpDocTypesAsCertain: false paths: - src diff --git a/src/Diff/MyersDiff.php b/src/Diff/MyersDiff.php index 9d455ab9..58cc8df1 100644 --- a/src/Diff/MyersDiff.php +++ b/src/Diff/MyersDiff.php @@ -142,6 +142,9 @@ private static function myers(array $a, array $b, int $n, int $m): array * rounds 0..d-1 finished. For backtrace at step s, we need V after * round s-1 finished = trace[s] (NOT trace[s-1]). * + * @param array> $trace + * @param array $a + * @param array $b * @return array */ private static function backtrace( diff --git a/src/Diff/TreeDiff.php b/src/Diff/TreeDiff.php index d0313f0a..a7ecc6d9 100644 --- a/src/Diff/TreeDiff.php +++ b/src/Diff/TreeDiff.php @@ -66,11 +66,15 @@ private function detectRenames(array $results): array $matched = []; foreach ($deleted as $di => $del) { + if ($del->oldHash === null) { + continue; + } + $bestScore = 0; $bestIdx = null; foreach ($added as $ai => $add) { - if (isset($matched[$ai])) { + if (isset($matched[$ai]) || $add->newHash === null) { continue; } @@ -101,6 +105,11 @@ private function detectRenames(array $results): array if ($bestIdx !== null) { $add = $added[$bestIdx]; + + if ($add->newHash === null) { + continue; + } + $oldContent = $this->readBlobContent($del->oldHash); $newContent = $this->readBlobContent($add->newHash); $hunks = DiffAlgorithm::diff($oldContent, $newContent, $this->algorithm); @@ -220,6 +229,10 @@ private function diffInto(?ObjectId $oldTree, ?ObjectId $newTree, string $prefix continue; } + if ($oldEntry === null || $newEntry === null) { + continue; + } + if ($oldEntry['hash'] === $newEntry['hash']) { continue; } diff --git a/src/Encoding/BinaryReader.php b/src/Encoding/BinaryReader.php index 1acc4bb8..5401238b 100644 --- a/src/Encoding/BinaryReader.php +++ b/src/Encoding/BinaryReader.php @@ -96,9 +96,8 @@ public function readByte(): int public function readUint32(): int { $data = $this->read(4); - $unpacked = unpack('N', $data); - return (int) $unpacked[1]; + return $this->unpackOne('N', $data); } /** @@ -117,9 +116,9 @@ public function readUint24(): int public function readUint64(): int { $data = $this->read(8); - $parts = unpack('Nhigh/Nlow', $data); + $parts = $this->unpackPair('Nhigh/Nlow', $data); - return ((int) $parts['high'] << 32) | (int) $parts['low']; + return ($parts['high'] << 32) | $parts['low']; } /** @@ -128,9 +127,38 @@ public function readUint64(): int public function readUint16(): int { $data = $this->read(2); - $unpacked = unpack('n', $data); - return (int) $unpacked[1]; + return $this->unpackOne('n', $data); + } + + private function unpackOne(string $format, string $data): int + { + $unpacked = unpack($format, $data); + + if ($unpacked === false || !isset($unpacked[1]) || !is_int($unpacked[1])) { + throw new RuntimeException("Failed to unpack {$format}"); + } + + return $unpacked[1]; + } + + /** + * @return array{high: int, low: int} + */ + private function unpackPair(string $format, string $data): array + { + $unpacked = unpack($format, $data); + + if ( + $unpacked === false + || !isset($unpacked['high'], $unpacked['low']) + || !is_int($unpacked['high']) + || !is_int($unpacked['low']) + ) { + throw new RuntimeException("Failed to unpack {$format}"); + } + + return ['high' => $unpacked['high'], 'low' => $unpacked['low']]; } /** diff --git a/src/Encoding/VarInt.php b/src/Encoding/VarInt.php index 02fe82a2..6512bb96 100644 --- a/src/Encoding/VarInt.php +++ b/src/Encoding/VarInt.php @@ -73,12 +73,12 @@ public static function encodePackSize(int $type, int $size): string $result = ''; while ($size > 0) { - $result .= chr($byte | 0x80); + $result .= chr(($byte | 0x80) & 0xFF); $byte = $size & 0x7F; $size >>= 7; } - $result .= chr($byte); + $result .= chr($byte & 0xFF); return $result; } diff --git a/src/Graph/Blame.php b/src/Graph/Blame.php index 63240da3..e9f3e0d7 100644 --- a/src/Graph/Blame.php +++ b/src/Graph/Blame.php @@ -206,6 +206,10 @@ private function matchedLineIndexes(array $olderLines, array $newerLines): array return array_keys($matchedNewIndexes); } + /** + * @param array $olderLines + * @param array $newerLines + */ private function isPrefixMatch(array $olderLines, array $newerLines, int $length): bool { for ($index = 0; $index < $length; $index++) { @@ -217,6 +221,10 @@ private function isPrefixMatch(array $olderLines, array $newerLines, int $length return true; } + /** + * @param array $olderLines + * @param array $newerLines + */ private function isSuffixMatch(array $olderLines, array $newerLines, int $oldCount, int $newCount): bool { $offset = $newCount - $oldCount; diff --git a/src/Graph/CommitWalker.php b/src/Graph/CommitWalker.php index b01b7dfb..d8bea873 100644 --- a/src/Graph/CommitWalker.php +++ b/src/Graph/CommitWalker.php @@ -28,23 +28,7 @@ public function __construct(private readonly ObjectDatabase $objects) */ public function walk(ObjectId $from, int $limit = 50): array { - $commits = []; - $visited = []; - $queue = new \SplPriorityQueue(); - - $this->enqueue($queue, $from, $visited); - - while (!$queue->isEmpty() && count($commits) < $limit) { - ['id' => $id, 'commit' => $object] = $queue->extract(); - - $commits[] = $object; - - foreach ($object->parents as $parentId) { - $this->enqueue($queue, $parentId, $visited); - } - } - - return $commits; + return $this->walkAll([$from], $limit); } /** @@ -57,15 +41,15 @@ public function walkAll(array $from, int $limit = 50): array { $commits = []; $visited = []; - $queue = new \SplPriorityQueue(); + $queue = []; foreach ($from as $id) { $this->enqueue($queue, $id, $visited); } - while (!$queue->isEmpty() && count($commits) < $limit) { - ['id' => $id, 'commit' => $object] = $queue->extract(); - + while ($queue !== [] && count($commits) < $limit) { + $entry = array_shift($queue); + $object = $entry['commit']; $commits[] = $object; foreach ($object->parents as $parentId) { @@ -77,9 +61,13 @@ public function walkAll(array $from, int $limit = 50): array } /** + * Insert by descending timestamp (newest first). + * + * @param array $queue * @param array $visited + * @param-out array $queue */ - private function enqueue(\SplPriorityQueue $queue, ObjectId $id, array &$visited): void + private function enqueue(array &$queue, ObjectId $id, array &$visited): void { if (isset($visited[$id->hex])) { return; @@ -89,9 +77,23 @@ private function enqueue(\SplPriorityQueue $queue, ObjectId $id, array &$visited $object = $this->objects->read($id); - if ($object instanceof Commit) { - $timestamp = $object->committerTimestamp() ?? 0; - $queue->insert(['id' => $id, 'commit' => $object], $timestamp); + if (!$object instanceof Commit) { + return; } + + $timestamp = $object->committerTimestamp() ?? 0; + $entry = ['timestamp' => $timestamp, 'commit' => $object]; + + $count = count($queue); + + for ($i = 0; $i < $count; $i++) { + if ($queue[$i]['timestamp'] < $timestamp) { + array_splice($queue, $i, 0, [$entry]); + + return; + } + } + + $queue[] = $entry; } } diff --git a/src/Graph/Grep.php b/src/Graph/Grep.php index c4e85d01..a0d98427 100644 --- a/src/Graph/Grep.php +++ b/src/Graph/Grep.php @@ -26,6 +26,7 @@ public function __construct(private readonly ObjectDatabase $objects) /** * Search for a pattern in all files in a tree. * + * @param array{regex?: bool, ignore_case?: bool} $options * @return array */ public function grep(ObjectId $treeId, string $pattern, string $prefix = '', array $options = []): array diff --git a/src/Lfs/LfsClient.php b/src/Lfs/LfsClient.php index daffbedd..7273dfc0 100644 --- a/src/Lfs/LfsClient.php +++ b/src/Lfs/LfsClient.php @@ -122,24 +122,41 @@ private function batch(string $operation, array $objects): array $data = json_decode($response, true); - if (!is_array($data) || !isset($data['objects'])) { + if (!is_array($data) || !isset($data['objects']) || !is_array($data['objects'])) { throw new ProtocolException('Invalid LFS batch response'); } $results = []; foreach ($data['objects'] as $obj) { + if (!is_array($obj)) { + continue; + } + $href = null; + $actions = $obj['actions'] ?? null; + + if (is_array($actions) && isset($actions[$operation]) && is_array($actions[$operation])) { + $candidate = $actions[$operation]['href'] ?? null; + + if (is_string($candidate)) { + $href = $candidate; + } + } + + $errorBag = $obj['error'] ?? null; + $error = null; - if (isset($obj['actions'][$operation]['href'])) { - $href = $obj['actions'][$operation]['href']; + if (is_array($errorBag) && isset($errorBag['message']) && is_string($errorBag['message'])) { + $error = $errorBag['message']; } - $error = $obj['error']['message'] ?? null; + $oid = $obj['oid'] ?? ''; + $size = $obj['size'] ?? 0; $results[] = [ - 'oid' => $obj['oid'] ?? '', - 'size' => $obj['size'] ?? 0, + 'oid' => is_string($oid) ? $oid : '', + 'size' => is_int($size) ? $size : 0, 'href' => $href, 'error' => $error, ]; diff --git a/src/Merge/MergeBase.php b/src/Merge/MergeBase.php index a322f734..0b1db57e 100644 --- a/src/Merge/MergeBase.php +++ b/src/Merge/MergeBase.php @@ -192,9 +192,9 @@ private function parentsOf(string $hex): array return []; } - return array_map( + return array_values(array_map( static fn (ObjectId $parent): string => $parent->hex, $commit->parents, - ); + )); } } diff --git a/src/Object/ObjectId.php b/src/Object/ObjectId.php index 0a3e49e3..801a4b70 100644 --- a/src/Object/ObjectId.php +++ b/src/Object/ObjectId.php @@ -83,8 +83,13 @@ public static function compute(ObjectType $type, string $content, string $algo = { $header = $type->value . ' ' . strlen($content) . "\0"; $hex = hash($algo, $header . $content); + $binary = hex2bin($hex); - return new self($hex, hex2bin($hex), $algo); + if ($binary === false) { + throw new \RuntimeException("hash({$algo}) produced invalid hex: {$hex}"); + } + + return new self($hex, $binary, $algo); } /** diff --git a/src/Pack/DeltaResolver.php b/src/Pack/DeltaResolver.php index 5a89228f..efbb7215 100644 --- a/src/Pack/DeltaResolver.php +++ b/src/Pack/DeltaResolver.php @@ -17,8 +17,12 @@ final class DeltaResolver { public static function maxChainDepth(): int { - return defined('PITMASTER_MAX_DELTA_CHAIN') - ? (int) constant('PITMASTER_MAX_DELTA_CHAIN') - : 50; + if (!defined('PITMASTER_MAX_DELTA_CHAIN')) { + return 50; + } + + $value = constant('PITMASTER_MAX_DELTA_CHAIN'); + + return is_int($value) ? $value : 50; } } diff --git a/src/Pack/PackFile.php b/src/Pack/PackFile.php index a265c1c9..db42df37 100644 --- a/src/Pack/PackFile.php +++ b/src/Pack/PackFile.php @@ -107,9 +107,7 @@ public function readAtOffset(int $offset, ?string $expectedHash = null): GitObje */ private function resolveAtOffset(int $offset, int $depth): array { - $maxDepth = defined('PITMASTER_MAX_DELTA_CHAIN') - ? (int) constant('PITMASTER_MAX_DELTA_CHAIN') - : 50; + $maxDepth = DeltaResolver::maxChainDepth(); if ($depth > $maxDepth) { throw PackParseException::deltaChainTooDeep($depth, $maxDepth); @@ -119,16 +117,28 @@ private function resolveAtOffset(int $offset, int $depth): array if (!$entry->isDelta()) { $type = $entry->objectType(); + + if ($type === null) { + throw PackParseException::invalidDeltaBase("invalid base type at offset {$offset}"); + } + $content = $this->readCompressedData($entry->dataOffset, $entry->uncompressedSize); return ['type' => $type, 'content' => $content]; } if ($entry->isOfsDelta()) { + if ($entry->baseOffset === null) { + throw PackParseException::invalidDeltaBase("ofs-delta missing baseOffset at {$offset}"); + } + $baseOffset = $entry->entryOffset - $entry->baseOffset; $base = $this->resolveAtOffset($baseOffset, $depth + 1); } else { - // REF_DELTA: look up the base by hash + if ($entry->baseHash === null) { + throw PackParseException::invalidDeltaBase("ref-delta missing baseHash at {$offset}"); + } + $basePackOffset = $this->index->findOffset($entry->baseHash); if ($basePackOffset === null) { diff --git a/src/Pack/PackIndexer.php b/src/Pack/PackIndexer.php index ecad5a5b..592b05c1 100644 --- a/src/Pack/PackIndexer.php +++ b/src/Pack/PackIndexer.php @@ -191,9 +191,7 @@ private static function resolveObjectAtOffset( array &$hashToOffset, int $depth = 0, ): array { - $maxDepth = defined('PITMASTER_MAX_DELTA_CHAIN') - ? (int) constant('PITMASTER_MAX_DELTA_CHAIN') - : 50; + $maxDepth = DeltaResolver::maxChainDepth(); if ($depth > $maxDepth) { throw PackParseException::deltaChainTooDeep($depth, $maxDepth); diff --git a/src/Pack/PackWriter.php b/src/Pack/PackWriter.php index 75fbf605..5b776b42 100644 --- a/src/Pack/PackWriter.php +++ b/src/Pack/PackWriter.php @@ -6,6 +6,7 @@ use Pitmaster\Object\GitObject; use Pitmaster\Storage\ObjectSerializer; +use RuntimeException; /** * Pack file writer. Creates .pack and .idx files from a set of objects. @@ -72,13 +73,18 @@ private static function buildPack(array $objects): array $entryHeader = ''; while ($size > 0) { - $entryHeader .= chr($byte | 0x80); + $entryHeader .= chr(($byte | 0x80) & 0xFF); $byte = $size & 0x7F; $size >>= 7; } - $entryHeader .= chr($byte); + $entryHeader .= chr($byte & 0xFF); $compressed = zlib_encode($raw, ZLIB_ENCODING_DEFLATE); + + if ($compressed === false) { + throw new RuntimeException("Failed to zlib-encode pack entry for {$object->id->hex}"); + } + $entryData = $entryHeader . $compressed; $crc = crc32($entryData); diff --git a/src/Protocol/Bundle.php b/src/Protocol/Bundle.php index 60a978a8..a6a86b15 100644 --- a/src/Protocol/Bundle.php +++ b/src/Protocol/Bundle.php @@ -102,6 +102,9 @@ public static function parse(string $data): self /** * Create a bundle from refs and pack data. + * + * @param array $refs + * @param array $prerequisites */ public static function create(array $refs, string $packData, array $prerequisites = []): self { diff --git a/src/Protocol/GitProtocolClient.php b/src/Protocol/GitProtocolClient.php index 45390b7d..48e6daa7 100644 --- a/src/Protocol/GitProtocolClient.php +++ b/src/Protocol/GitProtocolClient.php @@ -224,7 +224,13 @@ private function readUntilFlush($socket): string $remaining = $lineLen - 4; while (strlen($payload) < $remaining) { - $chunk = fread($socket, $remaining - strlen($payload)); + $toRead = $remaining - strlen($payload); + + if ($toRead < 1) { + break; + } + + $chunk = fread($socket, $toRead); if ($chunk === false) { break 2; diff --git a/src/Protocol/PktLine.php b/src/Protocol/PktLine.php index 70d5ba6e..542610e4 100644 --- a/src/Protocol/PktLine.php +++ b/src/Protocol/PktLine.php @@ -143,7 +143,13 @@ public static function readFromStream($stream): array $payload = ''; while (strlen($payload) < $payloadLen) { - $chunk = fread($stream, $payloadLen - strlen($payload)); + $remaining = $payloadLen - strlen($payload); + + if ($remaining < 1) { + break; + } + + $chunk = fread($stream, $remaining); if ($chunk === false || $chunk === '') { throw new ProtocolException('Truncated pkt-line stream'); diff --git a/src/Protocol/ProtocolV1.php b/src/Protocol/ProtocolV1.php index 7246aaa3..27f23618 100644 --- a/src/Protocol/ProtocolV1.php +++ b/src/Protocol/ProtocolV1.php @@ -103,6 +103,7 @@ public static function buildFetchRequest( * Build a v1 push request for receive-pack. * * @param array $updates + * @param array $capabilities */ public static function buildPushRequest(array $updates, array $capabilities = self::DEFAULT_PUSH_CAPABILITIES): string { diff --git a/src/Protocol/SmartHttpClient.php b/src/Protocol/SmartHttpClient.php index 60b499c3..385b0986 100644 --- a/src/Protocol/SmartHttpClient.php +++ b/src/Protocol/SmartHttpClient.php @@ -18,9 +18,14 @@ final class SmartHttpClient implements UploadPackTransport, ReceivePackTransport public function __construct(?int $timeout = null) { - $this->timeout = $timeout ?? (defined('PITMASTER_HTTP_TIMEOUT') - ? (int) constant('PITMASTER_HTTP_TIMEOUT') - : 30); + if ($timeout !== null) { + $this->timeout = $timeout; + } elseif (defined('PITMASTER_HTTP_TIMEOUT')) { + $value = constant('PITMASTER_HTTP_TIMEOUT'); + $this->timeout = is_int($value) ? $value : 30; + } else { + $this->timeout = 30; + } } /** @@ -130,6 +135,9 @@ private function discoverServiceRefs(string $url, string $service, string $expec return RefDiscovery::parse($filtered); } + /** + * @param array $extraHeaders + */ private function get(string $url, string $expectedContentType, array $extraHeaders = []): string { $headers = array_merge(['User-Agent: Pitmaster/1.0'], $extraHeaders); @@ -164,6 +172,9 @@ private function get(string $url, string $expectedContentType, array $extraHeade return $response; } + /** + * @param array $extraHeaders + */ private function post( string $url, string $body, diff --git a/src/Protocol/SshClient.php b/src/Protocol/SshClient.php index 1d73cf00..dec8e59b 100644 --- a/src/Protocol/SshClient.php +++ b/src/Protocol/SshClient.php @@ -388,7 +388,13 @@ private function readExact($stream, int $bytes): ?string $buffer = ''; while (strlen($buffer) < $bytes) { - $chunk = fread($stream, $bytes - strlen($buffer)); + $toRead = $bytes - strlen($buffer); + + if ($toRead < 1) { + break; + } + + $chunk = fread($stream, $toRead); if ($chunk === false || $chunk === '') { return null; diff --git a/src/Protocol/UploadPackClient.php b/src/Protocol/UploadPackClient.php index 27dda431..84d3e43e 100644 --- a/src/Protocol/UploadPackClient.php +++ b/src/Protocol/UploadPackClient.php @@ -86,7 +86,7 @@ public function fetchV2Result(string $url, array $wants, array $haves = [], ?int ]; } - $request = ProtocolV2::buildFetchRequest($wants, $haves, ProtocolV2::DEFAULT_FETCH_FEATURES, true, [], $depth); + $request = ProtocolV2::buildFetchRequest(array_values($wants), array_values($haves), ProtocolV2::DEFAULT_FETCH_FEATURES, true, [], $depth); if (!$this->transport instanceof SmartHttpClient) { throw new ProtocolException('Protocol v2 fetch requires smart HTTP transport'); } diff --git a/src/Ref/Notes.php b/src/Ref/Notes.php index b16311d1..589045c6 100644 --- a/src/Ref/Notes.php +++ b/src/Ref/Notes.php @@ -248,6 +248,9 @@ private function findNoteBlobId(ObjectId $treeId, ObjectId $commitId): ?ObjectId return null; } + /** + * @param array $parts + */ private function findBlobAtPath(ObjectId $treeId, array $parts): ?ObjectId { $tree = $this->objects->read($treeId); @@ -256,7 +259,13 @@ private function findBlobAtPath(ObjectId $treeId, array $parts): ?ObjectId return null; } - $entry = $tree->entry(array_shift($parts)); + $name = array_shift($parts); + + if ($name === null) { + return null; + } + + $entry = $tree->entry($name); if ($entry === null) { return null; @@ -302,59 +311,48 @@ private function flattenNotesTreeInto(ObjectId $treeId, string $prefix, array &$ */ private function buildNotesTree(array $entries): Tree { - $tree = []; ksort($entries); - foreach ($entries as $path => $blobId) { - $this->insertTreeEntry($tree, explode('/', $path), $blobId); - } - - return $this->writeTreeNode($tree); + return $this->writeTreeFromPaths($entries); } /** - * @param array $node - * @param array $parts + * @param array $entries Path -> blob ID */ - private function insertTreeEntry(array &$node, array $parts, ObjectId $blobId): void + private function writeTreeFromPaths(array $entries): Tree { - $name = array_shift($parts); + $direct = []; + $subDirs = []; - if ($name === null) { - return; - } - - if ($parts === []) { - $node[$name] = $blobId; - return; - } + foreach ($entries as $path => $blobId) { + $slashPos = strpos($path, '/'); - $node[$name] ??= []; - $this->insertTreeEntry($node[$name], $parts, $blobId); - } + if ($slashPos === false) { + $direct[$path] = $blobId; + continue; + } - /** - * @param array $node - */ - private function writeTreeNode(array $node): Tree - { - ksort($node); - $entries = []; + $dirName = substr($path, 0, $slashPos); + $rest = substr($path, $slashPos + 1); + $subDirs[$dirName] ??= []; + $subDirs[$dirName][$rest] = $blobId; + } - foreach ($node as $name => $value) { - $name = (string) $name; + $treeEntries = []; - if ($value instanceof ObjectId) { - $entries[] = new TreeEntry('100644', $name, $value); - continue; - } + foreach ($direct as $name => $blobId) { + $treeEntries[$name] = new TreeEntry('100644', $name, $blobId); + } - $tree = $this->writeTreeNode($value); - $this->objects->write($tree); - $entries[] = new TreeEntry('40000', $name, $tree->id); + foreach ($subDirs as $dirName => $subEntries) { + $subTree = $this->writeTreeFromPaths($subEntries); + $this->objects->write($subTree); + $treeEntries[$dirName] = new TreeEntry('40000', $dirName, $subTree->id); } - return Tree::fromEntries($entries); + ksort($treeEntries); + + return Tree::fromEntries(array_values($treeEntries)); } private function currentIdentity(): string diff --git a/src/Ref/PackedRefStore.php b/src/Ref/PackedRefStore.php index 3167a34e..f6ea0200 100644 --- a/src/Ref/PackedRefStore.php +++ b/src/Ref/PackedRefStore.php @@ -16,12 +16,14 @@ */ final class PackedRefStore implements RefStore { - /** @var array|null */ - private ?array $refs = null; + /** @var array */ + private array $refs = []; /** @var array Peeled values for tag refs */ private array $peeled = []; + private bool $loaded = false; + public function __construct(private readonly string $gitDir) { } @@ -119,15 +121,16 @@ public function replace(array $refs, array $peeled = []): void { $this->refs = $refs; $this->peeled = $peeled; + $this->loaded = true; } private function ensureLoaded(): void { - if ($this->refs !== null) { + if ($this->loaded) { return; } - $this->refs = []; + $this->loaded = true; $path = $this->gitDir . '/packed-refs'; if (!is_file($path)) { diff --git a/src/Ref/Reflog.php b/src/Ref/Reflog.php index fbd15ce1..7f3795e9 100644 --- a/src/Ref/Reflog.php +++ b/src/Ref/Reflog.php @@ -104,6 +104,8 @@ public function entries(): array /** * Get the most recent entry. + * + * @return array{old: string, new: string, identity: string, message: string}|null */ public function latest(): ?array { diff --git a/src/Repository.php b/src/Repository.php index bcb83a08..a6263864 100644 --- a/src/Repository.php +++ b/src/Repository.php @@ -779,10 +779,10 @@ public function logOneline(int $limit = 50, bool $all = false, ?string $path = n ? $this->logPath($path, $limit) : ($all ? $this->logAll($limit) : $this->log($limit)); - return array_map( + return array_values(array_map( fn (Commit $commit): string => substr($commit->id->hex, 0, 7) . ' ' . $this->subjectLine($commit->message), $commits, - ); + )); } /** @@ -943,7 +943,7 @@ public function mv(string $source, string $destination): void */ public function remove(string ...$paths): void { - ['cached' => $cached, 'recursive' => $recursive, 'paths' => $paths] = $this->parseRemoveArguments($paths); + ['cached' => $cached, 'recursive' => $recursive, 'paths' => $paths] = $this->parseRemoveArguments(array_values($paths)); if ($paths === []) { throw new \RuntimeException('No pathspec given for remove'); @@ -957,7 +957,7 @@ public function remove(string ...$paths): void $index = $this->index(); $headId = $this->refs->resolveHead(); $headEntries = $this->flattenTreeEntries($headId !== null ? $this->getCommitTree($headId) : null); - $trackedEntries = $this->trackedEntriesForPaths($index, $paths); + $trackedEntries = $this->trackedEntriesForPaths($index, array_values($paths)); $pathsToRemove = []; foreach ($paths as $path) { @@ -999,7 +999,7 @@ public function removeCached(string ...$paths): void $index = $this->index(); $headId = $this->refs->resolveHead(); $headEntries = $this->flattenTreeEntries($headId !== null ? $this->getCommitTree($headId) : null); - $trackedEntries = $this->trackedEntriesForPaths($index, $paths); + $trackedEntries = $this->trackedEntriesForPaths($index, array_values($paths)); $pathsToRemove = []; foreach ($paths as $path) { @@ -1149,7 +1149,12 @@ public function cherryPick(string $revision): ObjectId $id = $this->resolve($revision); $commit = $this->objects->read($id); $headId = $this->refs->resolveHead(); - $headCommit = $headId !== null ? $this->objects->read($headId) : null; + + if ($headId === null) { + throw new \RuntimeException('Cannot cherry-pick: HEAD is not set'); + } + + $headCommit = $this->objects->read($headId); if (!$commit instanceof Commit || !$headCommit instanceof Commit) { throw new \RuntimeException("Not a commit: {$revision}"); @@ -1227,7 +1232,12 @@ public function revert(string $revision): ObjectId $id = $this->resolve($revision); $commit = $this->objects->read($id); $headId = $this->refs->resolveHead(); - $headCommit = $headId !== null ? $this->objects->read($headId) : null; + + if ($headId === null) { + throw new \RuntimeException('Cannot revert: HEAD is not set'); + } + + $headCommit = $this->objects->read($headId); if (!$commit instanceof Commit || !$headCommit instanceof Commit) { throw new \RuntimeException("Not a commit: {$revision}"); @@ -1238,6 +1248,11 @@ public function revert(string $revision): ObjectId } $parentTree = $this->getCommitTree($commit->parents[0]); + + if ($parentTree === null) { + throw new \RuntimeException("Cannot revert: parent tree of {$commit->parents[0]->hex} not found"); + } + $message = "Revert \"{$this->subjectLine($commit->message)}\"\n\nThis reverts commit {$commit->id->hex}.\n"; $trackedPaths = $this->index()->paths(); $merge = $this->mergeTreeEntries( @@ -2535,69 +2550,62 @@ private function mergeBaseFinder(): MergeBase */ private function buildTreeFromIndex(Index $index): ObjectId { - // Group entries by directory - $root = []; + $entries = []; foreach ($index->entries() as $entry) { - $parts = explode('/', $entry->path); - $this->insertIntoTree($root, $parts, $entry); + $entries[$entry->path] = $entry; } - return $this->writeTreeRecursive($root); + return $this->writeTreeFromIndexEntries($entries); } /** - * @param array $node - * @param array $parts + * @param array $entries Path -> IndexEntry */ - private function insertIntoTree(array &$node, array $parts, IndexEntry $entry): void + private function writeTreeFromIndexEntries(array $entries): ObjectId { - if (count($parts) === 1) { - $node[$parts[0]] = $entry; + $direct = []; + $subDirs = []; - return; - } + foreach ($entries as $path => $entry) { + $slashPos = strpos($path, '/'); - $dir = array_shift($parts); + if ($slashPos === false) { + $direct[$path] = $entry; + continue; + } - if (!isset($node[$dir]) || !is_array($node[$dir])) { - $node[$dir] = []; + $dirName = substr($path, 0, $slashPos); + $rest = substr($path, $slashPos + 1); + $subDirs[$dirName] ??= []; + $subDirs[$dirName][$rest] = $entry; } - $this->insertIntoTree($node[$dir], $parts, $entry); - } + $treeEntries = []; - /** - * @param array $node - */ - private function writeTreeRecursive(array $node): ObjectId - { - $entries = []; + foreach ($direct as $name => $entry) { + $mode = match ($entry->mode) { + 0100755 => '100755', + 0120000 => '120000', + 0160000 => '160000', + default => '100644', + }; + $treeEntries[] = new TreeEntry($mode, $name, $entry->hash); + } - foreach ($node as $name => $value) { - if ($value instanceof IndexEntry) { - $mode = match ($value->mode) { - 0100755 => '100755', - 0120000 => '120000', - 0160000 => '160000', - default => '100644', - }; - $entries[] = new TreeEntry($mode, (string) $name, $value->hash); - } elseif (is_array($value)) { - $subtreeId = $this->writeTreeRecursive($value); - $entries[] = new TreeEntry('40000', (string) $name, $subtreeId); - } + foreach ($subDirs as $dirName => $subEntries) { + $subtreeId = $this->writeTreeFromIndexEntries($subEntries); + $treeEntries[] = new TreeEntry('40000', $dirName, $subtreeId); } - // Sort entries (git sorts trees with trailing / for directories) - usort($entries, function (TreeEntry $a, TreeEntry $b): int { + usort($treeEntries, function (TreeEntry $a, TreeEntry $b): int { $nameA = $a->isTree() ? $a->name . '/' : $a->name; $nameB = $b->isTree() ? $b->name . '/' : $b->name; return strcmp($nameA, $nameB); }); - $tree = Tree::fromEntries($entries, $this->objectHashAlgo()); + $tree = Tree::fromEntries($treeEntries, $this->objectHashAlgo()); $this->objects->write($tree); return $tree->id; @@ -2710,8 +2718,8 @@ private function flattenTreeEntriesInto(?ObjectId $treeId, string $prefix, array /** * Reset worktree and index to match a commit. - */ - /** + * + * @param array $pathsToPrune * @param array $preserveRefs */ private function resetWorktree(ObjectId $commitId, array $pathsToPrune = [], array $preserveRefs = []): void @@ -2868,6 +2876,8 @@ private function materializedTreeEntries(array $treeEntries, string $targetDir): /** * @param array{hash: string, mode: int} $treeEntry + * + * @phpstan-assert-if-true !null $entry */ private function canReuseResetEntry(?IndexEntry $entry, array $treeEntry, int $extendedFlags, ?int $scanTimeSec): bool { @@ -2976,18 +2986,18 @@ private function worktreeDiffersFromIndex(IndexEntry $entry, ?int $scanTimeSec = } /** - * @param array $stat + * @param array{ctime: int, mtime: int, dev: int, ino: int, uid: int, gid: int, size: int} $stat */ private function statMatchesIndexEntry(IndexEntry $entry, array $stat, int $mode): bool { - return $entry->ctimeSec === (int) $stat['ctime'] - && $entry->mtimeSec === (int) $stat['mtime'] - && $entry->dev === (int) $stat['dev'] - && $entry->ino === (int) $stat['ino'] + return $entry->ctimeSec === $stat['ctime'] + && $entry->mtimeSec === $stat['mtime'] + && $entry->dev === $stat['dev'] + && $entry->ino === $stat['ino'] && $entry->mode === $mode - && $entry->uid === (int) $stat['uid'] - && $entry->gid === (int) $stat['gid'] - && $entry->fileSize === (int) $stat['size']; + && $entry->uid === $stat['uid'] + && $entry->gid === $stat['gid'] + && $entry->fileSize === $stat['size']; } /** @@ -3373,6 +3383,9 @@ private function worktreeMode(string $path): ?int return is_executable($fullPath) ? 0100755 : 0100644; } + /** + * @param array $entries + */ private function buildTreeFromEntries(array $entries): ObjectId { $index = new Index($this->objectHashBytes()); @@ -3441,7 +3454,13 @@ private function buildVirtualMergeBaseTree(MergeBase $mergeBaseFinder, array $ba } if ($baseIds === []) { - return $this->getCommitTree($current); + $tree = $this->getCommitTree($current); + + if ($tree === null) { + throw new \RuntimeException("Cannot resolve commit tree for {$current->hex}"); + } + + return $tree; } $next = array_shift($baseIds); @@ -3480,7 +3499,13 @@ private function mergeBaseTreesRecursively( ObjectId $theirsId, ): ObjectId { if ($oursId->equals($theirsId)) { - return $this->getCommitTree($oursId); + $tree = $this->getCommitTree($oursId); + + if ($tree === null) { + throw new \RuntimeException("Cannot resolve commit tree for {$oursId->hex}"); + } + + return $tree; } $nestedBaseIds = $mergeBaseFinder->findAll($oursId, $theirsId); @@ -3528,7 +3553,12 @@ private function materializeMergeEntriesWithConflicts(array $merge): array $blob = Blob::fromContent($content, $this->objectHashAlgo()); $this->objects->write($blob); $stages = $merge['conflictEntries'][$path] ?? []; - $mode = $stages[2]['mode'] ?? $stages[3]['mode'] ?? $stages[1]['mode'] ?? 0100644; + $mode = match (true) { + isset($stages[2]) => $stages[2]['mode'], + isset($stages[3]) => $stages[3]['mode'], + isset($stages[1]) => $stages[1]['mode'], + default => 0100644, + }; $entries[$path] = [ 'hash' => $blob->id->hex, 'mode' => $mode, @@ -3551,7 +3581,7 @@ private function buildOctopusMergeMessage(array $branches): string } /** - * @return array{parents: array, author?: string, message?: string, type?: string}|null + * @return array{parents: array, author?: string|null, message?: string|null, type?: string}|null */ private function pendingOperationState(?ObjectId $headId): ?array { @@ -3609,7 +3639,7 @@ private function pendingOperationState(?ObjectId $headId): ?array } /** - * @param array{message?: string}|null $state + * @param array{message?: string|null}|null $state */ private function resolveCommitMessage(?string $message, ?array $state): string { @@ -3761,9 +3791,13 @@ private function mergeTreeEntries( if ($isRenameDestination && $base !== null && ($ours === null || $theirs === null)) { $conflictPaths[] = $path; $conflictEntries[$path] = $this->conflictStageEntries($base, $ours, $theirs); - $conflictContents[$path] = $ours !== null - ? $this->readBlobContent(ObjectId::fromHex($ours['hash'])) - : $this->readBlobContent(ObjectId::fromHex($theirs['hash'])); + $survivor = $ours ?? $theirs; + + if ($survivor === null) { + continue; + } + + $conflictContents[$path] = $this->readBlobContent(ObjectId::fromHex($survivor['hash'])); continue; } @@ -3794,15 +3828,19 @@ private function mergeTreeEntries( if ($ours === null || $theirs === null) { $conflictPaths[] = $path; $conflictEntries[$path] = $this->conflictStageEntries($base, $ours, $theirs); - $conflictContents[$path] = $ours !== null - ? $this->readBlobContent(ObjectId::fromHex($ours['hash'])) - : $this->readBlobContent(ObjectId::fromHex($theirs['hash'])); + $survivor = $ours ?? $theirs; + + if ($survivor === null) { + continue; + } + + $conflictContents[$path] = $this->readBlobContent(ObjectId::fromHex($survivor['hash'])); continue; } $baseContent = $baseHash !== null ? $this->readBlobContent(ObjectId::fromHex($baseHash)) : ''; - $oursContent = $this->readBlobContent(ObjectId::fromHex($oursHash)); - $theirsContent = $this->readBlobContent(ObjectId::fromHex($theirsHash)); + $oursContent = $this->readBlobContent(ObjectId::fromHex($ours['hash'])); + $theirsContent = $this->readBlobContent(ObjectId::fromHex($theirs['hash'])); if ( MyersDiff::isBinary($baseContent) @@ -4252,7 +4290,12 @@ private function continueRebaseSequence(): array while ($state['current'] < count($state['commits'])) { $headId = $this->refs->resolveHead(); - $headCommit = $headId !== null ? $this->objects->read($headId) : null; + + if ($headId === null) { + throw new \RuntimeException('Cannot continue rebase: HEAD is not set'); + } + + $headCommit = $this->objects->read($headId); $replayId = $state['commits'][$state['current']]; $commit = $this->objects->read($replayId); @@ -4413,8 +4456,13 @@ private function readRebaseState(): ?array } $commits = []; + $rawCommits = $state['commits'] ?? []; - foreach (($state['commits'] ?? []) as $hex) { + if (!is_array($rawCommits)) { + return null; + } + + foreach ($rawCommits as $hex) { if (!is_string($hex)) { return null; } @@ -4422,15 +4470,20 @@ private function readRebaseState(): ?array $commits[] = ObjectId::fromHex($hex); } - if (!is_string($state['headName'] ?? null) || !is_string($state['origHead'] ?? null) || !is_string($state['onto'] ?? null)) { + $headName = $state['headName'] ?? null; + $origHead = $state['origHead'] ?? null; + $onto = $state['onto'] ?? null; + $current = $state['current'] ?? 0; + + if (!is_string($headName) || !is_string($origHead) || !is_string($onto)) { return null; } return [ - 'headName' => $state['headName'], - 'origHead' => ObjectId::fromHex($state['origHead']), - 'onto' => ObjectId::fromHex($state['onto']), - 'current' => (int) ($state['current'] ?? 0), + 'headName' => $headName, + 'origHead' => ObjectId::fromHex($origHead), + 'onto' => ObjectId::fromHex($onto), + 'current' => is_int($current) ? $current : 0, 'commits' => $commits, ]; } @@ -4497,6 +4550,10 @@ private function clearRebaseState(): void ); foreach ($iterator as $path) { + if (!$path instanceof \SplFileInfo) { + continue; + } + if ($path->isDir()) { rmdir($path->getPathname()); continue; @@ -5128,8 +5185,14 @@ private function identityName(string $role): string } foreach ($this->identityNameConstants($role) as $constant) { - if (defined($constant) && trim((string) constant($constant)) !== '') { - return trim((string) constant($constant)); + if (!defined($constant)) { + continue; + } + + $value = constant($constant); + + if (is_string($value) && trim($value) !== '') { + return trim($value); } } @@ -5147,8 +5210,14 @@ private function identityEmail(string $role): string } foreach ($this->identityEmailConstants($role) as $constant) { - if (defined($constant) && trim((string) constant($constant)) !== '') { - return trim((string) constant($constant)); + if (!defined($constant)) { + continue; + } + + $value = constant($constant); + + if (is_string($value) && trim($value) !== '') { + return trim($value); } } @@ -5206,7 +5275,7 @@ private function identityEmailConstants(string $role): array } /** - * @param array{message?: string, type?: string}|null $state + * @param array{message?: string|null, type?: string}|null $state */ private function prepareCommitMessage(string $message, ?array $state): string { diff --git a/src/Stash/Stash.php b/src/Stash/Stash.php index 8ac3a108..6e51043e 100644 --- a/src/Stash/Stash.php +++ b/src/Stash/Stash.php @@ -62,7 +62,8 @@ public function push(string $message = '', bool $includeUntracked = false): Obje } $branch = $this->currentBranch(); - $headSummary = substr($headId->hex, 0, 7) . ' ' . trim(strtok($headCommit->message, "\n")); + $firstLine = strtok($headCommit->message, "\n"); + $headSummary = substr($headId->hex, 0, 7) . ' ' . trim($firstLine === false ? '' : $firstLine); $stashMessage = $message !== '' ? "On {$branch}: {$message}" : "WIP on {$branch}: {$headSummary}"; @@ -270,14 +271,13 @@ private function normalizeDate(string $date): string private function buildTreeFromIndex(Index $index): ObjectId { - $root = []; + $entries = []; foreach ($index->entries() as $entry) { - $parts = explode('/', $entry->path); - $this->insertIntoTreeNode($root, $parts, $entry); + $entries[$entry->path] = $entry; } - return $this->writeTreeNode($root); + return $this->writeTreeFromPaths($entries); } /** @@ -291,7 +291,7 @@ private function buildTreeFromWorktree( array &$dirtyPaths = [], array &$includedUntrackedPaths = [], ): ObjectId { - $root = []; + $entries = []; $modified = []; $deleted = []; $untracked = []; @@ -331,8 +331,7 @@ private function buildTreeFromWorktree( } if (!isset($modified[$entry->path])) { - $parts = explode('/', $entry->path); - $this->insertIntoTreeNode($root, $parts, $entry); + $entries[$entry->path] = $entry; continue; } @@ -343,8 +342,7 @@ private function buildTreeFromWorktree( && $pathStatus->worktree === FileStatus::Unmodified && $pathStatus->index !== FileStatus::Deleted ) { - $parts = explode('/', $entry->path); - $this->insertIntoTreeNode($root, $parts, $entry); + $entries[$entry->path] = $entry; continue; } @@ -357,8 +355,7 @@ private function buildTreeFromWorktree( $content = file_get_contents($fullPath); $blob = Blob::fromContent($content !== false ? $content : '', $this->hashAlgo()); $this->objects->write($blob); - $parts = explode('/', $entry->path); - $this->insertIntoTreeNode($root, $parts, IndexEntry::create($entry->path, $blob->id, $entry->mode)); + $entries[$entry->path] = IndexEntry::create($entry->path, $blob->id, $entry->mode); } if ($includeUntracked) { @@ -372,52 +369,53 @@ private function buildTreeFromWorktree( $content = file_get_contents($fullPath); $blob = Blob::fromContent($content !== false ? $content : '', $this->hashAlgo()); $this->objects->write($blob); - $worktreeEntry = IndexEntry::fromStat($path, $blob->id, $fullPath); - $parts = explode('/', $path); - $this->insertIntoTreeNode($root, $parts, $worktreeEntry); + $entries[$path] = IndexEntry::fromStat($path, $blob->id, $fullPath); } } - return $this->writeTreeNode($root); + return $this->writeTreeFromPaths($entries); } - private function insertIntoTreeNode(array &$node, array $parts, IndexEntry $entry): void + /** + * @param array $entries Path -> IndexEntry + */ + private function writeTreeFromPaths(array $entries): ObjectId { - if (count($parts) === 1) { - $node[$parts[0]] = $entry; + $direct = []; + $subDirs = []; - return; - } + foreach ($entries as $path => $entry) { + $slashPos = strpos($path, '/'); - $dir = array_shift($parts); + if ($slashPos === false) { + $direct[$path] = $entry; + continue; + } - if (!isset($node[$dir]) || !is_array($node[$dir])) { - $node[$dir] = []; + $dirName = substr($path, 0, $slashPos); + $rest = substr($path, $slashPos + 1); + $subDirs[$dirName] ??= []; + $subDirs[$dirName][$rest] = $entry; } - $this->insertIntoTreeNode($node[$dir], $parts, $entry); - } + $treeEntries = []; - private function writeTreeNode(array $node): ObjectId - { - $entries = []; + foreach ($direct as $name => $entry) { + $mode = $entry->mode === 0100755 ? '100755' : '100644'; + $treeEntries[] = new TreeEntry($mode, $name, $entry->hash); + } - foreach ($node as $name => $value) { - if ($value instanceof IndexEntry) { - $mode = $value->mode === 0100755 ? '100755' : '100644'; - $entries[] = new TreeEntry($mode, (string) $name, $value->hash); - } elseif (is_array($value)) { - $subtreeId = $this->writeTreeNode($value); - $entries[] = new TreeEntry('40000', (string) $name, $subtreeId); - } + foreach ($subDirs as $dirName => $subEntries) { + $subtreeId = $this->writeTreeFromPaths($subEntries); + $treeEntries[] = new TreeEntry('40000', $dirName, $subtreeId); } - usort($entries, fn (TreeEntry $a, TreeEntry $b) => strcmp( + usort($treeEntries, fn (TreeEntry $a, TreeEntry $b) => strcmp( $a->isTree() ? $a->name . '/' : $a->name, $b->isTree() ? $b->name . '/' : $b->name, )); - $tree = Tree::fromEntries($entries, $this->hashAlgo()); + $tree = Tree::fromEntries($treeEntries, $this->hashAlgo()); $this->objects->write($tree); return $tree->id; diff --git a/src/Status/WorkingTreeStatus.php b/src/Status/WorkingTreeStatus.php index c655c3a8..4ee09688 100644 --- a/src/Status/WorkingTreeStatus.php +++ b/src/Status/WorkingTreeStatus.php @@ -177,18 +177,18 @@ private function worktreeFileChanged(IndexEntry $entry, string $path): bool } /** - * @param array $stat + * @param array{ctime: int, mtime: int, dev: int, ino: int, uid: int, gid: int, size: int} $stat */ private function statMatchesIndexEntry(IndexEntry $entry, array $stat, int $mode): bool { - return $entry->ctimeSec === (int) $stat['ctime'] - && $entry->mtimeSec === (int) $stat['mtime'] - && $entry->dev === (int) $stat['dev'] - && $entry->ino === (int) $stat['ino'] + return $entry->ctimeSec === $stat['ctime'] + && $entry->mtimeSec === $stat['mtime'] + && $entry->dev === $stat['dev'] + && $entry->ino === $stat['ino'] && $entry->mode === $mode - && $entry->uid === (int) $stat['uid'] - && $entry->gid === (int) $stat['gid'] - && $entry->fileSize === (int) $stat['size']; + && $entry->uid === $stat['uid'] + && $entry->gid === $stat['gid'] + && $entry->fileSize === $stat['size']; } /** diff --git a/src/Storage/ObjectSerializer.php b/src/Storage/ObjectSerializer.php index 98141343..1a18b480 100644 --- a/src/Storage/ObjectSerializer.php +++ b/src/Storage/ObjectSerializer.php @@ -28,8 +28,13 @@ final class ObjectSerializer public static function encode(GitObject $object): string { $raw = self::encodeRaw($object); + $compressed = zlib_encode($raw, ZLIB_ENCODING_DEFLATE); - return zlib_encode($raw, ZLIB_ENCODING_DEFLATE); + if ($compressed === false) { + throw new \RuntimeException("Failed to zlib-encode object {$object->id->hex}"); + } + + return $compressed; } /** diff --git a/src/Support/Json.php b/src/Support/Json.php index 29e06b8a..36fd6f67 100644 --- a/src/Support/Json.php +++ b/src/Support/Json.php @@ -8,6 +8,9 @@ final class Json { + /** + * @return array + */ public static function decodeFile(string $path): array { $content = @file_get_contents($path); @@ -25,6 +28,9 @@ public static function decodeFile(string $path): array return $decoded; } + /** + * @param array $payload + */ public static function encodeFile(string $path, array $payload): void { $directory = dirname($path); diff --git a/src/Support/PathFingerprint.php b/src/Support/PathFingerprint.php index aa9fbc21..e6fc1635 100644 --- a/src/Support/PathFingerprint.php +++ b/src/Support/PathFingerprint.php @@ -62,7 +62,7 @@ private static function updateHashForPath($hash, string $path): void ); foreach ($iterator as $fileInfo) { - if (!$fileInfo->isFile()) { + if (!$fileInfo instanceof \SplFileInfo || !$fileInfo->isFile()) { continue; }