| | $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 ); |
| | - } ); |
| | - } |
| | - |
| | - public function walk( string $refOrSha, callable $callback, string $path = '' ): void { |
| | - $sha = $this->resolve( $refOrSha ); |
| | - |
| | - if( $sha === '' ) return; |
| | - |
| | - $treeSha = $this->getTreeSha( $sha ); |
| | - |
| | - if( $path !== '' ) { |
| | - $info = $this->resolvePath( $treeSha, $path ); |
| | - |
| | - if( !$info || !$info['isDir'] ) { |
| | - return; |
| | - } |
| | - |
| | - $treeSha = $info['sha']; |
| | - } |
| | - |
| | - if( $treeSha ) { |
| | - $this->walkTree( $treeSha, $callback ); |
| | - } |
| | - } |
| | - |
| | - public function readFile( string $ref, string $path ): ?File { |
| | - $sha = $this->resolve( $ref ); |
| | - if( $sha === '' ) return null; |
| | - |
| | - $treeSha = $this->getTreeSha( $sha ); |
| | - $info = $this->resolvePath( $treeSha, $path ); |
| | - |
| | - if( !$info ) { |
| | - return null; |
| | - } |
| | - |
| | - $size = $this->getObjectSize( $info['sha'] ); |
| | - $content = $info['isDir'] ? '' : $this->peek( $info['sha'] ); |
| | - |
| | - return new File( |
| | - basename( $path ), |
| | - $info['sha'], |
| | - $info['mode'], |
| | - 0, |
| | - $size, |
| | - $content |
| | - ); |
| | - } |
| | - |
| | - public function getObjectSize( string $sha, string $path = '' ): int { |
| | - if( $path !== '' ) { |
| | - $rootSha = $this->resolve( $sha ); |
| | - $treeSha = $this->getTreeSha( $rootSha ); |
| | - $info = $this->resolvePath( $treeSha, $path ); |
| | - $sha = $info ? $info['sha'] : ''; |
| | - } |
| | - |
| | - if( $sha === '' ) return 0; |
| | - |
| | - $size = $this->packs->getSize( $sha ); |
| | - return $size !== null ? $size : $this->getLooseObjectSize( $sha ); |
| | - } |
| | - |
| | - public function stream( string $sha, callable $callback, string $path = '' ): void { |
| | - if( $path !== '' ) { |
| | - $rootSha = $this->resolve( $sha ); |
| | - $treeSha = $this->getTreeSha( $rootSha ); |
| | - $info = $this->resolvePath( $treeSha, $path ); |
| | - $sha = $info ? $info['sha'] : ''; |
| | - } |
| | - |
| | - if( $sha !== '' ) { |
| | - $this->slurp( $sha, $callback ); |
| | - } |
| | - } |
| | - |
| | - private function getTreeSha( string $commitOrTreeSha ): string { |
| | - $data = $this->read( $commitOrTreeSha ); |
| | - |
| | - if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | - return $this->getTreeSha( $matches[1] ); |
| | - } |
| | - |
| | - if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | - return $matches[1]; |
| | - } |
| | - |
| | - return $commitOrTreeSha; |
| | - } |
| | - |
| | - private function resolvePath( string $treeSha, string $path ): ?array { |
| | - $parts = explode( '/', trim( $path, '/' ) ); |
| | - $currentSha = $treeSha; |
| | - $currentMode = '40000'; |
| | - |
| | - foreach( $parts as $part ) { |
| | - if( $part === '' ) continue; |
| | - |
| | - $entry = $this->findTreeEntry( $currentSha, $part ); |
| | - |
| | - if( !$entry ) { |
| | - return null; |
| | - } |
| | - |
| | - $currentSha = $entry['sha']; |
| | - $currentMode = $entry['mode']; |
| | - } |
| | - |
| | - $isDir = $currentMode === '40000' || $currentMode === '040000'; |
| | - |
| | - return [ |
| | - 'sha' => $currentSha, |
| | - 'mode' => $currentMode, |
| | - 'isDir' => $isDir |
| | - ]; |
| | - } |
| | - |
| | - private function findTreeEntry( string $treeSha, string $name ): ?array { |
| | - $data = $this->read( $treeSha ); |
| | - $position = 0; |
| | - $length = strlen( $data ); |
| | - |
| | - while( $position < $length ) { |
| | - $spacePos = strpos( $data, ' ', $position ); |
| | - $nullPos = strpos( $data, "\0", $spacePos ); |
| | - |
| | - if( $spacePos === false || $nullPos === false ) break; |
| | - |
| | - $entryName = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 ); |
| | - |
| | - if( $entryName === $name ) { |
| | - $mode = substr( $data, $position, $spacePos - $position ); |
| | - $sha = bin2hex( substr( $data, $nullPos + 1, 20 ) ); |
| | - return ['sha' => $sha, 'mode' => $mode]; |
| | - } |
| | - |
| | - $position = $nullPos + 21; |
| | - } |
| | - |
| | - return null; |
| | - } |
| | - |
| | - 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 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; |
| | - } |
| | - |
| | - 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 |
| | - ]; |
| | - } |
| | - |
| | - 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 is_file( $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 ); |
| | - } |
| | - ); |
| | - } |
| | - |
| | - public function collectObjects( array $wants, array $haves = [] ): array { |
| | - $objects = []; |
| | - $visited = []; |
| | - |
| | - foreach( $wants as $sha ) { |
| | - $this->collectObjectsRecursive( $sha, $objects, $visited ); |
| | - } |
| | - |
| | - foreach( $haves as $sha ) { |
| | - if( isset( $objects[$sha] ) ) { |
| | - unset( $objects[$sha] ); |
| | - } |
| | - } |
| | - |
| | - return $objects; |
| | - } |
| | - |
| | - private function collectObjectsRecursive( |
| | - string $sha, |
| | - array &$objects, |
| | - array &$visited |
| | - ): void { |
| | - if( isset( $visited[$sha] ) ) return; |
| | - $visited[$sha] = true; |
| | - |
| | - $data = $this->read( $sha ); |
| | - $type = $this->getObjectType( $data ); |
| | - $objects[$sha] = ['type' => $type, 'size' => strlen( $data )]; |
| | - |
| | - if( $type === 1 ) { // Commit |
| | - if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | - $this->collectObjectsRecursive( $matches[1], $objects, $visited ); |
| | - } |
| | - if( preg_match( '/^parent ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | - $this->collectObjectsRecursive( $matches[1], $objects, $visited ); |
| | - } |
| | - } elseif( $type === 2 ) { // Tree |
| | - $position = 0; |
| | - $length = strlen( $data ); |
| | - while( $position < $length ) { |
| | - $spacePos = strpos( $data, ' ', $position ); |
| | - $nullPos = strpos( $data, "\0", $spacePos ); |
| | - if( $spacePos === false || $nullPos === false ) break; |
| | - |
| | - $entrySha = bin2hex( substr( $data, $nullPos + 1, 20 ) ); |
| | - $this->collectObjectsRecursive( $entrySha, $objects, $visited ); |
| | - $position = $nullPos + 21; |
| | - } |
| | - } elseif( $type === 4 ) { // Tag |
| | - if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | - $this->collectObjectsRecursive( $matches[1], $objects, $visited ); |
| | - } |
| | - } |
| | - } |
| | - |
| | - private function getObjectType( string $data ): int { |
| | - if( strpos( $data, "tree " ) === 0 ) { |
| | - return 1; |
| | - } |
| | - |
| | - if( $this->isTreeData( $data ) ) { |
| | - return 2; |
| | - } |
| | - |
| | - if( strpos( $data, "object " ) === 0 ) { |
| | - return 4; |
| | - } |
| | - |
| | - return 3; |
| | - } |
| | - |
| | - public function generatePackfile( array $objects ): string { |
| | - if( empty( $objects ) ) { |
| | - $pack = "PACK" . pack( 'N', 2 ) . pack( 'N', 0 ); |
| | - return $pack . hash( 'sha1', $pack, true ); |
| | - } |
| | - |
| | - $packObjects = ''; |
| | - |
| | - foreach( $objects as $sha => $info ) { |
| | - $content = $this->read( $sha ); |
| | - $type = $info['type']; |
| | - $size = strlen( $content ); |
| | - $byte = ($type << 4) | ($size & 0x0f); |
| | - $size >>= 4; |
| | - |
| | - while( $size > 0 ) { |
| | - $packObjects .= chr( $byte | 0x80 ); |
| | - $byte = $size & 0x7f; |
| | - $size >>= 7; |
| | - } |
| | - |
| | - $packObjects .= chr( $byte ); |
| | - $packObjects .= gzcompress( $content ); |
| | - } |
| | - |
| | - $objectCount = count( $objects ); |
| | - $header = "PACK" . pack( 'N', 2 ) . pack( 'N', $objectCount ); |
| | - $packData = $header . $packObjects; |
| | - |
| | - $checksum = hash( 'sha1', $packData, true ); |
| | - return $packData . $checksum; |
| | + $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 ); |
| | + } ); |
| | + } |
| | + |
| | + public function walk( |
| | + string $refOrSha, |
| | + callable $callback, |
| | + string $path = '' |
| | + ): void { |
| | + $sha = $this->resolve( $refOrSha ); |
| | + $treeSha = ''; |
| | + |
| | + if( $sha !== '' ) { |
| | + $treeSha = $this->getTreeSha( $sha ); |
| | + } |
| | + |
| | + if( $path !== '' && $treeSha !== '' ) { |
| | + $info = $this->resolvePath( $treeSha, $path ); |
| | + $treeSha = $info['isDir'] ? $info['sha'] : ''; |
| | + } |
| | + |
| | + if( $treeSha !== '' ) { |
| | + $this->walkTree( $treeSha, $callback ); |
| | + } |
| | + } |
| | + |
| | + public function readFile( string $ref, string $path ): File { |
| | + $sha = $this->resolve( $ref ); |
| | + $tree = $sha !== '' ? $this->getTreeSha( $sha ) : ''; |
| | + $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : []; |
| | + $file = new MissingFile(); |
| | + |
| | + if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) { |
| | + $file = new File( |
| | + basename( $path ), |
| | + $info['sha'], |
| | + $info['mode'], |
| | + 0, |
| | + $this->getObjectSize( $info['sha'] ), |
| | + $this->peek( $info['sha'] ) |
| | + ); |
| | + } |
| | + |
| | + return $file; |
| | + } |
| | + |
| | + public function getObjectSize( string $sha, string $path = '' ): int { |
| | + $target = $sha; |
| | + |
| | + if( $path !== '' ) { |
| | + $info = $this->resolvePath( |
| | + $this->getTreeSha( $this->resolve( $sha ) ), |
| | + $path |
| | + ); |
| | + $target = $info['sha'] ?? ''; |
| | + } |
| | + |
| | + return $target !== '' |
| | + ? $this->packs->getSize( $target ) ?? $this->getLooseObjectSize( $target ) |
| | + : 0; |
| | + } |
| | + |
| | + public function stream( |
| | + string $sha, |
| | + callable $callback, |
| | + string $path = '' |
| | + ): void { |
| | + $target = $sha; |
| | + |
| | + if( $path !== '' ) { |
| | + $info = $this->resolvePath( |
| | + $this->getTreeSha( $this->resolve( $sha ) ), |
| | + $path |
| | + ); |
| | + $target = isset( $info['isDir'] ) && !$info['isDir'] |
| | + ? $info['sha'] |
| | + : ''; |
| | + } |
| | + |
| | + if( $target !== '' ) { |
| | + $this->slurp( $target, $callback ); |
| | + } |
| | + } |
| | + |
| | + private function getTreeSha( string $commitOrTreeSha ): string { |
| | + $data = $this->read( $commitOrTreeSha ); |
| | + $sha = $commitOrTreeSha; |
| | + |
| | + if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | + $sha = $this->getTreeSha( $matches[1] ); |
| | + } |
| | + |
| | + if( $sha === $commitOrTreeSha && |
| | + preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) { |
| | + $sha = $matches[1]; |
| | + } |
| | + |
| | + return $sha; |
| | + } |
| | + |
| | + private function resolvePath( string $treeSha, string $path ): array { |
| | + $parts = explode( '/', trim( $path, '/' ) ); |
| | + $sha = $treeSha; |
| | + $mode = '40000'; |
| | + |
| | + foreach( $parts as $part ) { |
| | + $entry = $part !== '' && $sha !== '' |
| | + ? $this->findTreeEntry( $sha, $part ) |
| | + : [ 'sha' => '', 'mode' => '' ]; |
| | + |
| | + $sha = $entry['sha']; |
| | + $mode = $entry['mode']; |
| | + } |
| | + |
| | + return [ |
| | + 'sha' => $sha, |
| | + 'mode' => $mode, |
| | + 'isDir' => $mode === '40000' || $mode === '040000' |
| | + ]; |
| | + } |
| | + |
| | + private function findTreeEntry( string $treeSha, string $name ): array { |
| | + $data = $this->read( $treeSha ); |
| | + $pos = 0; |
| | + $len = strlen( $data ); |
| | + $entry = [ 'sha' => '', 'mode' => '' ]; |
| | + |
| | + while( $pos < $len ) { |
| | + $space = strpos( $data, ' ', $pos ); |
| | + $eos = strpos( $data, "\0", $space ); |
| | + |
| | + if( $space === false || $eos === false ) { |
| | + break; |
| | + } |
| | + |
| | + if( substr( $data, $space + 1, $eos - $space - 1 ) === $name ) { |
| | + $entry = [ |
| | + 'sha' => bin2hex( substr( $data, $eos + 1, 20 ) ), |
| | + 'mode' => substr( $data, $pos, $space - $pos ) |
| | + ]; |
| | + break; |
| | + } |
| | + |
| | + $pos = $eos + 21; |
| | + } |
| | + |
| | + return $entry; |
| | + } |
| | + |
| | + private function parseTagData( |
| | + string $name, |
| | + string $sha, |
| | + string $data |
| | + ): Tag { |
| | + $isAnn = strncmp( $data, 'object ', 7 ) === 0; |
| | + $pattern = $isAnn |
| | + ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m' |
| | + : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m'; |
| | + $id = $this->parseIdentity( $data, $pattern ); |
| | + $target = $isAnn |
| | + ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha ) |
| | + : $sha; |
| | + |
| | + return new Tag( |
| | + $name, |
| | + $sha, |
| | + $target, |
| | + $id['timestamp'], |
| | + $this->extractMessage( $data ), |
| | + $id['name'] |
| | + ); |
| | + } |
| | + |
| | + private function extractPattern( |
| | + string $data, |
| | + string $pattern, |
| | + int $group, |
| | + string $default = '' |
| | + ): string { |
| | + return preg_match( $pattern, $data, $matches ) |
| | + ? $matches[$group] |
| | + : $default; |
| | + } |
| | + |
| | + private function parseIdentity( string $data, string $pattern ): array { |
| | + $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 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 ); |
| | + $content = ''; |
| | + |
| | + if( $size > 0 && $size <= self::MAX_READ_SIZE ) { |
| | + $this->slurp( $sha, function( $chunk ) use ( &$content ) { |
| | + $content .= $chunk; |
| | + } ); |
| | + } |
| | + |
| | + return $content; |
| | + } |
| | + |
| | + private function slurp( string $sha, callable $callback ): void { |
| | + $path = $this->getLoosePath( $sha ); |
| | + |
| | + if( is_file($path) ) { |
| | + $this->slurpLooseObject( $path, $callback ); |
| | + } |
| | + |
| | + if( !is_file($path) ) { |
| | + $this->slurpPackedObject( $sha, $callback ); |
| | + } |
| | + } |
| | + |
| | + private function iterateInflated( string $path, callable $processor ): void { |
| | + $this->withInflatedFile( |
| | + $path, |
| | + function( $handle, $inflator ) use ( $processor ) { |
| | + $found = false; |
| | + $buffer = ''; |
| | + |
| | + while( !feof($handle) ) { |
| | + $inflated = inflate_add( $inflator, fread( $handle, 16384 ) ); |
| | + |
| | + if( $inflated === false ) { |
| | + break; |
| | + } |
| | + |
| | + if( !$found ) { |
| | + $buffer .= $inflated; |
| | + $eos = strpos( $buffer, "\0" ); |
| | + |
| | + if( $eos !== false ) { |
| | + $found = true; |
| | + $body = substr( $buffer, $eos + 1 ); |
| | + $head = substr( $buffer, 0, $eos ); |
| | + |
| | + if( $processor( $body, $head ) === false ) { |
| | + break; |
| | + } |
| | + } |
| | + } |
| | + |
| | + if( $found ) { |
| | + if( $processor( $inflated, null ) === false ) { |
| | + break; |
| | + } |
| | + } |
| | + } |
| | + } |
| | + ); |
| | + } |
| | + |
| | + 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 { |
| | + $handle = fopen( $path, 'rb' ); |
| | + $infl = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; |
| | + |
| | + if( $handle && $infl ) { |
| | + $callback( $handle, $infl ); |
| | + fclose( $handle ); |
| | + } |
| | + } |
| | + |
| | + 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 { |
| | + $buf = ''; |
| | + |
| | + $this->iterateInflated( |
| | + $path, |
| | + function( $chunk ) use ( $length, &$buf ) { |
| | + $buf .= $chunk; |
| | + |
| | + return strlen($buf) < $length; |
| | + } |
| | + ); |
| | + |
| | + return substr( $buf, 0, $length ); |
| | + } |
| | + |
| | + public function history( string $ref, int $limit, callable $callback ): void { |
| | + $sha = $this->resolve( $ref ); |
| | + $count = 0; |
| | + |
| | + while( $sha !== '' && $count < $limit ) { |
| | + $commit = $this->parseCommit( $sha ); |
| | + |
| | + if( $commit->sha === '' ) { |
| | + $sha = ''; |
| | + } |
| | + |
| | + if( $sha !== '' ) { |
| | + $callback( $commit ); |
| | + $sha = $commit->parentSha; |
| | + $count++; |
| | + } |
| | + } |
| | + } |
| | + |
| | + private function parseCommit( string $sha ): object { |
| | + $data = $this->read( $sha ); |
| | + |
| | + return $data !== '' |
| | + ? $this->buildCommitObject( $sha, $data ) |
| | + : (object)[ 'sha' => '' ]; |
| | + } |
| | + |
| | + private function buildCommitObject( string $sha, string $data ): object { |
| | + $id = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' ); |
| | + |
| | + return (object)[ |
| | + 'sha' => $sha, |
| | + 'message' => $this->extractMessage( $data ), |
| | + 'author' => $id['name'], |
| | + 'email' => $id['email'], |
| | + 'date' => $id['timestamp'], |
| | + 'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 ) |
| | + ]; |
| | + } |
| | + |
| | + private function walkTree( string $sha, callable $callback ): void { |
| | + $data = $this->read( $sha ); |
| | + $tree = $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) |
| | + ? $this->read($m[1]) |
| | + : $data; |
| | + |
| | + if( $tree !== '' && $this->isTreeData($tree) ) { |
| | + $this->processTree( $tree, $callback ); |
| | + } |
| | + } |
| | + |
| | + private function processTree( string $data, callable $callback ): void { |
| | + $pos = 0; |
| | + $len = strlen( $data ); |
| | + |
| | + while( $pos < $len ) { |
| | + $entry = $this->parseTreeEntry( $data, $pos, $len ); |
| | + |
| | + if( $entry === null ) { |
| | + break; |
| | + } |
| | + |
| | + $callback( $entry['file'] ); |
| | + $pos = $entry['nextPosition']; |
| | + } |
| | + } |
| | + |
| | + private function parseTreeEntry( |
| | + string $data, |
| | + int $pos, |
| | + int $len |
| | + ): ?array { |
| | + $space = strpos( $data, ' ', $pos ); |
| | + $eos = strpos( $data, "\0", $space ); |
| | + |
| | + return $space !== false && $eos !== false && $eos + 21 <= $len |
| | + ? $this->buildTreeEntryResult( $data, $pos, $space, $eos ) |
| | + : null; |
| | + } |
| | + |
| | + private function buildTreeEntryResult( |
| | + string $data, |
| | + int $pos, |
| | + int $space, |
| | + int $eos |
| | + ): array { |
| | + $mode = substr( $data, $pos, $space - $pos ); |
| | + $sha = bin2hex( substr( $data, $eos + 1, 20 ) ); |
| | + $isD = $mode === '40000' || $mode === '040000'; |
| | + |
| | + return [ |
| | + 'file' => new File( |
| | + substr( $data, $space + 1, $eos - $space - 1 ), |
| | + $sha, |
| | + $mode, |
| | + 0, |
| | + $isD ? 0 : $this->getObjectSize( $sha ), |
| | + $isD ? '' : $this->peek( $sha ) |
| | + ), |
| | + 'nextPosition' => $eos + 21 |
| | + ]; |
| | + } |
| | + |
| | + private function isTreeData( string $data ): bool { |
| | + $len = strlen( $data ); |
| | + $patt = '/^(40000|100644|100755|120000|160000) /'; |
| | + $match = $len >= 25 && preg_match( $patt, $data ); |
| | + $eos = $match ? strpos( $data, "\0" ) : false; |
| | + |
| | + return $match && $eos !== false && $eos + 21 <= $len; |
| | + } |
| | + |
| | + 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 { |
| | + $path = "{$this->repoPath}/$subPath"; |
| | + |
| | + return is_file($path) ? $this->streamIfPathValid($path) : false; |
| | + } |
| | + |
| | + private function streamIfPathValid( string $fullPath ): bool { |
| | + $real = realpath( $fullPath ); |
| | + $repo = realpath( $this->repoPath ); |
| | + $isValid = $real && strpos($real, $repo) === 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( $n, $s ) use ( $callback ) { |
| | + $callback( "refs/heads/$n", $s ); |
| | + } ); |
| | + |
| | + $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) { |
| | + $callback( "refs/tags/$n", $s ); |
| | + } ); |
| | + } |
| | + |
| | + public function collectObjects( array $wants, array $haves = [] ): array { |
| | + $objs = []; |
| | + $seen = []; |
| | + |
| | + foreach( $wants as $sha ) { |
| | + $this->collectObjectsRecursive( $sha, $objs, $seen ); |
| | + } |
| | + |
| | + foreach( $haves as $sha ) { |
| | + unset($objs[$sha]); |
| | + } |
| | + |
| | + return $objs; |
| | + } |
| | + |
| | + private function collectObjectsRecursive( |
| | + string $sha, |
| | + array &$objs, |
| | + array &$seen |
| | + ): void { |
| | + if( !isset( $seen[$sha] ) ) { |
| | + $seen[$sha] = true; |
| | + $data = $this->read( $sha ); |
| | + $type = $this->getObjectType( $data ); |
| | + $objs[$sha] = [ 'type' => $type, 'size' => strlen($data) ]; |
| | + |
| | + if( $type === 1 ) { |
| | + $this->collectCommitLinks( $data, $objs, $seen ); |
| | + } |
| | + |
| | + if( $type === 2 ) { |
| | + $this->collectTreeLinks( $data, $objs, $seen ); |
| | + } |
| | + |
| | + if( $type === 4 && preg_match( '/^object (.*)$/m', $data, $m ) ) { |
| | + $this->collectObjectsRecursive( $m[1], $objs, $seen ); |
| | + } |
| | + } |
| | + } |
| | + |
| | + private function collectCommitLinks( $data, &$objs, &$seen ): void { |
| | + if( preg_match( '/^tree (.*)$/m', $data, $m ) ) { |
| | + $this->collectObjectsRecursive( $m[1], $objs, $seen ); |
| | + } |
| | + |
| | + if( preg_match( '/^parent (.*)$/m', $data, $m ) ) { |
| | + $this->collectObjectsRecursive( $m[1], $objs, $seen ); |
| | + } |
| | + } |
| | + |
| | + private function collectTreeLinks( $data, &$objs, &$seen ): void { |
| | + $pos = 0; |
| | + $len = strlen( $data ); |
| | + |
| | + while( $pos < $len ) { |
| | + $space = strpos( $data, ' ', $pos ); |
| | + $eos = strpos( $data, "\0", $space ); |
| | + |
| | + if( $space === false || $eos === false ) { |
| | + break; |
| | + } |
| | + |
| | + $sha = bin2hex( substr( $data, $eos + 1, 20 ) ); |
| | + $this->collectObjectsRecursive( $sha, $objs, $seen ); |
| | + $pos = $eos + 21; |
| | + } |
| | + } |
| | + |
| | + private function getObjectType( string $data ): int { |
| | + $isTree = strpos($data, "tree ") === 0; |
| | + $isObj = strpos($data, "object ") === 0; |
| | + |
| | + return $isTree |
| | + ? 1 |
| | + : ( $this->isTreeData($data) ? 2 : ( $isObj ? 4 : 3 ) ); |
| | + } |
| | + |
| | + public function generatePackfile( array $objs ): string { |
| | + $pData = ''; |
| | + |
| | + if( empty($objs) ) { |
| | + $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', 0 ); |
| | + } |
| | + |
| | + if( !empty($objs) ) { |
| | + $data = ''; |
| | + |
| | + foreach( $objs as $sha => $info ) { |
| | + $cont = $this->read( $sha ); |
| | + $size = strlen($cont); |
| | + $byte = $info['type'] << 4 | $size & 0x0f; |
| | + $size >>= 4; |
| | + |
| | + while( $size > 0 ) { |
| | + $data .= chr( $byte | 0x80 ); |
| | + $byte = $size & 0x7f; |
| | + $size >>= 7; |
| | + } |
| | + |
| | + $data .= chr( $byte ) . gzcompress( $cont ); |
| | + } |
| | + |
| | + $pData = "PACK" . pack( 'N', 2 ) . pack( 'N', count($objs) ) . $data; |
| | + } |
| | + |
| | + return $pData . hash( 'sha1', $pData, true ); |
| | + } |
| | +} |
| | + |
| | +class MissingFile extends File { |
| | + public function __construct() { |
| | + parent::__construct( '', '', '0', 0, 0, '' ); |
| | + } |
| | + |
| | + public function emitRawHeaders(): void { |
| | + header( "HTTP/1.1 404 Not Found" ); |
| | + exit; |
| | } |
| | } |