| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-15 22:06:36 GMT-0800 |
| Commit | 5b92e6a6e796551d97197c995d693b01a9d0889b |
| Parent | 90a5669 |
| private $repos = []; | ||
| private $git; | ||
| + private $basePath; | ||
| public function __construct( string $reposPath ) { | ||
| $this->git = new Git( $reposPath ); | ||
| $list = new RepositoryList( $reposPath ); | ||
| $list->eachRepository( function( $repo ) { | ||
| - $this->repos[] = $repo; | ||
| + $this->repos[$repo['safe_name']] = $repo; | ||
| } ); | ||
| } | ||
| public function route(): Page { | ||
| - $reqRepo = $_GET['repo'] ?? ''; | ||
| - $action = $_GET['action'] ?? 'file'; | ||
| - $hash = $this->sanitize( $_GET['hash'] ?? '' ); | ||
| - $subPath = ''; | ||
| $uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ); | ||
| - $scriptName = $_SERVER['SCRIPT_NAME']; | ||
| - if( strpos( $uri, $scriptName ) === 0 ) { | ||
| + $scriptName = dirname( $_SERVER['SCRIPT_NAME'] ); | ||
| + | ||
| + if( $scriptName !== '/' && strpos( $uri, $scriptName ) === 0 ) { | ||
| $uri = substr( $uri, strlen( $scriptName ) ); | ||
| } | ||
| - if( preg_match( '#^/([^/]+)\.git(?:/(.*))?$#', $uri, $matches ) ) { | ||
| - $reqRepo = urldecode( $matches[1] ); | ||
| - $subPath = isset( $matches[2] ) ? ltrim( $matches[2], '/' ) : ''; | ||
| - $action = 'clone'; | ||
| + $uri = trim( $uri, '/' ); | ||
| + | ||
| + if( empty( $uri ) ) { | ||
| + return new HomePage( $this->repos, $this->git ); | ||
| } | ||
| - $currRepo = null; | ||
| - $decoded = urldecode( $reqRepo ); | ||
| + $parts = explode( '/', $uri ); | ||
| + $repoName = array_shift( $parts ); | ||
| - foreach( $this->repos as $repo ) { | ||
| - if( $repo['safe_name'] === $reqRepo || $repo['name'] === $decoded ) { | ||
| - $currRepo = $repo; | ||
| + if( str_ends_with( $repoName, '.git' ) ) { | ||
| + $realName = substr( $repoName, 0, -4 ); | ||
| - break; | ||
| + if( isset( $this->repos[$realName] ) ) { | ||
| + $subPath = implode( '/', $parts ); | ||
| + $this->git->setRepository( $this->repos[$realName]['path'] ); | ||
| + | ||
| + return new ClonePage( $this->git, $subPath ); | ||
| } | ||
| + } | ||
| - $prefix = $repo['safe_name'] . '/'; | ||
| + if( !isset( $this->repos[$repoName] ) ) { | ||
| + return new HomePage( $this->repos, $this->git ); | ||
| + } | ||
| - if( strpos( $reqRepo, $prefix ) === 0 ) { | ||
| - $currRepo = $repo; | ||
| - $subPath = substr( $reqRepo, strlen( $prefix ) ); | ||
| - $action = 'clone'; | ||
| + $currRepo = $this->repos[$repoName]; | ||
| + $this->git->setRepository( $currRepo['path'] ); | ||
| - break; | ||
| - } | ||
| + $action = array_shift( $parts ); | ||
| + | ||
| + if( empty( $action ) ) { | ||
| + $action = 'tree'; | ||
| } | ||
| - if( $currRepo ) { | ||
| - $this->git->setRepository( $currRepo['path'] ); | ||
| + $hash = ''; | ||
| + $path = ''; | ||
| + | ||
| + if( in_array( $action, ['tree', 'blob', 'raw', 'commits'] ) ) { | ||
| + $hash = array_shift( $parts ) ?? 'HEAD'; | ||
| + $path = implode( '/', $parts ); | ||
| + } | ||
| + elseif( $action === 'commit' ) { | ||
| + $hash = array_shift( $parts ); | ||
| } | ||
| - $routes = [ | ||
| - 'home' => fn() => new HomePage( $this->repos, $this->git ), | ||
| - 'file' => fn() => new FilePage( $this->repos, $currRepo, $this->git, $hash ), | ||
| - 'raw' => fn() => new RawPage( $this->git, $hash ), | ||
| - 'commit' => fn() => new DiffPage( $this->repos, $currRepo, $this->git, $hash ), | ||
| - 'commits' => fn() => new CommitsPage( $this->repos, $currRepo, $this->git, $hash ), | ||
| - 'tags' => fn() => new TagsPage( $this->repos, $currRepo, $this->git ), | ||
| - 'clone' => fn() => new ClonePage( $this->git, $subPath ), | ||
| - ]; | ||
| + $_GET['repo'] = $repoName; | ||
| + $_GET['action'] = $action; | ||
| + $_GET['hash'] = $hash; | ||
| + $_GET['name'] = $path; | ||
| - $action = !$currRepo ? 'home' : $action; | ||
| + switch( $action ) { | ||
| + case 'tree': | ||
| + case 'blob': | ||
| + return new FilePage( $this->repos, $currRepo, $this->git, $hash, $path ); | ||
| - return ($routes[$action] ?? $routes['file'])(); | ||
| - } | ||
| + case 'raw': | ||
| + return new RawPage( $this->git, $hash ); | ||
| - private function sanitize( $path ) { | ||
| - $path = str_replace( [ '..', '\\', "\0" ], [ '', '/', '' ], $path ); | ||
| + case 'commits': | ||
| + return new CommitsPage( $this->repos, $currRepo, $this->git, $hash ); | ||
| - return preg_replace( '/[^a-zA-Z0-9_\-\.\/]/', '', $path ); | ||
| + case 'commit': | ||
| + return new DiffPage( $this->repos, $currRepo, $this->git, $hash ); | ||
| + | ||
| + case 'tags': | ||
| + return new TagsPage( $this->repos, $currRepo, $this->git ); | ||
| + | ||
| + default: | ||
| + return new FilePage( $this->repos, $currRepo, $this->git, 'HEAD', '' ); | ||
| + } | ||
| } | ||
| } |
| public function build() { | ||
| if( $this->switcher ) { | ||
| - $url = "window.location.href='?repo=' + encodeURIComponent(" . | ||
| - $this->switcher . ")"; | ||
| - } else { | ||
| - $params = []; | ||
| + return "window.location.href='/' + " . $this->switcher; | ||
| + } | ||
| - if( $this->repo ) { | ||
| - $params['repo'] = $this->repo; | ||
| - } | ||
| + if( !$this->repo ) { | ||
| + return '/'; | ||
| + } | ||
| - if( $this->action ) { | ||
| - $params['action'] = $this->action; | ||
| - } | ||
| + $url = '/' . $this->repo; | ||
| - if( $this->hash ) { | ||
| - $params['hash'] = $this->hash; | ||
| - } | ||
| + if( !$this->action && $this->name ) { | ||
| + $this->action = 'tree'; | ||
| + } | ||
| - if( $this->name ) { | ||
| - $params['name'] = $this->name; | ||
| + if( $this->action ) { | ||
| + $url .= '/' . $this->action; | ||
| + | ||
| + if( $this->hash ) { | ||
| + $url .= '/' . $this->hash; | ||
| + } elseif( in_array( $this->action, ['tree', 'blob', 'raw', 'commits'] ) ) { | ||
| + $url .= '/HEAD'; | ||
| } | ||
| + } | ||
| - $url = empty( $params ) ? '?' : '?' . http_build_query( $params ); | ||
| + if( $this->name ) { | ||
| + $url .= '/' . ltrim( $this->name, '/' ); | ||
| } | ||
| } | ||
| - 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 ); | ||
| - } | ||
| - } | ||
| + 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( '/^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 ) { | ||
| + // Inline parsing for speed | ||
| + $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 | ||
| + ]; | ||
| + } | ||
| + | ||
| + // --- Tree Processing --- | ||
| private function walkTree( string $sha, callable $callback ): void { |
| ($this->title ? ' - ' . htmlspecialchars( $this->title ) : ''); | ||
| ?></title> | ||
| - <link rel="stylesheet" | ||
| - href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css"> | ||
| - <link rel="stylesheet" href="repo.css"> | ||
| + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | ||
| + <link rel="stylesheet" href="/repo.css"> | ||
| </head> | ||
| <body> | ||
| <div class="container"> | ||
| <header> | ||
| <h1><?php echo Config::SITE_TITLE; ?></h1> | ||
| <nav class="nav"> | ||
| <a href="<?php echo (new UrlBuilder())->build(); ?>">Home</a> | ||
| <?php if( $currentRepo ): | ||
| $safeName = $currentRepo['safe_name']; | ||
| - $repoUrl = (new UrlBuilder())->withRepo( $safeName ); | ||
| ?> | ||
| - <a href="<?php echo $repoUrl->build(); ?>">Files</a> | ||
| + <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tree' )->build(); ?>">Files</a> | ||
| <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'commits' )->build(); ?>">Commits</a> | ||
| - <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'refs' )->build(); ?>">Branches</a> | ||
| <a href="<?php echo (new UrlBuilder())->withRepo( $safeName )->withAction( 'tags' )->build(); ?>">Tags</a> | ||
| <?php endif; ?> | ||
| <div class="repo-info-banner"> | ||
| <span class="current-repo"> | ||
| - Current: <strong><?php | ||
| - echo htmlspecialchars( $currentRepo['name'] ); | ||
| - ?></strong> | ||
| + Current: <strong><?php echo htmlspecialchars( $currentRepo['name'] ); ?></strong> | ||
| </span> | ||
| </div> | ||
| private $git; | ||
| private $hash; | ||
| + private $path; | ||
| public function __construct( | ||
| array $repositories, | ||
| array $currentRepo, | ||
| Git $git, | ||
| - string $hash = '' | ||
| + string $hash = 'HEAD', | ||
| + string $path = '' | ||
| ) { | ||
| parent::__construct( $repositories ); | ||
| $this->currentRepo = $currentRepo; | ||
| $this->git = $git; | ||
| - $this->hash = $hash; | ||
| + $this->hash = $hash ?: 'HEAD'; | ||
| + $this->path = $path; | ||
| $this->title = $currentRepo['name']; | ||
| } | ||
| echo '<div class="empty-state"><h3>No branches</h3></div>'; | ||
| } else { | ||
| - $target = $this->hash ?: $main['hash']; | ||
| + $target = $this->hash; | ||
| $entries = []; | ||
| $this->git->walk( $target, function( $file ) use ( &$entries ) { | ||
| $entries[] = $file; | ||
| - } ); | ||
| + }, $this->path ); | ||
| - if( !empty( $entries ) ) { | ||
| + if( !empty( $entries ) && !$this->isExactFileMatch( $entries ) ) { | ||
| $this->renderTree( $main, $target, $entries ); | ||
| } else { | ||
| $this->renderBlob( $target ); | ||
| } | ||
| } | ||
| }, $this->currentRepo ); | ||
| } | ||
| - private function renderTree( $main, $targetHash, $entries ) { | ||
| - $path = $_GET['name'] ?? ''; | ||
| + private function isExactFileMatch($entries) { | ||
| + return count( $entries ) === 1 && | ||
| + $entries[0]->name === basename( $this->path ) && | ||
| + !$entries[0]->isDir; | ||
| + } | ||
| - $this->emitBreadcrumbs( $targetHash, 'Tree', $path ); | ||
| + private function renderTree( $main, $targetHash, $entries ) { | ||
| + $this->emitBreadcrumbs( $targetHash, 'Tree', $this->path ); | ||
| echo '<h2>' . htmlspecialchars( $this->currentRepo['name'] ) . | ||
| ' <span class="branch-badge">' . | ||
| - htmlspecialchars( $main['name'] ) . '</span></h2>'; | ||
| + htmlspecialchars( $targetHash ) . '</span></h2>'; | ||
| usort( $entries, function( $a, $b ) { | ||
| return $a->compare( $b ); | ||
| } ); | ||
| echo '<table class="file-list-table">'; | ||
| - echo '<thead>'; | ||
| - echo '<tr>'; | ||
| - echo '<th></th>'; | ||
| - echo '<th>Name</th>'; | ||
| - echo '<th class="file-mode-cell">Mode</th>'; | ||
| - echo '<th class="file-size-cell">Size</th>'; | ||
| - echo '</tr>'; | ||
| - echo '</thead>'; | ||
| + echo '<thead><tr><th></th><th>Name</th><th class="file-mode-cell">Mode</th><th class="file-size-cell">Size</th></tr></thead>'; | ||
| echo '<tbody>'; | ||
| - $currentPath = $this->hash ? $path : ''; | ||
| $renderer = new HtmlFileRenderer( | ||
| - $this->currentRepo['safe_name'], $currentPath | ||
| + $this->currentRepo['safe_name'], | ||
| + $this->path, | ||
| + $targetHash | ||
| ); | ||
| foreach( $entries as $file ) { | ||
| $file->renderListEntry( $renderer ); | ||
| } | ||
| - echo '</tbody>'; | ||
| - echo '</table>'; | ||
| + echo '</tbody></table>'; | ||
| } | ||
| private function renderBlob( $targetHash ) { | ||
| - $filename = $_GET['name'] ?? ''; | ||
| + $filename = $this->path; | ||
| $file = $this->git->readFile( $targetHash, $filename ); | ||
| - $size = $this->git->getObjectSize( $targetHash ); | ||
| - $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'] ); | ||
| - | ||
| - $this->emitBreadcrumbs( $targetHash, 'File', $filename ); | ||
| + $size = $this->git->getObjectSize( $targetHash, $filename ); | ||
| - if( $size === 0 ) { | ||
| - $this->renderDownloadState( $targetHash, "This file is empty." ); | ||
| - } else { | ||
| - $rawUrl = (new UrlBuilder()) | ||
| - ->withRepo( $this->currentRepo['safe_name'] ) | ||
| - ->withAction( 'raw' ) | ||
| - ->withHash( $targetHash ) | ||
| - ->withName( $filename ) | ||
| - ->build(); | ||
| + $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], dirname($filename), $targetHash ); | ||
| - if( !$file->renderMedia( $renderer, $rawUrl ) ) { | ||
| - if( $file->isText() ) { | ||
| - if( $size > self::MAX_DISPLAY_SIZE ) { | ||
| - ob_start(); | ||
| - $file->renderSize( $renderer ); | ||
| - $sizeStr = ob_get_clean(); | ||
| + $this->emitBreadcrumbs( $targetHash, 'File', $filename ); | ||
| - $this->renderDownloadState( | ||
| - $targetHash, | ||
| - "File is too large to display ($sizeStr)." | ||
| - ); | ||
| - } else { | ||
| - $content = ''; | ||
| + if( $size === 0 && !$file ) { | ||
| + echo '<div class="empty-state">File not found.</div>'; | ||
| + return; | ||
| + } | ||
| - $this->git->stream( $targetHash, function( $d ) use ( &$content ) { | ||
| - $content .= $d; | ||
| - } ); | ||
| + $rawUrl = (new UrlBuilder()) | ||
| + ->withRepo( $this->currentRepo['safe_name'] ) | ||
| + ->withAction( 'raw' ) | ||
| + ->withHash( $targetHash ) | ||
| + ->withName( $filename ) | ||
| + ->build(); | ||
| - if( $size > self::MAX_HIGHLIGHT_SIZE ) { | ||
| - echo '<div class="blob-content"><pre class="blob-code">' . | ||
| - htmlspecialchars( $content ) . '</pre></div>'; | ||
| - } else { | ||
| - echo '<div class="blob-content"><pre class="blob-code">' . | ||
| - $file->highlight( $renderer, $content ) . '</pre></div>'; | ||
| - } | ||
| - } | ||
| + if( !$file->renderMedia( $renderer, $rawUrl ) ) { | ||
| + if( $file->isText() ) { | ||
| + if( $size > self::MAX_DISPLAY_SIZE ) { | ||
| + ob_start(); | ||
| + $file->renderSize( $renderer ); | ||
| + $sizeStr = ob_get_clean(); | ||
| + $this->renderDownloadState( $targetHash, "File is too large to display ($sizeStr)." ); | ||
| } else { | ||
| - $this->renderDownloadState( $targetHash, "This is a binary file." ); | ||
| + $content = ''; | ||
| + $this->git->stream( $targetHash, function( $d ) use ( &$content ) { | ||
| + $content .= $d; | ||
| + }, $filename ); | ||
| + | ||
| + echo '<div class="blob-content"><pre class="blob-code">' . | ||
| + ($size > self::MAX_HIGHLIGHT_SIZE | ||
| + ? htmlspecialchars( $content ) | ||
| + : $file->highlight( $renderer, $content )) . | ||
| + '</pre></div>'; | ||
| } | ||
| + } else { | ||
| + $this->renderDownloadState( $targetHash, "This is a binary file." ); | ||
| } | ||
| } | ||
| } | ||
| private function renderDownloadState( $hash, $reason ) { | ||
| - $filename = $_GET['name'] ?? ''; | ||
| $url = (new UrlBuilder()) | ||
| ->withRepo( $this->currentRepo['safe_name'] ) | ||
| ->withAction( 'raw' ) | ||
| ->withHash( $hash ) | ||
| - ->withName( $filename ) | ||
| + ->withName( $this->path ) | ||
| ->build(); | ||
| $url = (new UrlBuilder()) | ||
| ->withRepo( $this->currentRepo['safe_name'] ) | ||
| + ->withAction( 'tree' ) | ||
| + ->withHash( $hash ) | ||
| ->withName( $acc ) | ||
| ->build(); | ||
| - $trail[] = '<a href="' . $url . '">' . | ||
| - htmlspecialchars( $part ) . '</a>'; | ||
| + $trail[] = '<a href="' . $url . '">' . htmlspecialchars( $part ) . '</a>'; | ||
| } | ||
| } | ||
| - } elseif( $this->hash ) { | ||
| + } elseif( $hash ) { | ||
| $trail[] = $type . ' ' . substr( $hash, 0, 7 ); | ||
| } | ||
| private string $repoSafeName; | ||
| private string $currentPath; | ||
| + private string $currentRef; | ||
| - public function __construct( string $repoSafeName, string $currentPath = '' ) { | ||
| + public function __construct( string $repoSafeName, string $currentPath = '', string $currentRef = 'HEAD' ) { | ||
| $this->repoSafeName = $repoSafeName; | ||
| $this->currentPath = trim( $currentPath, '/' ); | ||
| + $this->currentRef = $currentRef; | ||
| } | ||
| int $size | ||
| ): void { | ||
| - $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') . | ||
| - $name; | ||
| + $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') . $name; | ||
| - // 2. Refactor: Use UrlBuilder instead of manual string concatenation | ||
| + $isDir = ($mode === '40000' || $mode === '040000'); | ||
| + $action = $isDir ? 'tree' : 'blob'; | ||
| + | ||
| $url = (new UrlBuilder()) | ||
| ->withRepo( $this->repoSafeName ) | ||
| - ->withHash( $sha ) | ||
| + ->withAction( $action ) | ||
| + ->withHash( $this->currentRef ) | ||
| ->withName( $fullPath ) | ||
| ->build(); | ||
| echo '<tr>'; | ||
| - echo '<td class="file-icon-cell">'; | ||
| - echo '<i class="fas ' . $iconClass . '"></i>'; | ||
| - echo '</td>'; | ||
| - echo '<td class="file-name-cell">'; | ||
| - echo '<a href="' . $url . '">' . htmlspecialchars( $name ) . '</a>'; | ||
| - echo '</td>'; | ||
| - echo '<td class="file-mode-cell">'; | ||
| - echo $this->formatMode( $mode ); | ||
| - echo '</td>'; | ||
| - echo '<td class="file-size-cell">'; | ||
| - | ||
| - if( $size > 0 ) { | ||
| - echo $this->formatSize( $size ); | ||
| - } | ||
| - | ||
| - echo '</td>'; | ||
| + echo '<td class="file-icon-cell"><i class="fas ' . $iconClass . '"></i></td>'; | ||
| + echo '<td class="file-name-cell"><a href="' . $url . '">' . htmlspecialchars( $name ) . '</a></td>'; | ||
| + echo '<td class="file-mode-cell">' . $this->formatMode( $mode ) . '</td>'; | ||
| + echo '<td class="file-size-cell">' . ($size > 0 ? $this->formatSize( $size ) : '') . '</td>'; | ||
| echo '</tr>'; | ||
| } | ||
| - | ||
| - public function renderMedia( | ||
| - File $file, | ||
| - string $url, | ||
| - string $mediaType | ||
| - ): bool { | ||
| - $rendered = false; | ||
| + public function renderMedia( File $file, string $url, string $mediaType ): bool { | ||
| if( $file->isImage() ) { | ||
| - echo '<div class="blob-content blob-content-image">' . | ||
| - '<img src="' . $url . '"></div>'; | ||
| - $rendered = true; | ||
| + echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>'; | ||
| + return true; | ||
| } elseif( $file->isVideo() ) { | ||
| - echo '<div class="blob-content blob-content-video">' . | ||
| - '<video controls><source src="' . $url . '" type="' . | ||
| - $mediaType . '"></video></div>'; | ||
| - $rendered = true; | ||
| + echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $mediaType . '"></video></div>'; | ||
| + return true; | ||
| } elseif( $file->isAudio() ) { | ||
| - echo '<div class="blob-content blob-content-audio">' . | ||
| - '<audio controls><source src="' . $url . '" type="' . | ||
| - $mediaType . '"></audio></div>'; | ||
| - $rendered = true; | ||
| + echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $mediaType . '"></audio></div>'; | ||
| + return true; | ||
| } | ||
| - | ||
| - return $rendered; | ||
| + return false; | ||
| } | ||
| public function renderSize( int $bytes ): void { | ||
| echo $this->formatSize( $bytes ); | ||
| } | ||
| - public function highlight( | ||
| - string $filename, | ||
| - string $content, | ||
| - string $mediaType | ||
| - ): string { | ||
| + public function highlight( string $filename, string $content, string $mediaType ): string { | ||
| return (new Highlighter($filename, $content, $mediaType))->render(); | ||
| } | ||
| public function renderTime( int $timestamp ): void { | ||
| $tokens = [ | ||
| - 31536000 => 'year', | ||
| - 2592000 => 'month', | ||
| - 604800 => 'week', | ||
| - 86400 => 'day', | ||
| - 3600 => 'hour', | ||
| - 60 => 'minute', | ||
| - 1 => 'second' | ||
| + 31536000 => 'year', 2592000 => 'month', 604800 => 'week', | ||
| + 86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second' | ||
| ]; | ||
| - | ||
| $diff = $timestamp ? time() - $timestamp : null; | ||
| $result = 'never'; | ||
| if( $diff && $diff >= 5 ) { | ||
| foreach( $tokens as $unit => $text ) { | ||
| - if( $diff < $unit ) { | ||
| - continue; | ||
| - } | ||
| - | ||
| + if( $diff < $unit ) continue; | ||
| $num = floor( $diff / $unit ); | ||
| $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago'; | ||
| while( $bytes >= 1024 && $i < count( $units ) - 1 ) { | ||
| - $bytes /= 1024; | ||
| - $i++; | ||
| + $bytes /= 1024; $i++; | ||
| } | ||
| } | ||
| } | ||
| - | ||
| Delta | 548 lines added, 463 lines removed, 85-line increase |
|---|