| | <?php |
| | class Git { |
| | - private $repoPath; |
| | - private $gitDir; |
| | - |
| | - public function __construct( $repoPath ) { |
| | - $repoPath = realpath($repoPath); |
| | - |
| | - if ($repoPath === false) { |
| | - throw new Exception( "Invalid repository path" ); |
| | - } |
| | - |
| | - $allowedBase = realpath(REPOS_PATH); |
| | - if ($allowedBase === false || strpos($repoPath, $allowedBase) !== 0) { |
| | - throw new Exception( "Repository path outside allowed directory" ); |
| | - } |
| | - |
| | - $this->repoPath = rtrim( $repoPath, '/' ); |
| | - $this->gitDir = $this->repoPath; |
| | - |
| | - if( !is_dir( $this->gitDir . '/objects' ) || |
| | - !is_dir( $this->gitDir . '/refs' ) ) { |
| | - throw new Exception( "Not a valid bare git repository: {$repoPath}" ); |
| | - } |
| | - } |
| | - |
| | - /** |
| | - * Iterate over all files using a callback/lambda. |
| | - |
| | - * @param callable $callback Function to call for each file. |
| | - */ |
| | - public function forEachFile( callable $callback ) { |
| | - $treeHash = $this->getHeadTreeHash(); |
| | - $this->traverseTree( $treeHash, '', $callback ); |
| | - } |
| | - |
| | - /** |
| | - * Get the tree hash for HEAD commit. |
| | - */ |
| | - private function getHeadTreeHash() { |
| | - $head = $this->getHead(); |
| | - $commitHash = $this->resolveRef( $head ); |
| | - $commit = $this->readCommit( $commitHash ); |
| | - return $commit['tree']; |
| | - } |
| | - |
| | - /** |
| | - * Get HEAD reference |
| | - */ |
| | - private function getHead() { |
| | - $headFile = $this->gitDir . '/HEAD'; |
| | - if( !file_exists( $headFile ) ) { |
| | - throw new Exception( "HEAD file not found" ); |
| | - } |
| | - |
| | - $content = trim( file_get_contents( $headFile ) ); |
| | - |
| | - // HEAD typically contains "ref: refs/heads/main" or similar |
| | - if( strpos( $content, 'ref:' ) === 0 ) { |
| | - return trim( substr( $content, 4 ) ); |
| | - } |
| | - |
| | - return $content; |
| | - } |
| | - |
| | - /** |
| | - * Resolve a reference to a commit hash |
| | - */ |
| | - private function resolveRef( $ref ) { |
| | - $refFile = $this->gitDir . '/' . $ref; |
| | - |
| | - if( file_exists( $refFile ) ) { |
| | - return trim( file_get_contents( $refFile ) ); |
| | - } |
| | - |
| | - $packedRefs = $this->gitDir . '/packed-refs'; |
| | - |
| | - if( file_exists( $packedRefs ) ) { |
| | - $lines = file( $packedRefs, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES ); |
| | - foreach ( $lines as $line ) { |
| | - if( $line[0] === '#' || $line[0] === '^' ) continue; |
| | - |
| | - $parts = preg_split( '/\s+/', $line, 2 ); |
| | - if( count( $parts ) === 2 && $parts[1] === $ref ) { |
| | - return $parts[0]; |
| | - } |
| | - } |
| | - } |
| | - |
| | - throw new Exception( "Could not resolve ref: {$ref}" ); |
| | - } |
| | - |
| | - /** |
| | - * Read a Git object. |
| | - */ |
| | - private function readObject( $hash ) { |
| | - $objectPath = $this->gitDir . '/objects/' . substr( $hash, 0, 2 ) . '/' . substr( $hash, 2 ); |
| | - |
| | - if( !file_exists( $objectPath ) ) { |
| | - throw new Exception( "Object not found: {$hash}" ); |
| | - } |
| | - |
| | - $compressed = file_get_contents( $objectPath ); |
| | - $data = @gzuncompress( $compressed ); |
| | - |
| | - if( $data === false ) { |
| | - throw new Exception( "Failed to decompress object: {$hash}" ); |
| | - } |
| | - |
| | - // Parse header: "type size\0content" |
| | - $nullPos = strpos( $data, "\0" ); |
| | - $header = substr( $data, 0, $nullPos ); |
| | - $content = substr( $data, $nullPos + 1 ); |
| | - |
| | - list( $type, $size ) = explode( ' ', $header, 2 ); |
| | - |
| | - return [ |
| | - 'type' => $type, |
| | - 'size' => ( int )$size, |
| | - 'content' => $content |
| | - ]; |
| | - } |
| | - |
| | - /** |
| | - * Read a commit object. |
| | - */ |
| | - private function readCommit( $hash ) { |
| | - $obj = $this->readObject( $hash ); |
| | - |
| | - if( $obj['type'] !== 'commit' ) { |
| | - throw new Exception( "Expected commit object, got {$obj['type']}" ); |
| | - } |
| | - |
| | - $lines = explode( "\n", $obj['content'] ); |
| | - $commit = []; |
| | - |
| | - foreach ( $lines as $line ) { |
| | - if( strpos( $line, 'tree ' ) === 0 ) { |
| | - $commit['tree'] = substr( $line, 5 ); |
| | - } elseif( strpos( $line, 'author ' ) === 0 ) { |
| | - $commit['author'] = $this->parseCommitLine( substr( $line, 7 ) ); |
| | - } elseif( strpos( $line, 'committer ' ) === 0 ) { |
| | - $commit['committer'] = $this->parseCommitLine( substr( $line, 10 ) ); |
| | - } |
| | - } |
| | - |
| | - return $commit; |
| | - } |
| | - |
| | - /** |
| | - * Parse author/committer line. |
| | - */ |
| | - private function parseCommitLine( $line ) { |
| | - if( preg_match( '/^(.+?)\s+<(.+?)>\s+(\d+)\s+([+-]\d{4})$/', $line, $matches ) ) { |
| | - return [ |
| | - 'name' => $matches[1], |
| | - 'email' => $matches[2], |
| | - 'timestamp' => ( int )$matches[3], |
| | - 'timezone' => $matches[4], |
| | - 'date' => date( 'Y-m-d H:i:s', ( int )$matches[3] ) |
| | - ]; |
| | - } |
| | - |
| | - return ['name' => $line, 'date' => null]; |
| | - } |
| | - |
| | - /** |
| | - * Traverse tree and call callback for each file. |
| | - */ |
| | - private function traverseTree( $hash, $prefix, callable $callback ) { |
| | - $obj = $this->readObject( $hash ); |
| | - |
| | - if( $obj['type'] !== 'tree' ) { |
| | - throw new Exception( "Expected tree object, got {$obj['type']}" ); |
| | - } |
| | - |
| | - $content = $obj['content']; |
| | - $pos = 0; |
| | - $len = strlen( $content ); |
| | - |
| | - while ( $pos < $len ) { |
| | - $nullPos = strpos( $content, "\0", $pos ); |
| | - $modeAndName = substr( $content, $pos, $nullPos - $pos ); |
| | - |
| | - list( $mode, $name ) = explode( ' ', $modeAndName, 2 ); |
| | - |
| | - $objHash = bin2hex( substr( $content, $nullPos + 1, 20 ) ); |
| | - |
| | - $path = $prefix . $name; |
| | - |
| | - if( $mode === '40000' ) { |
| | - // Subdirectory, recurse |
| | - $this->traverseTree( $objHash, $path . '/', $callback ); |
| | - } else { |
| | - // File - read blob and get media type |
| | - $blobObj = $this->readObject( $objHash ); |
| | - $blobContent = $blobObj['content']; |
| | - |
| | - // Inspect content first, fallback to extension |
| | - // Note: MediaTypeSniffer must be defined elsewhere |
| | - $mediaType = MediaTypeSniffer::getMediaType( $blobContent ); |
| | - if( $mediaType === 'application/octet-stream' ) { |
| | - $mediaType = MediaTypeSniffer::getMediaTypeByExtension( $path ); |
| | - } |
| | - |
| | - $commitInfo = $this->getHeadCommitInfo(); |
| | - |
| | - $fileInfo = [ |
| | - 'path' => $path, |
| | - 'name' => basename( $path ), |
| | - 'size' => $blobObj['size'], |
| | - 'mode' => $mode, |
| | - 'hash' => $objHash, |
| | - 'media_type' => $mediaType, |
| | - 'last_updated' => $commitInfo['date'], |
| | - 'contributor' => $commitInfo['name'] |
| | - ]; |
| | - |
| | - $callback( $fileInfo ); |
| | - } |
| | - |
| | - $pos = $nullPos + 21; |
| | - } |
| | - } |
| | - |
| | - /** |
| | - * Get HEAD commit information (date and contributor). |
| | - */ |
| | - private function getHeadCommitInfo() { |
| | - static $cache = null; |
| | - |
| | - if( $cache === null ) { |
| | - try { |
| | - $head = $this->getHead(); |
| | - $commitHash = $this->resolveRef( $head ); |
| | - $commit = $this->readCommit( $commitHash ); |
| | - |
| | - $cache = [ |
| | - 'name' => $commit['committer']['name'] ?? 'Unknown', |
| | - 'date' => $commit['committer']['date'] ?? null |
| | - ]; |
| | - } catch ( Exception $e ) { |
| | - $cache = [ |
| | - 'name' => 'Unknown', |
| | - 'date' => null |
| | - ]; |
| | - } |
| | - } |
| | - |
| | - return $cache; |
| | - } |
| | -} |
| | -?> |
| | + private string $basePath; |
| | + |
| | + public function __construct(string $basePath) { |
| | + $this->basePath = realpath($basePath) ?: ''; |
| | + if ($this->basePath === '' || !is_dir($this->basePath)) { |
| | + throw new InvalidArgumentException('Invalid base path'); |
| | + } |
| | + } |
| | + |
| | + public function listRepositories(): array { |
| | + $repos = []; |
| | + $iterator = new DirectoryIterator($this->basePath); |
| | + foreach ($iterator as $item) { |
| | + if ($item->isDot()) continue; |
| | + if ($item->isDir()) { |
| | + $gitDir = $item->getPathname() . DIRECTORY_SEPARATOR . '.git'; |
| | + if (is_dir($gitDir)) { |
| | + $repos[] = $item->getBasename(); |
| | + } |
| | + } |
| | + } |
| | + sort($repos); |
| | + return $repos; |
| | + } |
| | + |
| | + public function readRepository(string $name): array { |
| | + $repoPath = $this->basePath . DIRECTORY_SEPARATOR . $name; |
| | + $gitPath = $repoPath . DIRECTORY_SEPARATOR . '.git'; |
| | + |
| | + if (!is_dir($gitPath)) { |
| | + throw new InvalidArgumentException('Not a git repository'); |
| | + } |
| | + |
| | + $result = [ |
| | + 'files' => [], |
| | + 'directories' => [], |
| | + 'commits' => [], |
| | + 'tags' => [] |
| | + ]; |
| | + |
| | + $result['files'] = $this->scanFiles($repoPath); |
| | + $result['directories'] = $this->scanDirectories($repoPath); |
| | + $result['commits'] = $this->readCommits($gitPath); |
| | + $result['tags'] = $this->readTags($gitPath); |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function scanFiles(string $path): array { |
| | + $files = []; |
| | + $iterator = new RecursiveIteratorIterator( |
| | + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), |
| | + RecursiveIteratorIterator::LEAVES_ONLY |
| | + ); |
| | + |
| | + foreach ($iterator as $file) { |
| | + if ($file->isFile()) { |
| | + $relative = str_replace($path . DIRECTORY_SEPARATOR, '', $file->getPathname()); |
| | + if (strpos($relative, '.git' . DIRECTORY_SEPARATOR) !== 0 && $relative !== '.git') { |
| | + $files[] = $relative; |
| | + } |
| | + } |
| | + } |
| | + |
| | + sort($files); |
| | + return $files; |
| | + } |
| | + |
| | + private function scanDirectories(string $path): array { |
| | + $dirs = []; |
| | + $iterator = new RecursiveIteratorIterator( |
| | + new RecursiveDirectoryIterator($path, RecursiveDirectoryIterator::SKIP_DOTS), |
| | + RecursiveIteratorIterator::SELF_FIRST |
| | + ); |
| | + |
| | + foreach ($iterator as $dir) { |
| | + if ($dir->isDir()) { |
| | + $relative = str_replace($path . DIRECTORY_SEPARATOR, '', $dir->getPathname()); |
| | + if (strpos($relative, '.git' . DIRECTORY_SEPARATOR) !== 0 && $relative !== '.git') { |
| | + $dirs[] = $relative; |
| | + } |
| | + } |
| | + } |
| | + |
| | + sort($dirs); |
| | + return $dirs; |
| | + } |
| | + |
| | + private function readCommits(string $gitPath): array { |
| | + $commits = []; |
| | + $headFile = $gitPath . DIRECTORY_SEPARATOR . 'HEAD'; |
| | + |
| | + if (!file_exists($headFile)) { |
| | + return $commits; |
| | + } |
| | + |
| | + $head = trim(file_get_contents($headFile)); |
| | + $ref = ''; |
| | + |
| | + if (strpos($head, 'ref: ') === 0) { |
| | + $refPath = $gitPath . DIRECTORY_SEPARATOR . substr($head, 5); |
| | + if (file_exists($refPath)) { |
| | + $ref = trim(file_get_contents($refPath)); |
| | + } else { |
| | + $packedRefs = $this->readPackedRefs($gitPath, substr($head, 5)); |
| | + if ($packedRefs !== null) { |
| | + $ref = $packedRefs; |
| | + } |
| | + } |
| | + } else { |
| | + $ref = $head; |
| | + } |
| | + |
| | + if ($ref === '' || strlen($ref) !== 40 || !ctype_xdigit($ref)) { |
| | + return $commits; |
| | + } |
| | + |
| | + $this->walkCommits($gitPath, $ref, $commits, 0); |
| | + return $commits; |
| | + } |
| | + |
| | + private function readPackedRefs(string $gitPath, string $refName): ?string { |
| | + $packedPath = $gitPath . DIRECTORY_SEPARATOR . 'packed-refs'; |
| | + if (!file_exists($packedPath)) { |
| | + return null; |
| | + } |
| | + |
| | + $content = file_get_contents($packedPath); |
| | + $lines = explode("\n", $content); |
| | + |
| | + foreach ($lines as $line) { |
| | + $line = trim($line); |
| | + if ($line === '' || $line[0] === '#') continue; |
| | + |
| | + $parts = preg_split('/\s+/', $line, 2); |
| | + if (count($parts) === 2 && $parts[1] === $refName) { |
| | + $sha = $parts[0]; |
| | + if (strlen($sha) === 40 && ctype_xdigit($sha)) { |
| | + return $sha; |
| | + } |
| | + } |
| | + } |
| | + |
| | + return null; |
| | + } |
| | + |
| | + private function walkCommits(string $gitPath, string $sha, array &$commits, int $depth): void { |
| | + if ($depth > 1000) return; |
| | + if (strlen($sha) !== 40 || !ctype_xdigit($sha)) return; |
| | + |
| | + foreach ($commits as $existing) { |
| | + if ($existing['sha'] === $sha) return; |
| | + } |
| | + |
| | + $content = $this->getObject($gitPath, $sha); |
| | + if ($content === null) return; |
| | + |
| | + $nullPos = strpos($content, "\x00"); |
| | + if ($nullPos === false) return; |
| | + |
| | + $header = substr($content, 0, $nullPos); |
| | + $body = substr($content, $nullPos + 1); |
| | + |
| | + if (!str_starts_with($header, 'commit ')) return; |
| | + |
| | + $lines = explode("\n", $body); |
| | + $commit = ['sha' => $sha, 'tree' => '', 'parent' => [], 'author' => '', 'committer' => '', 'message' => '']; |
| | + $inMessage = false; |
| | + $messageLines = []; |
| | + |
| | + foreach ($lines as $line) { |
| | + if ($inMessage) { |
| | + $messageLines[] = $line; |
| | + } elseif ($line === '') { |
| | + $inMessage = true; |
| | + } elseif (str_starts_with($line, 'tree ')) { |
| | + $commit['tree'] = substr($line, 5); |
| | + } elseif (str_starts_with($line, 'parent ')) { |
| | + $parent = substr($line, 7); |
| | + if (strlen($parent) === 40 && ctype_xdigit($parent)) { |
| | + $commit['parent'][] = $parent; |
| | + } |
| | + } elseif (str_starts_with($line, 'author ')) { |
| | + $commit['author'] = substr($line, 7); |
| | + } elseif (str_starts_with($line, 'committer ')) { |
| | + $commit['committer'] = substr($line, 10); |
| | + } |
| | + } |
| | + |
| | + $commit['message'] = implode("\n", $messageLines); |
| | + $commits[] = $commit; |
| | + |
| | + foreach ($commit['parent'] as $parent) { |
| | + $this->walkCommits($gitPath, $parent, $commits, $depth + 1); |
| | + } |
| | + } |
| | + |
| | + private function readTags(string $gitPath): array { |
| | + $tags = []; |
| | + $tagsPath = $gitPath . DIRECTORY_SEPARATOR . 'refs' . DIRECTORY_SEPARATOR . 'tags'; |
| | + |
| | + if (is_dir($tagsPath)) { |
| | + $iterator = new DirectoryIterator($tagsPath); |
| | + foreach ($iterator as $item) { |
| | + if ($item->isFile() && !$item->isDot()) { |
| | + $name = $item->getBasename(); |
| | + $sha = trim(file_get_contents($item->getPathname())); |
| | + if (strlen($sha) === 40 && ctype_xdigit($sha)) { |
| | + $tags[] = ['name' => $name, 'sha' => $sha]; |
| | + } |
| | + } |
| | + } |
| | + } |
| | + |
| | + $packedPath = $gitPath . DIRECTORY_SEPARATOR . 'packed-refs'; |
| | + if (file_exists($packedPath)) { |
| | + $content = file_get_contents($packedPath); |
| | + $lines = explode("\n", $content); |
| | + |
| | + foreach ($lines as $line) { |
| | + $line = trim($line); |
| | + if ($line === '' || $line[0] === '#') continue; |
| | + |
| | + $parts = preg_split('/\s+/', $line, 2); |
| | + if (count($parts) === 2 && str_starts_with($parts[1], 'refs/tags/')) { |
| | + $name = substr($parts[1], 10); |
| | + $sha = $parts[0]; |
| | + if (strlen($sha) === 40 && ctype_xdigit($sha)) { |
| | + $exists = false; |
| | + foreach ($tags as $tag) { |
| | + if ($tag['name'] === $name) { |
| | + $exists = true; |
| | + break; |
| | + } |
| | + } |
| | + if (!$exists) { |
| | + $tags[] = ['name' => $name, 'sha' => $sha]; |
| | + } |
| | + } |
| | + } |
| | + } |
| | + } |
| | + |
| | + usort($tags, fn($a, $b) => strcmp($a['name'], $b['name'])); |
| | + return $tags; |
| | + } |
| | + |
| | + private function getObject(string $gitPath, string $sha): ?string { |
| | + $loosePath = $gitPath . DIRECTORY_SEPARATOR . 'objects' . DIRECTORY_SEPARATOR . substr($sha, 0, 2) . DIRECTORY_SEPARATOR . substr($sha, 2); |
| | + if (file_exists($loosePath)) { |
| | + $content = file_get_contents($loosePath); |
| | + if ($content === false) return null; |
| | + $uncompressed = @gzuncompress($content); |
| | + return $uncompressed !== false ? $uncompressed : null; |
| | + } |
| | + |
| | + return $this->getPackedObject($gitPath, $sha); |
| | + } |
| | + |
| | + private function getPackedObject(string $gitPath, string $sha): ?string { |
| | + $packDir = $gitPath . DIRECTORY_SEPARATOR . 'objects' . DIRECTORY_SEPARATOR . 'pack'; |
| | + if (!is_dir($packDir)) return null; |
| | + |
| | + foreach (glob($packDir . DIRECTORY_SEPARATOR . '*.idx') as $idxFile) { |
| | + $content = $this->readIndexFile($idxFile); |
| | + if ($content === null) continue; |
| | + |
| | + $binSha = hex2bin($sha); |
| | + if (!isset($content[$binSha])) continue; |
| | + |
| | + $offset = $content[$binSha]; |
| | + $packFile = substr($idxFile, 0, -3) . 'pack'; |
| | + |
| | + $obj = $this->unpackObject($packFile, $offset, $gitPath); |
| | + if ($obj !== null) return $obj; |
| | + } |
| | + |
| | + return null; |
| | + } |
| | + |
| | + private function readIndexFile(string $path): ?array { |
| | + $content = file_get_contents($path); |
| | + if ($content === false) return null; |
| | + |
| | + $offset = 0; |
| | + $signature = substr($content, 0, 4); |
| | + |
| | + if ($signature === "\xFFtOc") { |
| | + $version = unpack('N', substr($content, 4, 4))[1]; |
| | + $offset = 8; |
| | + } else { |
| | + $version = 1; |
| | + } |
| | + |
| | + if ($version !== 1 && $version !== 2) return null; |
| | + |
| | + $fanout = unpack('N*', substr($content, $offset, 256 * 4)); |
| | + $offset += 256 * 4; |
| | + $count = $fanout[256]; |
| | + |
| | + $result = []; |
| | + if ($version === 1) { |
| | + for ($i = 0; $i < $count; $i++) { |
| | + $sha = substr($content, $offset + 4, 20); |
| | + $off = unpack('N', substr($content, $offset, 4))[1]; |
| | + $result[$sha] = $off; |
| | + $offset += 24; |
| | + } |
| | + } else { |
| | + $shas = []; |
| | + for ($i = 0; $i < $count; $i++) { |
| | + $shas[] = substr($content, $offset, 20); |
| | + $offset += 20; |
| | + } |
| | + $offset += $count * 4; |
| | + for ($i = 0; $i < $count; $i++) { |
| | + $off = unpack('N', substr($content, $offset, 4))[1]; |
| | + $result[$shas[$i]] = $off; |
| | + $offset += 4; |
| | + } |
| | + } |
| | + |
| | + return $result; |
| | + } |
| | + |
| | + private function unpackObject(string $packFile, int $offset, string $gitPath): ?string { |
| | + $fp = fopen($packFile, 'rb'); |
| | + if (!$fp) return null; |
| | + |
| | + fseek($fp, $offset, SEEK_SET); |
| | + $byte = ord(fread($fp, 1)); |
| | + $type = ($byte >> 4) & 7; |
| | + $size = $byte & 0xF; |
| | + $shift = 4; |
| | + |
| | + while (($byte & 0x80) !== 0) { |
| | + $byte = ord(fread($fp, 1)); |
| | + $size |= ($byte & 0x7F) << $shift; |
| | + $shift += 7; |
| | + } |
| | + |
| | + switch ($type) { |
| | + case 1: case 2: case 3: case 4: |
| | + return $this->readCompressed($fp, $size); |
| | + case 6: |
| | + return $this->readOfsDelta($fp, $offset, $size, $gitPath); |
| | + case 7: |
| | + return $this->readRefDelta($fp, $size, $gitPath); |
| | + default: |
| | + fclose($fp); |
| | + return null; |
| | + } |
| | + } |
| | + |
| | + private function readCompressed($fp, int $size): ?string { |
| | + $data = ''; |
| | + stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ); |
| | + while (strlen($data) < $size && !feof($fp)) { |
| | + $chunk = fread($fp, $size - strlen($data)); |
| | + if ($chunk === false) break; |
| | + $data .= $chunk; |
| | + } |
| | + fclose($fp); |
| | + return strlen($data) === $size ? $data : null; |
| | + } |
| | + |
| | + private function readOfsDelta($fp, int $objOffset, int $deltaSize, string $gitPath): ?string { |
| | + $offset = 0; |
| | + $byte = ord(fread($fp, 1)); |
| | + $baseOffset = $byte & 0x7F; |
| | + while (($byte & 0x80) !== 0) { |
| | + $byte = ord(fread($fp, 1)); |
| | + $baseOffset = (($baseOffset + 1) << 7) | ($byte & 0x7F); |
| | + } |
| | + |
| | + $baseOffset = $objOffset - $baseOffset; |
| | + fclose($fp); |
| | + |
| | + $packFile = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['args'][0]; |
| | + $base = $this->unpackObject($packFile, $baseOffset, $gitPath); |
| | + if ($base === null) return null; |
| | + |
| | + $delta = $this->readDeltaData($packFile, $objOffset + 1 + strlen(decbin($objOffset - $baseOffset)) / 7, $deltaSize); |
| | + if ($delta === null) return null; |
| | + |
| | + return $this->applyDelta($base, $delta); |
| | + } |
| | + |
| | + private function readRefDelta($fp, int $deltaSize, string $gitPath): ?string { |
| | + $sha = bin2hex(fread($fp, 20)); |
| | + fclose($fp); |
| | + |
| | + $base = $this->getObject($gitPath, $sha); |
| | + if ($base === null) return null; |
| | + |
| | + $packFile = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1]['args'][0]; |
| | + $delta = $this->readDeltaData($packFile, ftell($fp), $deltaSize); |
| | + if ($delta === null) return null; |
| | + |
| | + return $this->applyDelta($base, $delta); |
| | + } |
| | + |
| | + private function readDeltaData(string $packFile, int $offset, int $size): ?string { |
| | + $fp = fopen($packFile, 'rb'); |
| | + if (!$fp) return null; |
| | + fseek($fp, $offset, SEEK_SET); |
| | + |
| | + stream_filter_append($fp, 'zlib.inflate', STREAM_FILTER_READ); |
| | + $data = ''; |
| | + while (strlen($data) < $size && !feof($fp)) { |
| | + $chunk = fread($fp, $size - strlen($data)); |
| | + if ($chunk === false) break; |
| | + $data .= $chunk; |
| | + } |
| | + fclose($fp); |
| | + return strlen($data) === $size ? $data : null; |
| | + } |
| | + |
| | + private function applyDelta(string $base, string $delta): ?string { |
| | + $pos = 0; |
| | + $srcSize = 0; |
| | + do { |
| | + $byte = ord($delta[$pos++]); |
| | + $srcSize |= ($byte & 0x7F) << (($pos - 1) * 7); |
| | + } while (($byte & 0x80) !== 0); |
| | + |
| | + $dstSize = 0; |
| | + $shift = 0; |
| | + do { |
| | + $byte = ord($delta[$pos++]); |
| | + $dstSize |= ($byte & 0x7F) << $shift; |
| | + $shift += 7; |
| | + } while (($byte & 0x80) !== 0); |
| | + |
| | + if (strlen($base) !== $srcSize) return null; |
| | + |
| | + $result = ''; |
| | + $deltaLen = strlen($delta); |
| | + |
| | + while ($pos < $deltaLen) { |
| | + $byte = ord($delta[$pos++]); |
| | + |
| | + if (($byte & 0x80) !== 0) { |
| | + $cpOff = 0; |
| | + $cpSize = 0; |
| | + |
| | + if ($byte & 0x01) $cpOff |= ord($delta[$pos++]); |
| | + if ($byte & 0x02) $cpOff |= ord($delta[$pos++]) << 8; |
| | + if ($byte & 0x04) $cpOff |= ord($delta[$pos++]) << 16; |
| | + if ($byte & 0x08) $cpOff |= ord($delta[$pos++]) << 24; |
| | + |
| | + if ($byte & 0x10) $cpSize |= ord($delta[$pos++]); |
| | + if ($byte & 0x20) $cpSize |= ord($delta[$pos++]) << 8; |
| | + if ($byte & 0x40) $cpSize |= ord($delta[$pos++]) << 16; |
| | + |
| | + if ($cpSize === 0) $cpSize = 0x10000; |
| | + |
| | + $result .= substr($base, $cpOff, $cpSize); |
| | + } elseif ($byte !== 0) { |
| | + $result .= substr($delta, $pos, $byte); |
| | + $pos += $byte; |
| | + } else { |
| | + return null; |
| | + } |
| | + } |
| | + |
| | + return strlen($result) === $dstSize ? $result : null; |
| | + } |
| | +} |
| | + |
| | |