<?php require_once __DIR__ . '/../File.php'; require_once __DIR__ . '/../Tag.php'; require_once __DIR__ . '/GitRefs.php'; require_once __DIR__ . '/GitPacks.php'; class Git { private const MAX_READ_SIZE = 1048576; private string $repoPath; private string $objectsPath; private GitRefs $refs; private GitPacks $packs; public function __construct( string $repoPath ) { $this->setRepository( $repoPath ); } public function setRepository( string $repoPath ): void { $this->repoPath = rtrim( $repoPath, '/' ); $this->objectsPath = $this->repoPath . '/objects'; $this->refs = new GitRefs( $this->repoPath ); $this->packs = new GitPacks( $this->objectsPath ); } public function resolve( string $reference ): string { return $this->refs->resolve( $reference ); } public function getMainBranch(): array { return $this->refs->getMainBranch(); } public function eachBranch( callable $callback ): void { $this->refs->scanRefs( 'refs/heads', $callback ); } public function eachTag( callable $callback ): void { $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) { $data = $this->read( $sha ); $tag = $this->parseTagData( $name, $sha, $data ); $callback( $tag ); } ); } private function parseTagData( string $name, string $sha, string $data ): Tag { $isAnnotated = strncmp( $data, 'object ', 7 ) === 0; $targetSha = $isAnnotated ? $this->extractPattern( $data, '/^object ([0-9a-f]{40})$/m', 1, $sha ) : $sha; $pattern = $isAnnotated ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m' : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m'; $identity = $this->parseIdentity( $data, $pattern ); $message = $this->extractMessage( $data ); return new Tag( $name, $sha, $targetSha, $identity['timestamp'], $message, $identity['name'] ); } private function extractPattern( string $data, string $pattern, int $group, string $default = '' ): string { $matches = []; $result = preg_match( $pattern, $data, $matches ) ? $matches[$group] : $default; return $result; } private function parseIdentity( string $data, string $pattern ): array { $matches = []; $found = preg_match( $pattern, $data, $matches ); return [ 'name' => $found ? trim( $matches[1] ) : 'Unknown', 'email' => $found ? $matches[2] : '', 'timestamp' => $found ? (int)$matches[3] : 0 ]; } private function extractMessage( string $data ): string { $pos = strpos( $data, "\n\n" ); return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : ''; } public function getObjectSize( string $sha ): int { $size = $this->packs->getSize( $sha ); return $size !== null ? $size : $this->getLooseObjectSize( $sha ); } public function peek( string $sha, int $length = 255 ): string { $size = $this->packs->getSize( $sha ); return $size === null ? $this->peekLooseObject( $sha, $length ) : $this->packs->peek( $sha, $length ) ?? ''; } public function read( string $sha ): string { $size = $this->getObjectSize( $sha ); if( $size > self::MAX_READ_SIZE ) { return ''; } $content = ''; $this->slurp( $sha, function( $chunk ) use ( &$content ) { $content .= $chunk; } ); return $content; } public function readFile( string $hash, string $name ) { return new File( $name, $hash, '100644', 0, $this->getObjectSize( $hash ), $this->peek( $hash ) ); } public function stream( string $sha, callable $callback ): void { $this->slurp( $sha, $callback ); } private function slurp( string $sha, callable $callback ): void { $loosePath = $this->getLoosePath( $sha ); if( is_file( $loosePath ) ) { $this->slurpLooseObject( $loosePath, $callback ); } else { $this->slurpPackedObject( $sha, $callback ); } } private function iterateInflated( string $path, callable $processor ): void { $this->withInflatedFile( $path, function( $fileHandle, $inflator ) use ( $processor ) { $headerFound = false; $buffer = ''; while( !feof( $fileHandle ) ) { $chunk = fread( $fileHandle, 16384 ); $inflated = inflate_add( $inflator, $chunk ); if( $inflated === false ) { break; } if( !$headerFound ) { $buffer .= $inflated; $nullPos = strpos( $buffer, "\0" ); if( $nullPos !== false ) { $headerFound = true; $header = substr( $buffer, 0, $nullPos ); $body = substr( $buffer, $nullPos + 1 ); if( $processor( $body, $header ) === false ) { return; } } } else { if( $processor( $inflated, null ) === false ) { return; } } } } ); } private function slurpLooseObject( string $path, callable $callback ): void { $this->iterateInflated( $path, function( $chunk ) use ( $callback ) { if( $chunk !== '' ) { $callback( $chunk ); } return true; } ); } private function withInflatedFile( string $path, callable $callback ): void { $fileHandle = fopen( $path, 'rb' ); $inflator = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; if( $fileHandle && $inflator ) { $callback( $fileHandle, $inflator ); fclose( $fileHandle ); } } private function slurpPackedObject( string $sha, callable $callback ): void { $streamed = $this->packs->stream( $sha, $callback ); if( !$streamed ) { $data = $this->packs->read( $sha ); if( $data !== null && $data !== '' ) { $callback( $data ); } } } private function peekLooseObject( string $sha, int $length ): string { $path = $this->getLoosePath( $sha ); return is_file( $path ) ? $this->inflateLooseObjectPrefix( $path, $length ) : ''; } private function inflateLooseObjectPrefix( string $path, int $length ): string { $buffer = ''; $this->iterateInflated( $path, function( $chunk ) use ( $length, &$buffer ) { $buffer .= $chunk; return strlen( $buffer ) < $length; } ); return substr( $buffer, 0, $length ); } public function history( string $ref, int $limit, callable $callback ): void { $currentSha = $this->resolve( $ref ); $count = 0; while( $currentSha !== '' && $count < $limit ) { $commit = $this->parseCommit( $currentSha ); if( $commit === null ) { break; } $callback( $commit ); $currentSha = $commit->parentSha; $count++; } } private function parseCommit( string $sha ): ?object { $data = $this->read( $sha ); return $data === '' ? null : $this->buildCommitObject( $sha, $data ); } private function buildCommitObject( string $sha, string $data ): object { $identity = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' ); $message = $this->extractMessage( $data ); $parentSha = $this->extractPattern( $data, '/^parent ([0-9a-f]{40})$/m', 1 ); return (object)[ 'sha' => $sha, 'message' => $message, 'author' => $identity['name'], 'email' => $identity['email'], 'date' => $identity['timestamp'], 'parentSha' => $parentSha ]; } public function walk( string $refOrSha, callable $callback ): void { $sha = $this->resolve( $refOrSha ); if( $sha !== '' ) { $this->walkTree( $sha, $callback ); } } private function walkTree( string $sha, callable $callback ): void { $data = $this->read( $sha ); $treeData = $data !== '' && preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ? $this->read( $matches[1] ) : $data; if( $treeData !== '' && $this->isTreeData( $treeData ) ) { $this->processTree( $treeData, $callback ); } } private function processTree( string $data, callable $callback ): void { $position = 0; $length = strlen( $data ); while( $position < $length ) { $result = $this->parseTreeEntry( $data, $position, $length ); if( $result === null ) { break; } $callback( $result['file'] ); $position = $result['nextPosition']; } } private function parseTreeEntry( string $data, int $position, int $length ): ?array { $spacePos = strpos( $data, ' ', $position ); $nullPos = strpos( $data, "\0", $spacePos ); $hasValidPositions = $spacePos !== false && $nullPos !== false && $nullPos + 21 <= $length; return $hasValidPositions ? $this->buildTreeEntryResult( $data, $position, $spacePos, $nullPos ) : null; } private function buildTreeEntryResult( string $data, int $position, int $spacePos, int $nullPos ): array { $mode = substr( $data, $position, $spacePos - $position ); $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 ); $sha = bin2hex( substr( $data, $nullPos + 1, 20 ) ); $isDirectory = $mode === '40000' || $mode === '040000'; $size = $isDirectory ? 0 : $this->getObjectSize( $sha ); $contents = $isDirectory ? '' : $this->peek( $sha ); $file = new File( $name, $sha, $mode, 0, $size, $contents ); return [ 'file' => $file, 'nextPosition' => $nullPos + 21 ]; } private function isTreeData( string $data ): bool { $pattern = '/^(40000|100644|100755|120000|160000) /'; $minLength = strlen( $data ) >= 25; $matchesPattern = $minLength && preg_match( $pattern, $data ); $nullPos = $matchesPattern ? strpos( $data, "\0" ) : false; return $matchesPattern && $nullPos !== false && $nullPos + 21 <= strlen( $data ); } private function getLoosePath( string $sha ): string { return "{$this->objectsPath}/" . substr( $sha, 0, 2 ) . "/" . substr( $sha, 2 ); } private function getLooseObjectSize( string $sha ): int { $path = $this->getLoosePath( $sha ); return is_file( $path ) ? $this->readLooseObjectHeader( $path ) : 0; } private function readLooseObjectHeader( string $path ): int { $size = 0; $this->iterateInflated( $path, function( $chunk, $header ) use ( &$size ) { if( $header !== null ) { $parts = explode( ' ', $header ); $size = isset( $parts[1] ) ? (int)$parts[1] : 0; } return false; } ); return $size; } public function streamRaw( string $subPath ): bool { return strpos( $subPath, '..' ) === false ? $this->streamRawFile( $subPath ) : false; } private function streamRawFile( string $subPath ): bool { $fullPath = "{$this->repoPath}/$subPath"; return file_exists( $fullPath ) ? $this->streamIfPathValid( $fullPath ) : false; } private function streamIfPathValid( string $fullPath ): bool { $realPath = realpath( $fullPath ); $repoReal = realpath( $this->repoPath ); $isValid = $realPath && strpos( $realPath, $repoReal ) === 0; return $isValid ? readfile( $fullPath ) !== false : false; } public function eachRef( callable $callback ): void { $head = $this->resolve( 'HEAD' ); if( $head !== '' ) { $callback( 'HEAD', $head ); } $this->refs->scanRefs( 'refs/heads', function( $name, $sha ) use ( $callback ) { $callback( "refs/heads/$name", $sha ); } ); $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) { $callback( "refs/tags/$name", $sha ); } ); } }