| | private array $packFiles; |
| | |
| | - // Cache for open file handles (idx and pack files) |
| | - private array $fileHandles = []; |
| | - |
| | - // Cache for the fanout table of each index file |
| | - private array $fanoutCache = []; |
| | - |
| | - // Cache for the last successfully used pack file (Locality Optimization) |
| | - private ?string $lastPack = null; |
| | - |
| | - public function __construct( string $repoPath ) { |
| | - $this->path = rtrim( $repoPath, '/' ); |
| | - $this->objPath = $this->path . '/objects'; |
| | - $this->packFiles = glob( "{$this->objPath}/pack/*.idx" ) ?: []; |
| | - } |
| | - |
| | - public function __destruct() { |
| | - foreach( $this->fileHandles as $handle ) { |
| | - if( is_resource( $handle ) ) { |
| | - fclose( $handle ); |
| | - } |
| | - } |
| | - } |
| | - |
| | - public function getObjectSize( string $sha ): int { |
| | - $info = $this->getPackOffset( $sha ); |
| | - |
| | - if( $info['offset'] !== -1 ) { |
| | - return $this->extractPackedSize( $info ); |
| | - } |
| | - |
| | - $prefix = substr( $sha, 0, 2 ); |
| | - $suffix = substr( $sha, 2 ); |
| | - $loosePath = "{$this->objPath}/{$prefix}/{$suffix}"; |
| | - |
| | - return file_exists( $loosePath ) |
| | - ? $this->getLooseObjectSize( $loosePath ) |
| | - : 0; |
| | - } |
| | - |
| | - 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; |
| | - $packPath = $info['file']; |
| | - |
| | - if( !isset( $this->fileHandles[$packPath] ) ) { |
| | - $this->fileHandles[$packPath] = @fopen( $packPath, 'rb' ); |
| | - } |
| | - |
| | - $packFile = $this->fileHandles[$packPath]; |
| | - |
| | - 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']; |
| | - } |
| | - |
| | - 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); |
| | - |
| | - // Fast size calculation due to caching + binary search |
| | - $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|160000) /'; |
| | - |
| | - 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 ) { |
| | - $packPath = $info['file']; |
| | - |
| | - if( !isset( $this->fileHandles[$packPath] ) ) { |
| | - $this->fileHandles[$packPath] = @fopen( $packPath, 'rb' ); |
| | - } |
| | - |
| | - $packFile = $this->fileHandles[$packPath]; |
| | - |
| | - if( $packFile ) { |
| | - $result = $this->readPackEntry( $packFile, $info['offset'] ); |
| | - } |
| | - } |
| | - |
| | - return $result; |
| | - } |
| | - |
| | - private function getPackOffset( string $sha ): array { |
| | - $result = ['file' => '', 'offset' => -1]; |
| | - |
| | - if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) { |
| | - $binSha = hex2bin( $sha ); |
| | - |
| | - // Locality Cache: Check the last successful pack first |
| | - if( $this->lastPack ) { |
| | - $offset = $this->findInPack( $this->lastPack, $binSha ); |
| | - if( $offset !== -1 ) { |
| | - return [ |
| | - 'file' => str_replace( '.idx', '.pack', $this->lastPack ), |
| | - 'offset' => $offset |
| | - ]; |
| | - } |
| | - } |
| | - |
| | - foreach( $this->packFiles as $idxFile ) { |
| | - if( $idxFile === $this->lastPack ) { |
| | - continue; |
| | - } |
| | - |
| | - $offset = $this->findInPack( $idxFile, $binSha ); |
| | - |
| | - if( $offset !== -1 ) { |
| | - $this->lastPack = $idxFile; // Update cache |
| | - $result = [ |
| | - 'file' => str_replace( '.idx', '.pack', $idxFile ), |
| | - 'offset' => $offset |
| | - ]; |
| | - break; |
| | - } |
| | - } |
| | - } |
| | - |
| | - return $result; |
| | - } |
| | - |
| | - private function findInPack( string $idxFile, string $binSha ): int { |
| | - if( !isset( $this->fileHandles[$idxFile] ) ) { |
| | - $handle = @fopen( $idxFile, 'rb' ); |
| | - if( !$handle ) return -1; |
| | - |
| | - $this->fileHandles[$idxFile] = $handle; |
| | - |
| | - fseek( $handle, 0 ); |
| | - |
| | - if( fread( $handle, 8 ) === "\377tOc\0\0\0\2" ) { |
| | - $this->fanoutCache[$idxFile] = array_values( unpack( 'N*', fread( $handle, 1024 ) ) ); |
| | - } else { |
| | - $this->fanoutCache[$idxFile] = null; |
| | - } |
| | - } |
| | - |
| | - $handle = $this->fileHandles[$idxFile]; |
| | - $fanout = $this->fanoutCache[$idxFile] ?? null; |
| | - |
| | - if( !$handle || !$fanout ) return -1; |
| | - |
| | - $firstByte = ord( $binSha[0] ); |
| | - $start = ($firstByte === 0) ? 0 : $fanout[$firstByte - 1]; |
| | - $end = $fanout[$firstByte]; |
| | - $offset = -1; |
| | - |
| | - if( $end > $start ) { |
| | - $total = $fanout[255]; |
| | - $count = $end - $start; |
| | - |
| | - fseek( $handle, 1032 + ($start * 20) ); |
| | - $shaBlock = fread( $handle, $count * 20 ); |
| | - |
| | - $foundIdx = $this->searchShaBlock( |
| | - $shaBlock, |
| | - $count, |
| | - $binSha |
| | - ); |
| | - |
| | - if( $foundIdx !== -1 ) { |
| | - $offset = $this->getOffsetFromTable( |
| | - $handle, |
| | - $start + $foundIdx, |
| | - $total |
| | - ); |
| | - } |
| | - } |
| | - |
| | - return $offset; |
| | - } |
| | - |
| | - private function searchShaBlock( |
| | - string $shaBlock, |
| | - int $count, |
| | - string $binSha |
| | - ): int { |
| | - $low = 0; |
| | - $high = $count - 1; |
| | - |
| | - while( $low <= $high ) { |
| | - $mid = ($low + $high) >> 1; |
| | - |
| | - // Extract 20 bytes from the memory block |
| | - $currentSha = substr( $shaBlock, $mid * 20, 20 ); |
| | - |
| | - if( $currentSha < $binSha ) { |
| | - $low = $mid + 1; |
| | - } elseif( $currentSha > $binSha ) { |
| | - $high = $mid - 1; |
| | - } else { |
| | - return $mid; |
| | - } |
| | - } |
| | - |
| | - return -1; |
| | - } |
| | - |
| | - private function searchShaTable( |
| | - $fileHandle, |
| | - int $start, |
| | - int $end, |
| | - string $binSha |
| | - ): int { |
| | - $low = $start; |
| | - $high = $end - 1; |
| | - |
| | - // Binary search within the bucket |
| | - while( $low <= $high ) { |
| | - $mid = ($low + $high) >> 1; |
| | - |
| | - fseek( $fileHandle, 1032 + ($mid * 20) ); |
| | - $currentSha = fread( $fileHandle, 20 ); |
| | - |
| | - if( $currentSha < $binSha ) { |
| | - $low = $mid + 1; |
| | - } elseif( $currentSha > $binSha ) { |
| | - $high = $mid - 1; |
| | - } else { |
| | - return $mid; |
| | - } |
| | - } |
| | - |
| | - return -1; |
| | - } |
| | - |
| | - private function getOffsetFromTable( $fileHandle, int $idx, int $total ): int { |
| | - $pos = 1032 + ($total * 20) + ($total * 4) + ($idx * 4); |
| | - fseek( $fileHandle, $pos ); |
| | - $data = fread( $fileHandle, 4 ); |
| | - $offset = $data ? unpack( 'N', $data )[1] : 0; |
| | - |
| | - if( $offset & 0x80000000 ) { |
| | - $base = 1032 + ($total * 24) + ($total * 4); |
| | - fseek( $fileHandle, $base + (($offset & 0x7FFFFFFF) * 8) ); |
| | - $data64 = fread( $fileHandle, 8 ); |
| | - $offset = $data64 ? unpack( 'J', $data64 )[1] : 0; |
| | - } |
| | - |
| | - return (int)$offset; |
| | - } |
| | - |
| | - private function readPackEntry( $fileHandle, int $offset ): string { |
| | - fseek( $fileHandle, $offset ); |
| | - $header = $this->readVarInt( $fileHandle ); |
| | - $type = ($header['byte'] >> 4) & 7; |
| | - |
| | - if( $type === 6 ) return $this->handleOfsDelta( $fileHandle, $offset ); |
| | - if( $type === 7 ) return $this->handleRefDelta( $fileHandle ); |
| | - |
| | - $inf = inflate_init( ZLIB_ENCODING_DEFLATE ); |
| | - $res = ''; |
| | - |
| | - while( !feof( $fileHandle ) ) { |
| | - $chunk = fread( $fileHandle, 8192 ); |
| | - $data = @inflate_add( $inf, $chunk ); |
| | - |
| | - if( $data !== false ) $res .= $data; |
| | - if( $data === false || ($inf && inflate_get_status( $inf ) === ZLIB_STREAM_END) ) break; |
| | - } |
| | - |
| | - return $res; |
| | - } |
| | - |
| | - private function deltaCopy( |
| | - string $base, string $delta, int &$position, int $opcode |
| | - ): string { |
| | - $offset = 0; |
| | - $length = 0; |
| | - |
| | - if( $opcode & 0x01 ) $offset |= ord( $delta[$position++] ); |
| | - if( $opcode & 0x02 ) $offset |= ord( $delta[$position++] ) << 8; |
| | - if( $opcode & 0x04 ) $offset |= ord( $delta[$position++] ) << 16; |
| | - if( $opcode & 0x08 ) $offset |= ord( $delta[$position++] ) << 24; |
| | - |
| | - if( $opcode & 0x10 ) $length |= ord( $delta[$position++] ); |
| | - if( $opcode & 0x20 ) $length |= ord( $delta[$position++] ) << 8; |
| | - if( $opcode & 0x40 ) $length |= ord( $delta[$position++] ) << 16; |
| | - |
| | - if( $length === 0 ) $length = 0x10000; |
| | - |
| | - return substr( $base, $offset, $length ); |
| | - } |
| | - |
| | - 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); |
| | - } |
| | - |
| | - $currentPos = ftell( $fileHandle ); |
| | - $base = $this->readPackEntry( $fileHandle, $offset - $negOffset ); |
| | - fseek( $fileHandle, $currentPos ); |
| | - |
| | - $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 skipSize( string $data, int &$position ): void { |
| | - while( ord( $data[$position++] ) & 128 ) { |
| | - // Intentionally empty |
| | + private array $fileHandles = []; |
| | + private array $fanoutCache = []; |
| | + private array $shaBucketCache = []; |
| | + private array $offsetBucketCache = []; |
| | + private ?string $lastPack = null; |
| | + |
| | + public function __construct( string $repoPath ) { |
| | + $this->path = rtrim( $repoPath, '/' ); |
| | + $this->objPath = $this->path . '/objects'; |
| | + $this->packFiles = glob( "{$this->objPath}/pack/*.idx" ) ?: []; |
| | + } |
| | + |
| | + public function __destruct() { |
| | + foreach( $this->fileHandles as $handle ) { |
| | + if( is_resource( $handle ) ) { |
| | + fclose( $handle ); |
| | + } |
| | + } |
| | + } |
| | + |
| | + public function getObjectSize( string $sha ): int { |
| | + $info = $this->getPackOffset( $sha ); |
| | + |
| | + if( $info['offset'] !== -1 ) { |
| | + return $this->extractPackedSize( $info ); |
| | + } |
| | + |
| | + $prefix = substr( $sha, 0, 2 ); |
| | + $suffix = substr( $sha, 2 ); |
| | + $loosePath = "{$this->objPath}/{$prefix}/{$suffix}"; |
| | + |
| | + return file_exists( $loosePath ) |
| | + ? $this->getLooseObjectSize( $loosePath ) |
| | + : 0; |
| | + } |
| | + |
| | + 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; |
| | + $packPath = $info['file']; |
| | + |
| | + if( !isset( $this->fileHandles[$packPath] ) ) { |
| | + $this->fileHandles[$packPath] = @fopen( $packPath, 'rb' ); |
| | + } |
| | + |
| | + $packFile = $this->fileHandles[$packPath]; |
| | + |
| | + if( $packFile ) { |
| | + fseek( $packFile, $info['offset'] ); |
| | + $buffer = fread( $packFile, 64 ); |
| | + $pos = 0; |
| | + |
| | + $byte = ord( $buffer[$pos++] ); |
| | + $type = ($byte >> 4) & 7; |
| | + $targetSize = $byte & 15; |
| | + $shift = 4; |
| | + |
| | + while( $byte & 128 ) { |
| | + $byte = ord( $buffer[$pos++] ); |
| | + $targetSize |= (($byte & 127) << $shift); |
| | + $shift += 7; |
| | + } |
| | + |
| | + if( $type === 6 || $type === 7 ) { |
| | + $targetSize = $this->readDeltaTargetSize( $packFile, $type, $buffer, $pos ); |
| | + } |
| | + } |
| | + |
| | + 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, string $buffer, int $pos ): int { |
| | + if( $type === 6 ) { |
| | + $byte = ord( $buffer[$pos++] ); |
| | + while( $byte & 128 ) { |
| | + $byte = ord( $buffer[$pos++] ); |
| | + } |
| | + } else { |
| | + $pos += 20; |
| | + } |
| | + |
| | + $inflateContext = inflate_init( ZLIB_ENCODING_DEFLATE ); |
| | + $headerData = ''; |
| | + |
| | + if( $pos < strlen( $buffer ) ) { |
| | + $chunk = substr( $buffer, $pos ); |
| | + $inflated = @inflate_add( $inflateContext, $chunk, ZLIB_NO_FLUSH ); |
| | + if( $inflated !== false ) { |
| | + $headerData .= $inflated; |
| | + } |
| | + } |
| | + |
| | + 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|160000) /'; |
| | + |
| | + 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 ) { |
| | + $packPath = $info['file']; |
| | + |
| | + if( !isset( $this->fileHandles[$packPath] ) ) { |
| | + $this->fileHandles[$packPath] = @fopen( $packPath, 'rb' ); |
| | + } |
| | + |
| | + $packFile = $this->fileHandles[$packPath]; |
| | + |
| | + if( $packFile ) { |
| | + $result = $this->readPackEntry( $packFile, $info['offset'] ); |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function getPackOffset( string $sha ): array { |
| | + $result = ['file' => '', 'offset' => -1]; |
| | + |
| | + if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) { |
| | + $binSha = hex2bin( $sha ); |
| | + |
| | + if( $this->lastPack ) { |
| | + $offset = $this->findInPack( $this->lastPack, $binSha ); |
| | + if( $offset !== -1 ) { |
| | + return [ |
| | + 'file' => str_replace( '.idx', '.pack', $this->lastPack ), |
| | + 'offset' => $offset |
| | + ]; |
| | + } |
| | + } |
| | + |
| | + foreach( $this->packFiles as $idxFile ) { |
| | + if( $idxFile === $this->lastPack ) { |
| | + continue; |
| | + } |
| | + |
| | + $offset = $this->findInPack( $idxFile, $binSha ); |
| | + |
| | + if( $offset !== -1 ) { |
| | + $this->lastPack = $idxFile; |
| | + $result = [ |
| | + 'file' => str_replace( '.idx', '.pack', $idxFile ), |
| | + 'offset' => $offset |
| | + ]; |
| | + break; |
| | + } |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function findInPack( string $idxFile, string $binSha ): int { |
| | + if( !isset( $this->fileHandles[$idxFile] ) ) { |
| | + $handle = @fopen( $idxFile, 'rb' ); |
| | + if( !$handle ) return -1; |
| | + |
| | + $this->fileHandles[$idxFile] = $handle; |
| | + fseek( $handle, 0 ); |
| | + |
| | + if( fread( $handle, 8 ) === "\377tOc\0\0\0\2" ) { |
| | + $this->fanoutCache[$idxFile] = array_values( unpack( 'N*', fread( $handle, 1024 ) ) ); |
| | + } else { |
| | + $this->fanoutCache[$idxFile] = null; |
| | + } |
| | + } |
| | + |
| | + $handle = $this->fileHandles[$idxFile]; |
| | + $fanout = $this->fanoutCache[$idxFile] ?? null; |
| | + |
| | + if( !$handle || !$fanout ) return -1; |
| | + |
| | + $firstByte = ord( $binSha[0] ); |
| | + $start = ($firstByte === 0) ? 0 : $fanout[$firstByte - 1]; |
| | + $end = $fanout[$firstByte]; |
| | + |
| | + if( $end <= $start ) return -1; |
| | + |
| | + $cacheKey = "$idxFile:$firstByte"; |
| | + |
| | + if( isset( $this->shaBucketCache[$cacheKey] ) ) { |
| | + $shaBlock = $this->shaBucketCache[$cacheKey]; |
| | + } else { |
| | + $count = $end - $start; |
| | + fseek( $handle, 1032 + ($start * 20) ); |
| | + $shaBlock = fread( $handle, $count * 20 ); |
| | + $this->shaBucketCache[$cacheKey] = $shaBlock; |
| | + |
| | + $total = $fanout[255]; |
| | + $layer4Start = 1032 + ($total * 24); |
| | + |
| | + fseek( $handle, $layer4Start + ($start * 4) ); |
| | + $this->offsetBucketCache[$cacheKey] = fread( $handle, $count * 4 ); |
| | + } |
| | + |
| | + $count = strlen( $shaBlock ) / 20; |
| | + $foundIdx = $this->searchShaBlock( $shaBlock, $count, $binSha ); |
| | + |
| | + if( $foundIdx === -1 ) return -1; |
| | + |
| | + $offsetData = substr( $this->offsetBucketCache[$cacheKey], $foundIdx * 4, 4 ); |
| | + $offset = unpack( 'N', $offsetData )[1]; |
| | + |
| | + if( $offset & 0x80000000 ) { |
| | + $total = $fanout[255]; |
| | + $layer5Start = 1032 + ($total * 24) + ($total * 4); |
| | + fseek( $handle, $layer5Start + (($offset & 0x7FFFFFFF) * 8) ); |
| | + $data64 = fread( $handle, 8 ); |
| | + $offset = $data64 ? unpack( 'J', $data64 )[1] : 0; |
| | + } |
| | + |
| | + return (int)$offset; |
| | + } |
| | + |
| | + private function searchShaBlock( |
| | + string $shaBlock, |
| | + int $count, |
| | + string $binSha |
| | + ): int { |
| | + $low = 0; |
| | + $high = $count - 1; |
| | + |
| | + while( $low <= $high ) { |
| | + $mid = ($low + $high) >> 1; |
| | + $currentSha = substr( $shaBlock, $mid * 20, 20 ); |
| | + |
| | + if( $currentSha < $binSha ) { |
| | + $low = $mid + 1; |
| | + } elseif( $currentSha > $binSha ) { |
| | + $high = $mid - 1; |
| | + } else { |
| | + return $mid; |
| | + } |
| | + } |
| | + |
| | + return -1; |
| | + } |
| | + |
| | + private function readPackEntry( $fileHandle, int $offset ): string { |
| | + fseek( $fileHandle, $offset ); |
| | + $header = $this->readVarInt( $fileHandle ); |
| | + $type = ($header['byte'] >> 4) & 7; |
| | + |
| | + if( $type === 6 ) return $this->handleOfsDelta( $fileHandle, $offset ); |
| | + if( $type === 7 ) return $this->handleRefDelta( $fileHandle ); |
| | + |
| | + $inf = inflate_init( ZLIB_ENCODING_DEFLATE ); |
| | + $res = ''; |
| | + |
| | + while( !feof( $fileHandle ) ) { |
| | + $chunk = fread( $fileHandle, 8192 ); |
| | + $data = @inflate_add( $inf, $chunk ); |
| | + |
| | + if( $data !== false ) $res .= $data; |
| | + if( $data === false || ($inf && inflate_get_status( $inf ) === ZLIB_STREAM_END) ) break; |
| | + } |
| | + |
| | + return $res; |
| | + } |
| | + |
| | + private function deltaCopy( |
| | + string $base, string $delta, int &$position, int $opcode |
| | + ): string { |
| | + $offset = 0; |
| | + $length = 0; |
| | + |
| | + if( $opcode & 0x01 ) $offset |= ord( $delta[$position++] ); |
| | + if( $opcode & 0x02 ) $offset |= ord( $delta[$position++] ) << 8; |
| | + if( $opcode & 0x04 ) $offset |= ord( $delta[$position++] ) << 16; |
| | + if( $opcode & 0x08 ) $offset |= ord( $delta[$position++] ) << 24; |
| | + |
| | + if( $opcode & 0x10 ) $length |= ord( $delta[$position++] ); |
| | + if( $opcode & 0x20 ) $length |= ord( $delta[$position++] ) << 8; |
| | + if( $opcode & 0x40 ) $length |= ord( $delta[$position++] ) << 16; |
| | + |
| | + if( $length === 0 ) $length = 0x10000; |
| | + |
| | + return substr( $base, $offset, $length ); |
| | + } |
| | + |
| | + 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); |
| | + } |
| | + |
| | + $currentPos = ftell( $fileHandle ); |
| | + $base = $this->readPackEntry( $fileHandle, $offset - $negOffset ); |
| | + fseek( $fileHandle, $currentPos ); |
| | + |
| | + $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 skipSize( string $data, int &$position ): void { |
| | + while( ord( $data[$position++] ) & 128 ) { |
| | } |
| | } |