| | |
| | class Git { |
| | - private string $path; |
| | - private string $objPath; |
| | - |
| | - public function __construct(string $repoPath) { |
| | - $this->path = rtrim($repoPath, '/'); |
| | - $this->objPath = $this->path . '/objects'; |
| | - } |
| | - |
| | - public function getObjectSize(string $sha): int { |
| | - $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); |
| | - if (file_exists($loose)) { |
| | - $f = @fopen($loose, 'rb'); |
| | - if (!$f) return 0; |
| | - $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); |
| | - $data = ''; |
| | - while (!feof($f)) { |
| | - $chunk = fread($f, 128); |
| | - $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); |
| | - if ($inflated === false) break; |
| | - $data .= $inflated; |
| | - if (strpos($data, "\0") !== false) break; |
| | - } |
| | - fclose($f); |
| | - $header = explode("\0", $data, 2)[0]; |
| | - $parts = explode(' ', $header); |
| | - return isset($parts[1]) ? (int)$parts[1] : 0; |
| | - } |
| | - return $this->getPackedObjectSize($sha); |
| | - } |
| | - |
| | -private function getPackedObjectSize(string $sha): int { |
| | - $info = $this->getPackOffset($sha); |
| | - if (!$info) return 0; |
| | - |
| | - $pf = @fopen($info['file'], 'rb'); |
| | - if (!$pf) return 0; |
| | - |
| | - fseek($pf, $info['offset']); |
| | - $byte = ord(fread($pf, 1)); |
| | - $type = ($byte >> 4) & 7; |
| | - $size = $byte & 15; |
| | - $shift = 4; |
| | - |
| | - while ($byte & 128) { |
| | - $byte = ord(fread($pf, 1)); |
| | - $size |= (($byte & 127) << $shift); |
| | - $shift += 7; |
| | - } |
| | - |
| | - // If the object is not a delta, the size we just read is the final size. |
| | - if ($type !== 6 && $type !== 7) { |
| | - fclose($pf); |
| | - return $size; |
| | - } |
| | - |
| | - // For Deltas (Type 6 or 7), the 'size' above is the size of the delta data, |
| | - // not the resulting file. We must find the "Target Size" inside the delta header. |
| | - if ($type === 6) { // OFS_DELTA: skip the negative offset |
| | - $byte = ord(fread($pf, 1)); |
| | - while ($byte & 128) { $byte = ord(fread($pf, 1)); } |
| | - } else { // REF_DELTA: skip the 20-byte base SHA |
| | - fread($pf, 20); |
| | - } |
| | - |
| | - // Inflate only the beginning of the delta data to get the header |
| | - $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); |
| | - $headerData = ''; |
| | - while (!feof($pf)) { |
| | - $chunk = fread($pf, 512); |
| | - $inflated = @inflate_add($ctx, $chunk, ZLIB_NO_FLUSH); |
| | - if ($inflated !== false) $headerData .= $inflated; |
| | - if (strlen($headerData) >= 32) break; // We usually only need ~10 bytes |
| | - } |
| | - fclose($pf); |
| | - |
| | - if (strlen($headerData) === 0) return 0; |
| | - |
| | - $pos = 0; |
| | - // 1. Skip Source Size (Base Object Size) |
| | - $byte = ord($headerData[$pos++]); |
| | - while ($byte & 128 && $pos < strlen($headerData)) { |
| | - $byte = ord($headerData[$pos++]); |
| | - } |
| | - |
| | - // 2. Read Target Size (The actual file size we want to display) |
| | - if ($pos >= strlen($headerData)) return 0; |
| | - $byte = ord($headerData[$pos++]); |
| | - $targetSize = $byte & 127; |
| | - $shift = 7; |
| | - while ($byte & 128 && $pos < strlen($headerData)) { |
| | - $byte = ord($headerData[$pos++]); |
| | - $targetSize |= (($byte & 127) << $shift); |
| | - $shift += 7; |
| | - } |
| | - |
| | - return $targetSize; |
| | - } |
| | - |
| | - public function getMainBranch(): ?array { |
| | - $branches = []; |
| | - $this->eachBranch(function($name, $sha) use (&$branches) { $branches[$name] = $sha; }); |
| | - foreach (['main', 'master', 'trunk', 'develop'] as $b) { |
| | - if (isset($branches[$b])) return ['name' => $b, 'hash' => $branches[$b]]; |
| | - } |
| | - if (!empty($branches)) { |
| | - $f = array_key_first($branches); |
| | - return ['name' => $f, 'hash' => $branches[$f]]; |
| | - } |
| | - return null; |
| | - } |
| | - |
| | - public function eachBranch(callable $callback): void { $this->scanRefs('refs/heads', $callback); } |
| | - public function eachTag(callable $callback): void { $this->scanRefs('refs/tags', $callback); } |
| | - |
| | - public function walk(string $refOrSha, callable $callback): void { |
| | - $sha = $this->resolve($refOrSha); |
| | - if (!$sha) return; |
| | - $data = $this->read($sha); |
| | - if (!$data) return; |
| | - if (preg_match('/^tree ([0-9a-f]{40})$/m', $data, $m)) { |
| | - $data = $this->read($m[1]); |
| | - if (!$data) return; |
| | - } elseif (!$this->isTreeData($data)) return; |
| | - |
| | - $pos = 0; |
| | - while ($pos < strlen($data)) { |
| | - $space = strpos($data, ' ', $pos); |
| | - $null = strpos($data, "\0", $space); |
| | - if ($space === false || $null === false) break; |
| | - $mode = substr($data, $pos, $space - $pos); |
| | - $name = substr($data, $space + 1, $null - $space - 1); |
| | - $entrySha = bin2hex(substr($data, $null + 1, 20)); |
| | - |
| | - $isDir = ($mode === '40000' || $mode === '040000'); |
| | - $size = $isDir ? 0 : $this->getObjectSize($entrySha); |
| | - |
| | - $callback(new File($name, $entrySha, $mode, 0, $size)); |
| | - |
| | - $pos = $null + 21; |
| | - } |
| | - } |
| | - |
| | - private function isTreeData(string $data): bool { |
| | - if (strlen($data) < 25) return false; |
| | - if (preg_match('/^(40000|100644|100755|120000) /', $data)) { |
| | - $null = strpos($data, "\0"); |
| | - return ($null !== false && ($null + 21 <= strlen($data))); |
| | - } |
| | - return false; |
| | - } |
| | - |
| | - public function history(string $refOrSha, int $limit, callable $callback): void { |
| | - $currentSha = $this->resolve($refOrSha); |
| | - $count = 0; |
| | - while ($currentSha && $count < $limit) { |
| | - $data = $this->read($currentSha); |
| | - if (!$data) break; |
| | - $message = (strpos($data, "\n\n") !== false) ? substr($data, strpos($data, "\n\n") + 2) : ''; |
| | - preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $auth); |
| | - $callback((object)['sha' => $currentSha, 'message' => trim($message), 'author' => $auth[1] ?? 'Unknown', 'email' => $auth[2] ?? '', 'date' => (int)($auth[3] ?? 0)]); |
| | - $currentSha = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $m) ? $m[1] : null; |
| | - $count++; |
| | - } |
| | - } |
| | - |
| | - public function stream(string $sha, callable $callback): void { |
| | - $data = $this->read($sha); |
| | - if ($data) $callback($data); |
| | - } |
| | - |
| | - private function resolve(string $input): ?string { |
| | - if (preg_match('/^[0-9a-f]{40}$/', $input)) return $input; |
| | - if ($input === 'HEAD' && file_exists($h = "{$this->path}/HEAD")) { |
| | - $head = trim(file_get_contents($h)); |
| | - return (strpos($head, 'ref: ') === 0) ? $this->resolve(substr($head, 5)) : $head; |
| | - } |
| | - foreach ([$input, "refs/heads/$input", "refs/tags/$input"] as $p) { |
| | - if (file_exists($f = "{$this->path}/$p")) return trim(file_get_contents($f)); |
| | - } |
| | - if (file_exists($packed = "{$this->path}/packed-refs")) { |
| | - foreach (file($packed) as $line) { |
| | - if ($line[0] === '#' || $line[0] === '^') continue; |
| | - $parts = explode(' ', trim($line)); |
| | - if (count($parts) >= 2 && ($parts[1] === $input || $parts[1] === "refs/heads/$input" || $parts[1] === "refs/tags/$input")) return $parts[0]; |
| | - } |
| | - } |
| | - return null; |
| | - } |
| | - |
| | - private function read(string $sha): ?string { |
| | - $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); |
| | - if (file_exists($loose)) { |
| | - $inflated = @gzuncompress(file_get_contents($loose)); |
| | - return $inflated ? explode("\0", $inflated, 2)[1] : null; |
| | - } |
| | - return $this->fromPack($sha); |
| | - } |
| | - |
| | - private function scanRefs(string $prefix, callable $callback): void { |
| | - $dir = "{$this->path}/$prefix"; |
| | - if (is_dir($dir)) { |
| | - foreach (array_diff(scandir($dir), ['.', '..']) as $f) { |
| | - $callback($f, trim(file_get_contents("$dir/$f"))); |
| | - } |
| | - } |
| | - } |
| | - |
| | - private function fromPack(string $sha): ?string { |
| | - $info = $this->getPackOffset($sha); |
| | - if (!$info) return null; |
| | - $pf = @fopen($info['file'], 'rb'); |
| | - if (!$pf) return null; |
| | - $data = $this->readPackEntry($pf, $info['offset']); |
| | - fclose($pf); |
| | - return $data; |
| | - } |
| | - |
| | - private function readPackEntry($pf, int $offset): ?string { |
| | - fseek($pf, $offset); |
| | - $byte = ord(fread($pf, 1)); |
| | - $type = ($byte >> 4) & 7; |
| | - $size = $byte & 15; |
| | - $shift = 4; |
| | - while ($byte & 128) { |
| | - $byte = ord(fread($pf, 1)); |
| | - $size |= (($byte & 127) << $shift); |
| | - $shift += 7; |
| | - } |
| | - |
| | - // Type 6: OBJ_OFS_DELTA |
| | - if ($type === 6) { |
| | - $byte = ord(fread($pf, 1)); |
| | - $negOffset = $byte & 127; |
| | - while ($byte & 128) { |
| | - $byte = ord(fread($pf, 1)); |
| | - $negOffset = (($negOffset + 1) << 7) | ($byte & 127); |
| | - } |
| | - $baseOffset = $offset - $negOffset; |
| | - $base = $this->readPackEntry($pf, $baseOffset); |
| | - |
| | - fseek($pf, $offset); |
| | - $b = ord(fread($pf, 1)); |
| | - while ($b & 128) { $b = ord(fread($pf, 1)); } |
| | - $b = ord(fread($pf, 1)); |
| | - while ($b & 128) { $b = ord(fread($pf, 1)); } |
| | - |
| | - $delta = @gzuncompress(fread($pf, 16777216)); |
| | - return $this->applyDelta($base, $delta); |
| | - } |
| | - |
| | - // Type 7: OBJ_REF_DELTA |
| | - if ($type === 7) { |
| | - $baseSha = bin2hex(fread($pf, 20)); |
| | - $base = $this->read($baseSha); |
| | - $delta = @gzuncompress(fread($pf, 16777216)); |
| | - return $this->applyDelta($base, $delta); |
| | - } |
| | - |
| | - return @gzuncompress(fread($pf, 16777216)); |
| | - } |
| | - |
| | - private function applyDelta(?string $base, ?string $delta): string { |
| | - if (!$base || !$delta) return ''; |
| | - $pos = 0; |
| | - // Skip Source Size |
| | - $byte = ord($delta[$pos++]); |
| | - while ($byte & 128) { $byte = ord($delta[$pos++]); } |
| | - // Skip Target Size |
| | - $byte = ord($delta[$pos++]); |
| | - while ($byte & 128) { $byte = ord($delta[$pos++]); } |
| | - |
| | - $out = ''; |
| | - while ($pos < strlen($delta)) { |
| | - $opcode = ord($delta[$pos++]); |
| | - if ($opcode & 128) { // Copy |
| | - $off = 0; $len = 0; |
| | - if ($opcode & 1) $off |= ord($delta[$pos++]); |
| | - if ($opcode & 2) $off |= ord($delta[$pos++]) << 8; |
| | - if ($opcode & 4) $off |= ord($delta[$pos++]) << 16; |
| | - if ($opcode & 8) $off |= ord($delta[$pos++]) << 24; |
| | - if ($opcode & 16) $len |= ord($delta[$pos++]); |
| | - if ($opcode & 32) $len |= ord($delta[$pos++]) << 8; |
| | - if ($opcode & 64) $len |= ord($delta[$pos++]) << 16; |
| | - if ($len === 0) $len = 0x10000; |
| | - $out .= substr($base, $off, $len); |
| | - } else { // Insert |
| | - $len = $opcode & 127; |
| | - $out .= substr($delta, $pos, $len); |
| | - $pos += $len; |
| | - } |
| | - } |
| | - return $out; |
| | - } |
| | - |
| | - private function getPackOffset(string $sha): ?array { |
| | - $packs = glob("{$this->objPath}/pack/*.idx"); |
| | - if (!$packs) return null; |
| | - |
| | - $binSha = hex2bin($sha); |
| | - $firstByte = ord($binSha[0]); |
| | - |
| | - foreach ($packs as $idxFile) { |
| | - $f = @fopen($idxFile, 'rb'); |
| | - if (!$f) continue; |
| | - |
| | - $sig = fread($f, 4); |
| | - $ver = unpack('N', fread($f, 4))[1]; |
| | - if ($sig !== "\377tOc" || $ver !== 2) { fclose($f); continue; } |
| | - |
| | - $fanoutOffset = 8; |
| | - if ($firstByte > 0) { |
| | - fseek($f, $fanoutOffset + (($firstByte - 1) * 4)); |
| | - $start = unpack('N', fread($f, 4))[1]; |
| | - } else { |
| | - $start = 0; |
| | - } |
| | - fseek($f, $fanoutOffset + ($firstByte * 4)); |
| | - $end = unpack('N', fread($f, 4))[1]; |
| | - |
| | - if ($end <= $start) { fclose($f); continue; } |
| | - |
| | - fseek($f, $fanoutOffset + (255 * 4)); |
| | - $totalObjects = unpack('N', fread($f, 4))[1]; |
| | - |
| | - $shaTableOffset = 8 + 1024; |
| | - fseek($f, $shaTableOffset + ($start * 20)); |
| | - |
| | - $foundIdx = -1; |
| | - for ($i = $start; $i < $end; $i++) { |
| | - if (fread($f, 20) === $binSha) { $foundIdx = $i; break; } |
| | - } |
| | - |
| | - if ($foundIdx === -1) { fclose($f); continue; } |
| | - |
| | - $crcOffset = $shaTableOffset + ($totalObjects * 20); |
| | - $offsetTableOffset = $crcOffset + ($totalObjects * 4); |
| | - |
| | - fseek($f, $offsetTableOffset + ($foundIdx * 4)); |
| | - $offset32 = unpack('N', fread($f, 4))[1]; |
| | - |
| | - if ($offset32 & 0x80000000) { |
| | - $largeOffsetIdx = $offset32 & 0x7FFFFFFF; |
| | - $largeOffsetTablePos = $offsetTableOffset + ($totalObjects * 4); |
| | - fseek($f, $largeOffsetTablePos + ($largeOffsetIdx * 8)); |
| | - $data = unpack('J', fread($f, 8)); |
| | - $offset = $data[1]; |
| | - } else { |
| | - $offset = $offset32; |
| | - } |
| | - |
| | - fclose($f); |
| | - return ['file' => str_replace('.idx', '.pack', $idxFile), 'offset' => $offset]; |
| | - } |
| | - return null; |
| | + private const CHUNK_SIZE = 128; |
| | + private const MAX_READ = 16777216; |
| | + private const MODE_TREE = '40000'; |
| | + private const MODE_TREE_A = '040000'; |
| | + |
| | + private string $path; |
| | + private string $objPath; |
| | + |
| | + public function __construct( string $repoPath ) { |
| | + $this->path = rtrim( $repoPath, '/' ); |
| | + $this->objPath = $this->path . '/objects'; |
| | + } |
| | + |
| | + public function getObjectSize( string $sha ): int { |
| | + $prefix = substr( $sha, 0, 2 ); |
| | + $suffix = substr( $sha, 2 ); |
| | + $loosePath = "{$this->objPath}/{$prefix}/{$suffix}"; |
| | + |
| | + $size = file_exists( $loosePath ) |
| | + ? $this->getLooseObjectSize( $loosePath ) |
| | + : $this->getPackedObjectSize( $sha ); |
| | + |
| | + return $size; |
| | + } |
| | + |
| | + private function getLooseObjectSize( string $path ): int { |
| | + $size = 0; |
| | + $fileHandle = @fopen( $path, 'rb' ); |
| | + |
| | + if( $fileHandle ) { |
| | + $data = $this->decompressHeader( $fileHandle ); |
| | + $header = explode( "\0", $data, 2 )[0]; |
| | + $parts = explode( ' ', $header ); |
| | + $size = isset( $parts[1] ) ? (int)$parts[1] : 0; |
| | + fclose( $fileHandle ); |
| | + } |
| | + |
| | + return $size; |
| | + } |
| | + |
| | + private function decompressHeader( $fileHandle ): string { |
| | + $data = ''; |
| | + $inflateContext = inflate_init( ZLIB_ENCODING_DEFLATE ); |
| | + |
| | + while( !feof( $fileHandle ) ) { |
| | + $chunk = fread( $fileHandle, self::CHUNK_SIZE ); |
| | + $inflated = @inflate_add( $inflateContext, $chunk, ZLIB_NO_FLUSH ); |
| | + |
| | + if( $inflated === false ) { |
| | + break; |
| | + } |
| | + |
| | + $data .= $inflated; |
| | + |
| | + if( strpos( $data, "\0" ) !== false ) { |
| | + break; |
| | + } |
| | + } |
| | + |
| | + return $data; |
| | + } |
| | + |
| | + private function getPackedObjectSize( string $sha ): int { |
| | + $info = $this->getPackOffset( $sha ); |
| | + |
| | + $size = ($info['offset'] !== -1) |
| | + ? $this->extractPackedSize( $info ) |
| | + : 0; |
| | + |
| | + return $size; |
| | + } |
| | + |
| | + private function extractPackedSize( array $info ): int { |
| | + $targetSize = 0; |
| | + $packFile = @fopen( $info['file'], 'rb' ); |
| | + |
| | + if( $packFile ) { |
| | + fseek( $packFile, $info['offset'] ); |
| | + $header = $this->readVarInt( $packFile ); |
| | + $type = ($header['byte'] >> 4) & 7; |
| | + |
| | + $targetSize = ($type === 6 || $type === 7) |
| | + ? $this->readDeltaTargetSize( $packFile, $type ) |
| | + : $header['value']; |
| | + |
| | + fclose( $packFile ); |
| | + } |
| | + |
| | + return $targetSize; |
| | + } |
| | + |
| | + private function readVarInt( $fileHandle ): array { |
| | + $byte = ord( fread( $fileHandle, 1 ) ); |
| | + $value = $byte & 15; |
| | + $shift = 4; |
| | + $firstByte = $byte; |
| | + |
| | + while( $byte & 128 ) { |
| | + $byte = ord( fread( $fileHandle, 1 ) ); |
| | + $value |= (($byte & 127) << $shift); |
| | + $shift += 7; |
| | + } |
| | + |
| | + return ['value' => $value, 'byte' => $firstByte]; |
| | + } |
| | + |
| | + private function readDeltaTargetSize( $fileHandle, int $type ): int { |
| | + $dummy = ($type === 6) |
| | + ? $this->skipOffsetDelta( $fileHandle ) |
| | + : fread( $fileHandle, 20 ); |
| | + |
| | + $inflateContext = inflate_init( ZLIB_ENCODING_DEFLATE ); |
| | + $headerData = ''; |
| | + |
| | + while( !feof( $fileHandle ) && strlen( $headerData ) < 32 ) { |
| | + $inflated = @inflate_add( |
| | + $inflateContext, |
| | + fread( $fileHandle, 512 ), |
| | + ZLIB_NO_FLUSH |
| | + ); |
| | + |
| | + if( $inflated !== false ) { |
| | + $headerData .= $inflated; |
| | + } |
| | + } |
| | + |
| | + $result = 0; |
| | + $position = 0; |
| | + |
| | + if( strlen( $headerData ) > 0 ) { |
| | + $this->skipSize( $headerData, $position ); |
| | + $result = $this->readSize( $headerData, $position ); |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + public function getMainBranch(): array { |
| | + $result = ['name' => '', 'hash' => '']; |
| | + $branches = []; |
| | + $this->eachBranch( function( $name, $sha ) use( &$branches ) { |
| | + $branches[$name] = $sha; |
| | + } ); |
| | + |
| | + foreach( ['main', 'master', 'trunk', 'develop'] as $branch ) { |
| | + if( isset( $branches[$branch] ) ) { |
| | + $result = ['name' => $branch, 'hash' => $branches[$branch]]; |
| | + break; |
| | + } |
| | + } |
| | + |
| | + if( $result['name'] === '' ) { |
| | + $firstKey = array_key_first( $branches ); |
| | + |
| | + if( $firstKey !== null ) { |
| | + $result = ['name' => $firstKey, 'hash' => $branches[$firstKey]]; |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + public function eachBranch( callable $callback ): void { |
| | + $this->scanRefs( 'refs/heads', $callback ); |
| | + } |
| | + |
| | + public function eachTag( callable $callback ): void { |
| | + $this->scanRefs( 'refs/tags', $callback ); |
| | + } |
| | + |
| | + public function walk( string $refOrSha, callable $callback ): void { |
| | + $sha = $this->resolve( $refOrSha ); |
| | + $data = ($sha !== '') ? $this->read( $sha ) : ''; |
| | + |
| | + if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) { |
| | + $data = $this->read( $matches[1] ); |
| | + } |
| | + |
| | + if( $this->isTreeData( $data ) ) { |
| | + $this->processTree( $data, $callback ); |
| | + } |
| | + } |
| | + |
| | + private function processTree( string $data, callable $callback ): void { |
| | + $position = 0; |
| | + |
| | + while( $position < strlen( $data ) ) { |
| | + $spacePos = strpos( $data, ' ', $position ); |
| | + $nullPos = strpos( $data, "\0", $spacePos ); |
| | + |
| | + if( $spacePos === false || $nullPos === false ) { |
| | + break; |
| | + } |
| | + |
| | + $mode = substr( $data, $position, $spacePos - $position ); |
| | + $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 ); |
| | + $entrySha = bin2hex( substr( $data, $nullPos + 1, 20 ) ); |
| | + |
| | + $isDir = ($mode === self::MODE_TREE || $mode === self::MODE_TREE_A); |
| | + $size = $isDir ? 0 : $this->getObjectSize( $entrySha ); |
| | + |
| | + $callback( new File( $name, $entrySha, $mode, 0, $size ) ); |
| | + $position = $nullPos + 21; |
| | + } |
| | + } |
| | + |
| | + private function isTreeData( string $data ): bool { |
| | + $result = false; |
| | + $pattern = '/^(40000|100644|100755|120000) /'; |
| | + |
| | + if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) { |
| | + $nullPos = strpos( $data, "\0" ); |
| | + $result = ($nullPos !== false && ($nullPos + 21 <= strlen( $data ))); |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + public function history( string $ref, int $limit, callable $cb ): void { |
| | + $currentSha = $this->resolve( $ref ); |
| | + $count = 0; |
| | + |
| | + while( $currentSha !== '' && $count < $limit ) { |
| | + $data = $this->read( $currentSha ); |
| | + |
| | + if( $data === '' ) { |
| | + break; |
| | + } |
| | + |
| | + $pos = strpos( $data, "\n\n" ); |
| | + $message = ($pos !== false) ? substr( $data, $pos + 2 ) : ''; |
| | + preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $m ); |
| | + |
| | + $cb( (object)[ |
| | + 'sha' => $currentSha, |
| | + 'message' => trim( $message ), |
| | + 'author' => $m[1] ?? 'Unknown', |
| | + 'email' => $m[2] ?? '', |
| | + 'date' => (int)($m[3] ?? 0) |
| | + ] ); |
| | + |
| | + $currentSha = preg_match( '/^parent ([0-9a-f]{40})$/m', $data, $ms ) |
| | + ? $ms[1] : ''; |
| | + $count++; |
| | + } |
| | + } |
| | + |
| | + public function stream( string $sha, callable $callback ): void { |
| | + $data = $this->read( $sha ); |
| | + |
| | + if( $data !== '' ) { |
| | + $callback( $data ); |
| | + } |
| | + } |
| | + |
| | + public function resolve( string $input ): string { |
| | + $result = ''; |
| | + |
| | + if( preg_match( '/^[0-9a-f]{40}$/', $input ) ) { |
| | + $result = $input; |
| | + } elseif( $input === 'HEAD' && |
| | + file_exists( $headFile = "{$this->path}/HEAD" ) ) { |
| | + $head = trim( file_get_contents( $headFile ) ); |
| | + $result = (strpos( $head, 'ref: ' ) === 0) |
| | + ? $this->resolve( substr( $head, 5 ) ) : $head; |
| | + } else { |
| | + $result = $this->resolveRef( $input ); |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function resolveRef( string $input ): string { |
| | + $found = ''; |
| | + $refPaths = [$input, "refs/heads/$input", "refs/tags/$input"]; |
| | + |
| | + foreach( $refPaths as $path ) { |
| | + if( file_exists( $filePath = "{$this->path}/$path" ) ) { |
| | + $found = trim( file_get_contents( $filePath ) ); |
| | + break; |
| | + } |
| | + } |
| | + |
| | + if( $found === '' && |
| | + file_exists( $packed = "{$this->path}/packed-refs" ) ) { |
| | + $found = $this->findInPackedRefs( $packed, $input ); |
| | + } |
| | + |
| | + return $found; |
| | + } |
| | + |
| | + private function findInPackedRefs( string $path, string $input ): string { |
| | + $result = ''; |
| | + $targets = [$input, "refs/heads/$input", "refs/tags/$input"]; |
| | + |
| | + foreach( file( $path ) as $line ) { |
| | + if( $line[0] === '#' || $line[0] === '^' ) { |
| | + continue; |
| | + } |
| | + |
| | + $parts = explode( ' ', trim( $line ) ); |
| | + |
| | + if( count( $parts ) >= 2 && in_array( $parts[1], $targets ) ) { |
| | + $result = $parts[0]; |
| | + break; |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + public function read( string $sha ): string { |
| | + $result = ''; |
| | + $prefix = substr( $sha, 0, 2 ); |
| | + $suffix = substr( $sha, 2 ); |
| | + $loose = "{$this->objPath}/{$prefix}/{$suffix}"; |
| | + |
| | + if( file_exists( $loose ) ) { |
| | + $raw = file_get_contents( $loose ); |
| | + $inflated = $raw ? @gzuncompress( $raw ) : false; |
| | + $result = $inflated ? explode( "\0", $inflated, 2 )[1] : ''; |
| | + } else { |
| | + $result = $this->fromPack( $sha ); |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function fromPack( string $sha ): string { |
| | + $info = $this->getPackOffset( $sha ); |
| | + $result = ''; |
| | + |
| | + if( $info['offset'] !== -1 ) { |
| | + $packFile = @fopen( $info['file'], 'rb' ); |
| | + |
| | + if( $packFile ) { |
| | + $result = $this->readPackEntry( $packFile, $info['offset'] ); |
| | + fclose( $packFile ); |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function getPackOffset( string $sha ): array { |
| | + $result = ['file' => '', 'offset' => -1]; |
| | + |
| | + if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) { |
| | + $binSha = hex2bin( $sha ); |
| | + $packs = glob( "{$this->objPath}/pack/*.idx" ); |
| | + |
| | + foreach( (array)$packs as $idxFile ) { |
| | + $offset = $this->findInPack( $idxFile, $binSha ); |
| | + |
| | + if( $offset !== -1 ) { |
| | + $result = [ |
| | + 'file' => str_replace( '.idx', '.pack', $idxFile ), |
| | + 'offset' => $offset |
| | + ]; |
| | + break; |
| | + } |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function findInPack( string $idxFile, string $binSha ): int { |
| | + $offset = -1; |
| | + $fileHandle = @fopen( $idxFile, 'rb' ); |
| | + |
| | + if( $fileHandle ) { |
| | + $range = $this->getFanoutRange( $fileHandle, ord( $binSha[0] ) ); |
| | + |
| | + if( $range['end'] > $range['start'] ) { |
| | + $total = $this->getTotalObjects( $fileHandle ); |
| | + $foundIdx = $this->searchShaTable( |
| | + $fileHandle, |
| | + $range['start'], |
| | + $range['end'], |
| | + $binSha |
| | + ); |
| | + |
| | + if( $foundIdx !== -1 ) { |
| | + $offset = $this->getOffsetFromTable( |
| | + $fileHandle, |
| | + $foundIdx, |
| | + $total |
| | + ); |
| | + } |
| | + } |
| | + |
| | + fclose( $fileHandle ); |
| | + } |
| | + |
| | + return $offset; |
| | + } |
| | + |
| | + private function getFanoutRange( $fileHandle, int $firstByte ): array { |
| | + $range = ['start' => 0, 'end' => 0]; |
| | + fseek( $fileHandle, 0 ); |
| | + |
| | + if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) { |
| | + fseek( $fileHandle, 8 + ($firstByte * 4) ); |
| | + $range['end'] = unpack( 'N', fread( $fileHandle, 4 ) )[1]; |
| | + |
| | + if( $firstByte > 0 ) { |
| | + fseek( $fileHandle, 8 + (($firstByte - 1) * 4) ); |
| | + $range['start'] = unpack( 'N', fread( $fileHandle, 4 ) )[1]; |
| | + } |
| | + } |
| | + |
| | + return $range; |
| | + } |
| | + |
| | + private function getTotalObjects( $fileHandle ): int { |
| | + fseek( $fileHandle, 1032 ); |
| | + |
| | + return unpack( 'N', fread( $fileHandle, 4 ) )[1]; |
| | + } |
| | + |
| | + private function searchShaTable( |
| | + $fileHandle, |
| | + int $start, |
| | + int $end, |
| | + string $binSha |
| | + ): int { |
| | + $result = -1; |
| | + fseek( $fileHandle, 1032 + ($start * 20) ); |
| | + |
| | + for( $i = $start; $i < $end; $i++ ) { |
| | + if( fread( $fileHandle, 20 ) === $binSha ) { |
| | + $result = $i; |
| | + break; |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function getOffsetFromTable( $fileHandle, int $idx, int $total ): int { |
| | + $pos = 1032 + ($total * 20) + ($total * 4) + ($idx * 4); |
| | + fseek( $fileHandle, $pos ); |
| | + $offset = unpack( 'N', fread( $fileHandle, 4 ) )[1]; |
| | + |
| | + if( $offset & 0x80000000 ) { |
| | + $base = 1032 + ($total * 24) + ($total * 4); |
| | + fseek( $fileHandle, $base + (($offset & 0x7FFFFFFF) * 8) ); |
| | + $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1]; |
| | + } |
| | + |
| | + return $offset; |
| | + } |
| | + |
| | + private function readPackEntry( $fileHandle, int $offset ): string { |
| | + fseek( $fileHandle, $offset ); |
| | + $header = $this->readVarInt( $fileHandle ); |
| | + $type = ($header['byte'] >> 4) & 7; |
| | + $result = ''; |
| | + |
| | + if( $type === 6 ) { |
| | + $result = $this->handleOfsDelta( $fileHandle, $offset ); |
| | + } elseif( $type === 7 ) { |
| | + $result = $this->handleRefDelta( $fileHandle ); |
| | + } else { |
| | + $result = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: ''; |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function handleOfsDelta( $fileHandle, int $offset ): string { |
| | + $byte = ord( fread( $fileHandle, 1 ) ); |
| | + $negOffset = $byte & 127; |
| | + |
| | + while( $byte & 128 ) { |
| | + $byte = ord( fread( $fileHandle, 1 ) ); |
| | + $negOffset = (($negOffset + 1) << 7) | ($byte & 127); |
| | + } |
| | + |
| | + $base = $this->readPackEntry( $fileHandle, $offset - $negOffset ); |
| | + $delta = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: ''; |
| | + |
| | + return $this->applyDelta( $base, $delta ); |
| | + } |
| | + |
| | + private function handleRefDelta( $fileHandle ): string { |
| | + $base = $this->read( bin2hex( fread( $fileHandle, 20 ) ) ); |
| | + $delta = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: ''; |
| | + |
| | + return $this->applyDelta( $base, $delta ); |
| | + } |
| | + |
| | + private function applyDelta( string $base, string $delta ): string { |
| | + $out = ''; |
| | + |
| | + if( $base !== '' && $delta !== '' ) { |
| | + $position = 0; |
| | + $this->skipSize( $delta, $position ); |
| | + $this->skipSize( $delta, $position ); |
| | + |
| | + while( $position < strlen( $delta ) ) { |
| | + $opcode = ord( $delta[$position++] ); |
| | + |
| | + if( $opcode & 128 ) { |
| | + $out .= $this->deltaCopy( $base, $delta, $position, $opcode ); |
| | + } else { |
| | + $len = $opcode & 127; |
| | + $out .= substr( $delta, $position, $len ); |
| | + $position += $len; |
| | + } |
| | + } |
| | + } |
| | + |
| | + return $out; |
| | + } |
| | + |
| | + private function deltaCopy( |
| | + string $base, |
| | + string $delta, |
| | + int &$position, |
| | + int $opcode |
| | + ): string { |
| | + $offset = 0; |
| | + $length = 0; |
| | + |
| | + if( $opcode & 1 ) { $offset |= ord( $delta[$position++] ); } |
| | + if( $opcode & 2 ) { $offset |= ord( $delta[$position++] ) << 8; } |
| | + if( $opcode & 4 ) { $offset |= ord( $delta[$position++] ) << 16; } |
| | + if( $opcode & 8 ) { $offset |= ord( $delta[$position++] ) << 24; } |
| | + if( $opcode & 16 ) { $length |= ord( $delta[$position++] ); } |
| | + if( $opcode & 32 ) { $length |= ord( $delta[$position++] ) << 8; } |
| | + if( $opcode & 64 ) { $length |= ord( $delta[$position++] ) << 16; } |
| | + |
| | + return substr( $base, $offset, ($length === 0 ? 0x10000 : $length) ); |
| | + } |
| | + |
| | + private function skipSize( string $data, int &$position ): void { |
| | + while( ord( $data[$position++] ) & 128 ) { |
| | + // Intentionally empty |
| | + } |
| | + } |
| | + |
| | + private function readSize( string $data, int &$position ): int { |
| | + $byte = ord( $data[$position++] ); |
| | + $value = $byte & 127; |
| | + $shift = 7; |
| | + |
| | + while( $byte & 128 ) { |
| | + $byte = ord( $data[$position++] ); |
| | + $value |= (($byte & 127) << $shift); |
| | + $shift += 7; |
| | + } |
| | + |
| | + return $value; |
| | + } |
| | + |
| | + private function skipOffsetDelta( $fileHandle ): void { |
| | + $byte = ord( fread( $fileHandle, 1 ) ); |
| | + |
| | + while( $byte & 128 ) { |
| | + $byte = ord( fread( $fileHandle, 1 ) ); |
| | + } |
| | + } |
| | + |
| | + private function scanRefs( string $prefix, callable $callback ): void { |
| | + $directory = "{$this->path}/$prefix"; |
| | + |
| | + if( is_dir( $directory ) ) { |
| | + foreach( array_diff( scandir( $directory ), ['.', '..'] ) as $fileName ) { |
| | + $content = file_get_contents( "$directory/$fileName" ); |
| | + $callback( $fileName, trim( $content ) ); |
| | + } |
| | + } |
| | } |
| | } |