<?php require_once __DIR__ . '/../model/File.php'; require_once __DIR__ . '/../model/Tag.php'; require_once __DIR__ . '/../model/Commit.php'; require_once __DIR__ . '/GitRefs.php'; require_once __DIR__ . '/GitPacks.php'; require_once __DIR__ . '/LooseObjects.php'; require_once __DIR__ . '/PackfileWriter.php'; class Git { private const MAX_READ = 1048576; private string $repoPath; private GitRefs $refs; private GitPacks $packs; private LooseObjects $loose; private PackfileWriter $packWriter; public function __construct( string $repoPath ) { $this->setRepository( $repoPath ); } public function setRepository( string $repoPath ): void { $this->repoPath = \rtrim( $repoPath, '/' ); $objPath = $this->repoPath . '/objects'; $this->refs = new GitRefs( $this->repoPath ); $this->packs = new GitPacks( $objPath ); $this->loose = new LooseObjects( $objPath ); $this->packWriter = new PackfileWriter( $this->packs, $this->loose ); } 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 ) { $callback( new Tag( $name, $sha, $this->read( $sha ) ) ); } ); } public function walk( string $refOrSha, callable $callback, string $path = '' ): void { $sha = $this->resolve( $refOrSha ); $treeSha = $sha !== '' ? $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 ) : []; return isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ? new File( \basename( $path ), $info['sha'], $info['mode'], 0, $this->getObjectSize( $info['sha'] ), $this->peek( $info['sha'] ) ) : new MissingFile(); } public function getObjectSize( string $sha, string $path = '' ): int { $target = $sha; if( $path !== '' ) { $target = $this->resolvePath( $this->getTreeSha( $this->resolve( $sha ) ), $path )['sha'] ?? ''; } return $target !== '' ? $this->packs->getSize( $target ) ?: $this->loose->getSize( $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 ); } } public function peek( string $sha, int $length = 255 ): string { return $this->packs->getSize( $sha ) > 0 ? $this->packs->peek( $sha, $length ) : $this->loose->peek( $sha, $length ); } public function read( string $sha ): string { $size = $this->getObjectSize( $sha ); $content = ''; if( $size > 0 && $size <= self::MAX_READ ) { $this->slurp( $sha, function( $chunk ) use ( &$content ) { $content .= $chunk; } ); } return $content; } public function history( string $ref, int $limit, callable $callback ): void { $this->traverseHistory( $this->resolve( $ref ), $limit, $callback, 0 ); } private function traverseHistory( string $sha, int $limit, callable $callback, int $count ): void { $data = $sha !== '' && $count < $limit ? $this->read( $sha ) : ''; if( $data !== '' ) { $commit = new Commit( $sha, $data ); if( $callback( $commit ) !== false ) { $commit->provideParent( function( $parent ) use ( $limit, $callback, $count ): void { $this->traverseHistory( $parent, $limit, $callback, $count + 1 ); } ); } } } public function streamRaw( string $subPath ): bool { return \strpos( $subPath, '..' ) === false && \is_file( "{$this->repoPath}/$subPath" ) && \realpath( "{$this->repoPath}/$subPath" ) !== false && \strpos( \realpath( "{$this->repoPath}/$subPath" ), \realpath( $this->repoPath ) ) === 0 ? $this->sendHeaders( "{$this->repoPath}/$subPath" ) : false; } private function sendHeaders( string $path ): bool { \header( 'X-Accel-Redirect: ' . $path ); \header( 'Content-Type: application/octet-stream' ); return true; } 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 generatePackfile( array $objs ): Generator { yield from $this->packWriter->generate( $objs ); } public function collectObjects( array $wants, array $haves = [] ): array { $objs = $this->traverseObjects( $wants ); if( !empty( $haves ) ) { foreach( $this->traverseObjects( $haves ) as $sha => $type ) { unset( $objs[$sha] ); } } return $objs; } public function parseTreeData( string $data, callable $callback ): void { $pos = 0; $len = \strlen( $data ); while( $pos < $len ) { $space = \strpos( $data, ' ', $pos ); $eos = \strpos( $data, "\0", $space ); if( $space === false || $eos === false || $eos + 21 > $len ) { break; } if( $callback( \substr( $data, $space + 1, $eos - $space - 1 ), \bin2hex( \substr( $data, $eos + 1, 20 ) ), \substr( $data, $pos, $space - $pos ) ) === false ) { break; } $pos = $eos + 21; } } private function slurp( string $sha, callable $callback ): void { if( !$this->loose->stream( $sha, $callback ) && !$this->packs->stream( $sha, $callback ) ) { $data = $this->packs->read( $sha ); if( $data !== '' ) { $callback( $data ); } } } 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->parseTreeData( $tree, function( $n, $s, $m ) use ( $callback ) { $dir = $m === '40000' || $m === '040000'; $isSub = $m === '160000'; $callback( new File( $n, $s, $m, 0, $dir || $isSub ? 0 : $this->getObjectSize( $s ), $dir || $isSub ? '' : $this->peek( $s ) ) ); } ); } } private function isTreeData( string $data ): bool { $len = \strlen( $data ); $match = $len >= 25 && \preg_match( '/^(40000|100644|100755|120000|160000) /', $data ); return $match && \strpos( $data, "\0" ) !== false && \strpos( $data, "\0" ) + 21 <= $len; } private function getTreeSha( string $commitOrTreeSha ): string { $data = $this->read( $commitOrTreeSha ); return \preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ? $this->getTreeSha( $matches[1] ) : ( \preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ? $matches[1] : $commitOrTreeSha ); } 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 { $entry = [ 'sha' => '', 'mode' => '' ]; $this->parseTreeData( $this->read( $treeSha ), function( $n, $s, $m ) use ( $name, &$entry ) { if( $n === $name ) { $entry = [ 'sha' => $s, 'mode' => $m ]; return false; } } ); return $entry; } private function traverseObjects( array $roots ): array { $objs = []; $queue = []; foreach( $roots as $sha ) { $queue[] = [ 'sha' => $sha, 'type' => 0 ]; } while( !empty( $queue ) ) { $item = \array_pop( $queue ); $sha = $item['sha']; $type = $item['type']; if( !isset( $objs[$sha] ) ) { $data = $type !== 3 ? $this->read( $sha ) : ''; $type = $type === 0 ? $this->getObjectType( $data ) : $type; $objs[$sha] = $type; if( $type === 1 ) { if( \preg_match( '/^tree ([0-9a-f]{40})/m', $data, $m ) ) { $queue[] = [ 'sha' => $m[1], 'type' => 2 ]; } if( \preg_match_all( '/^parent ([0-9a-f]{40})/m', $data, $m ) ) { foreach( $m[1] as $parentSha ) { $queue[] = [ 'sha' => $parentSha, 'type' => 1 ]; } } } elseif( $type === 2 ) { $this->parseTreeData( $data, function( $n, $s, $m ) use ( &$queue ) { if( $m !== '160000' ) { $queue[] = [ 'sha' => $s, 'type' => $m === '40000' || $m === '040000' ? 2 : 3 ]; } } ); } elseif( $type === 4 ) { if( \preg_match( '/^object ([0-9a-f]{40})/m', $data, $m ) ) { $queue[] = [ 'sha' => $m[1], 'type' => \preg_match( '/^type (commit|tree|blob|tag)/m', $data, $t ) ? ([ 'commit' => 1, 'tree' => 2, 'blob' => 3, 'tag' => 4 ][$t[1]] ?? 1) : 1 ]; } } } } return $objs; } private function getObjectType( string $data ): int { return \strpos( $data, "tree " ) === 0 ? 1 : (\strpos( $data, "object " ) === 0 ? 4 : ($this->isTreeData( $data ) ? 2 : 3)); } } class MissingFile extends File { public function __construct() { parent::__construct( '', '', '0', 0, 0, '' ); } public function emitRawHeaders(): void { \header( "HTTP/1.1 404 Not Found" ); exit; } }