| | <?php |
| | require_once 'File.php'; |
| | - |
| | -class Git { |
| | - 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; |
| | - private array $packFiles; |
| | - |
| | - private array $fileHandles = []; |
| | - private array $fanoutCache = []; |
| | - private array $shaBucketCache = []; |
| | - private array $offsetBucketCache = []; |
| | - private ?string $lastPack = null; |
| | - |
| | - // Profiling |
| | - private array $pStats = []; |
| | - private array $pTimers = []; |
| | - |
| | - public function __construct( string $repoPath ) { |
| | - $this->setRepository($repoPath); |
| | - } |
| | - |
| | - public function __destruct() { |
| | - foreach( $this->fileHandles as $handle ) { |
| | - if( is_resource( $handle ) ) { |
| | - fclose( $handle ); |
| | - } |
| | - } |
| | - } |
| | - |
| | - // --- Profiling Methods --- |
| | - |
| | - private function enter( string $name ): void { |
| | - $this->pTimers[$name] = microtime( true ); |
| | - } |
| | - |
| | - private function leave( string $name ): void { |
| | - if( !isset( $this->pTimers[$name] ) ) return; |
| | - |
| | - $elapsed = microtime( true ) - $this->pTimers[$name]; |
| | - |
| | - // Initialize stat entry if missing |
| | - if( !isset( $this->pStats[$name] ) ) { |
| | - $this->pStats[$name] = ['cnt' => 0, 'time' => 0.0]; |
| | - } |
| | - |
| | - $this->pStats[$name]['cnt']++; |
| | - $this->pStats[$name]['time'] += $elapsed; |
| | - unset( $this->pTimers[$name] ); |
| | - } |
| | - |
| | - public function profileReport(): string { |
| | - if (empty($this->pStats)) { |
| | - return "<p>No profiling data collected.</p>"; |
| | - } |
| | - |
| | - // Sort by total time descending |
| | - uasort($this->pStats, fn($a, $b) => $b['time'] <=> $a['time']); |
| | - |
| | - $html = '<table border="1" cellspacing="0" cellpadding="5" style="border-collapse: collapse; font-family: monospace; width: 100%;">'; |
| | - $html .= '<thead style="background: #eee;"><tr>'; |
| | - $html .= '<th style="text-align: left;">Method</th>'; |
| | - $html .= '<th style="text-align: right;">Calls</th>'; |
| | - $html .= '<th style="text-align: right;">Total (ms)</th>'; |
| | - $html .= '<th style="text-align: right;">Avg (ms)</th>'; |
| | - $html .= '</tr></thead><tbody>'; |
| | - |
| | - foreach ($this->pStats as $name => $stat) { |
| | - $totalMs = $stat['time'] * 1000; |
| | - $avgMs = $stat['cnt'] > 0 ? $totalMs / $stat['cnt'] : 0; |
| | - |
| | - // Remove namespace/class prefix for cleaner display |
| | - $cleanName = str_replace(__CLASS__ . '::', '', $name); |
| | - |
| | - $html .= '<tr>'; |
| | - $html .= '<td>' . htmlspecialchars($cleanName) . '</td>'; |
| | - $html .= '<td style="text-align: right;">' . $stat['cnt'] . '</td>'; |
| | - $html .= '<td style="text-align: right;">' . number_format($totalMs, 2) . '</td>'; |
| | - $html .= '<td style="text-align: right;">' . number_format($avgMs, 2) . '</td>'; |
| | - $html .= '</tr>'; |
| | - } |
| | - |
| | - $html .= '</tbody></table>'; |
| | - |
| | - return $html; |
| | - } |
| | - |
| | - // --- Core Methods (Instrumented) --- |
| | - |
| | - public function setRepository($repoPath) { |
| | - $this->path = rtrim( $repoPath, '/' ); |
| | - $this->objPath = $this->path . '/objects'; |
| | - $this->packFiles = glob( "{$this->objPath}/pack/*.idx" ) ?: []; |
| | - } |
| | - |
| | - public function getObjectSize( string $sha ): int { |
| | - $this->enter( __METHOD__ ); |
| | - $info = $this->getPackOffset( $sha ); |
| | - |
| | - if( $info['offset'] !== -1 ) { |
| | - $res = $this->extractPackedSize( $info ); |
| | - $this->leave( __METHOD__ ); |
| | - return $res; |
| | - } |
| | - |
| | - $prefix = substr( $sha, 0, 2 ); |
| | - $suffix = substr( $sha, 2 ); |
| | - $loosePath = "{$this->objPath}/{$prefix}/{$suffix}"; |
| | - |
| | - $res = file_exists( $loosePath ) |
| | - ? $this->getLooseObjectSize( $loosePath ) |
| | - : 0; |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - return $res; |
| | - } |
| | - |
| | - private function getLooseObjectSize( string $path ): int { |
| | - $this->enter( __METHOD__ ); |
| | - $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 ); |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - $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 ); |
| | - } |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - 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 ) { |
| | - if( inflate_get_status( $inflateContext ) === ZLIB_STREAM_END ) { |
| | - break; |
| | - } |
| | - |
| | - $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 ); |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - $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); |
| | - |
| | - // Recursive call tracked |
| | - $size = $isDir ? 0 : $this->getObjectSize( $entrySha ); |
| | - |
| | - $callback( new File( $name, $entrySha, $mode, 0, $size ) ); |
| | - $position = $nullPos + 21; |
| | - } |
| | - $this->leave( __METHOD__ ); |
| | - } |
| | - |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - $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 ); |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - $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 ); |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - $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 ) { |
| | - $this->leave( __METHOD__ ); |
| | - 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; |
| | - } |
| | - } |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - return $result; |
| | - } |
| | - |
| | - private function findInPack( string $idxFile, string $binSha ): int { |
| | - $this->enter( __METHOD__ ); |
| | - |
| | - if( !isset( $this->fileHandles[$idxFile] ) ) { |
| | - $handle = @fopen( $idxFile, 'rb' ); |
| | - if( !$handle ) { |
| | - $this->leave( __METHOD__ ); |
| | - 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 ) { |
| | - $this->leave( __METHOD__ ); |
| | - return -1; |
| | - } |
| | - |
| | - $firstByte = ord( $binSha[0] ); |
| | - $start = ($firstByte === 0) ? 0 : $fanout[$firstByte - 1]; |
| | - $end = $fanout[$firstByte]; |
| | - |
| | - if( $end <= $start ) { |
| | - $this->leave( __METHOD__ ); |
| | - 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 ) { |
| | - $this->leave( __METHOD__ ); |
| | - 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; |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - fseek( $fileHandle, $offset ); |
| | - $header = $this->readVarInt( $fileHandle ); |
| | - $type = ($header['byte'] >> 4) & 7; |
| | - |
| | - if( $type === 6 ) { |
| | - $res = $this->handleOfsDelta( $fileHandle, $offset ); |
| | - $this->leave( __METHOD__ ); |
| | - return $res; |
| | - } |
| | - if( $type === 7 ) { |
| | - $res = $this->handleRefDelta( $fileHandle ); |
| | - $this->leave( __METHOD__ ); |
| | - return $res; |
| | - } |
| | - |
| | - $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; |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - 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 { |
| | - $this->enter( __METHOD__ ); |
| | - $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; |
| | - } |
| | - } |
| | - } |
| | - |
| | - $this->leave( __METHOD__ ); |
| | - return $out; |
| | - } |
| | - |
| | - private function skipSize( string $data, int &$position ): void { |
| | - while( ord( $data[$position++] ) & 128 ) { |
| | - } |
| | - } |
| | - |
| | - 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 ) ); |
| | - } |
| | - } |
| | +require_once 'GitRefs.php'; |
| | +require_once 'GitPacks.php'; |
| | + |
| | +class Git { |
| | + private const CHUNK_SIZE = 128; |
| | + private string $path; |
| | + private string $objPath; |
| | + |
| | + private GitRefs $refs; |
| | + private GitPacks $packs; |
| | + |
| | + public function __construct(string $repoPath) { |
| | + $this->path = rtrim($repoPath, '/'); |
| | + $this->objPath = $this->path . '/objects'; |
| | + $this->refs = new GitRefs($this->path); |
| | + $this->packs = new GitPacks($this->objPath); |
| | + } |
| | + |
| | + public function resolve(string $ref): string { |
| | + return $this->refs->resolve($ref); |
| | + } |
| | + |
| | + public function getMainBranch(): array { |
| | + return $this->refs->getMainBranch(); |
| | + } |
| | + |
| | + public function eachBranch(callable $cb): void { |
| | + $this->refs->scanRefs('refs/heads', $cb); |
| | + } |
| | + |
| | + public function eachTag(callable $cb): void { |
| | + $this->refs->scanRefs('refs/tags', $cb); |
| | + } |
| | + |
| | + public function getObjectSize(string $sha): int { |
| | + $size = $this->packs->getSize($sha); |
| | + if ($size !== null) return $size; |
| | + |
| | + return $this->getLooseObjectSize($sha); |
| | + } |
| | + |
| | + public function read(string $sha): string { |
| | + $loosePath = $this->getLoosePath($sha); |
| | + |
| | + if (file_exists($loosePath)) { |
| | + $raw = file_get_contents($loosePath); |
| | + $inflated = $raw ? @gzuncompress($raw) : false; |
| | + return $inflated ? explode("\0", $inflated, 2)[1] : ''; |
| | + } |
| | + |
| | + return $this->packs->read($sha) ?? ''; |
| | + } |
| | + |
| | + public function stream(string $sha, callable $callback): void { |
| | + $data = $this->read($sha); |
| | + if ($data !== '') $callback($data); |
| | + } |
| | + |
| | + public function history(string $ref, int $limit, callable $cb): void { |
| | + $curr = $this->resolve($ref); |
| | + $count = 0; |
| | + |
| | + while ($curr !== '' && $count < $limit) { |
| | + $data = $this->read($curr); |
| | + if ($data === '') break; |
| | + |
| | + $pos = strpos($data, "\n\n"); |
| | + $msg = ($pos !== false) ? substr($data, $pos + 2) : ''; |
| | + preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $m); |
| | + |
| | + $cb((object)[ |
| | + 'sha' => $curr, |
| | + 'message' => trim($msg), |
| | + 'author' => $m[1] ?? 'Unknown', |
| | + 'email' => $m[2] ?? '', |
| | + 'date' => (int)($m[3] ?? 0) |
| | + ]); |
| | + |
| | + $curr = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $ms) ? $ms[1] : ''; |
| | + $count++; |
| | + } |
| | + } |
| | + |
| | + 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, $m)) { |
| | + $data = $this->read($m[1]); |
| | + } |
| | + |
| | + if ($this->isTreeData($data)) { |
| | + $this->processTree($data, $callback); |
| | + } |
| | + } |
| | + |
| | + private function processTree(string $data, callable $callback): void { |
| | + $pos = 0; |
| | + $len = strlen($data); |
| | + |
| | + while ($pos < $len) { |
| | + $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); |
| | + $sha = bin2hex(substr($data, $null + 1, 20)); |
| | + |
| | + $isDir = ($mode === '40000' || $mode === '040000'); |
| | + $size = $isDir ? 0 : $this->getObjectSize($sha); |
| | + |
| | + $callback(new File($name, $sha, $mode, 0, $size)); |
| | + $pos = $null + 21; |
| | + } |
| | + } |
| | + |
| | + private function isTreeData(string $data): bool { |
| | + $pat = '/^(40000|100644|100755|120000|160000) /'; |
| | + if (strlen($data) >= 25 && preg_match($pat, $data)) { |
| | + $null = strpos($data, "\0"); |
| | + return ($null !== false && ($null + 21 <= strlen($data))); |
| | + } |
| | + return false; |
| | + } |
| | + |
| | + private function getLoosePath(string $sha): string { |
| | + return "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2); |
| | + } |
| | + |
| | + private function getLooseObjectSize(string $sha): int { |
| | + $path = $this->getLoosePath($sha); |
| | + if (!file_exists($path)) return 0; |
| | + |
| | + $h = @fopen($path, 'rb'); |
| | + if (!$h) return 0; |
| | + |
| | + $data = ''; |
| | + $inf = inflate_init(ZLIB_ENCODING_DEFLATE); |
| | + |
| | + while (!feof($h)) { |
| | + $chunk = fread($h, self::CHUNK_SIZE); |
| | + $out = @inflate_add($inf, $chunk, ZLIB_NO_FLUSH); |
| | + if ($out === false) break; |
| | + $data .= $out; |
| | + if (strpos($data, "\0") !== false) break; |
| | + } |
| | + fclose($h); |
| | + |
| | + $header = explode("\0", $data, 2)[0]; |
| | + $parts = explode(' ', $header); |
| | + return isset($parts[1]) ? (int)$parts[1] : 0; |
| | } |
| | } |