<?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 = 1048576; private string $repoPath; private string $objPath; 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->objPath = $this->repoPath . '/objects'; $this->refs = new GitRefs( $this->repoPath ); $this->packs = new GitPacks( $this->objPath ); } 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; $result = 0; if( $path !== '' ) { $info = $this->resolvePath( $this->getTreeSha( $this->resolve( $sha ) ), $path ); $target = $info['sha'] ?? ''; } if( $target !== '' ) { $result = $this->packs->getSize( $target ); if( $result === 0 ) { $result = $this->getLooseObjectSize( $target ); } } return $result; } 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 { $size = $this->packs->getSize( $sha ); return $size === 0 ? $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 ) { $this->slurp( $sha, function( $chunk ) use ( &$content ) { $content .= $chunk; } ); } return $content; } 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++; } } } public function streamRaw( string $subPath ): bool { $result = false; if( strpos( $subPath, '..' ) === false ) { $path = "{$this->repoPath}/$subPath"; if( is_file( $path ) ) { $real = realpath( $path ); $repo = realpath( $this->repoPath ); if( $real && strpos( $real, $repo ) === 0 ) { $result = $this->streamFileContent( $path ); } } } return $result; } private function streamFileContent( string $path ): bool { $result = false; if( $path !== '' ) { header( 'X-Accel-Redirect: ' . $path ); header( 'Content-Type: application/octet-stream' ); $result = true; } return $result; } 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 { $ctx = hash_init( 'sha1' ); $head = "PACK" . pack( 'N', 2 ) . pack( 'N', count( $objs ) ); hash_update( $ctx, $head ); yield $head; foreach( $objs as $sha => $type ) { $size = $this->getObjectSize( $sha ); $byte = $type << 4 | $size & 0x0f; $sz = $size >> 4; $hdr = ''; while( $sz > 0 ) { $hdr .= chr( $byte | 0x80 ); $byte = $sz & 0x7f; $sz >>= 7; } $hdr .= chr( $byte ); hash_update( $ctx, $hdr ); yield $hdr; $deflate = deflate_init( ZLIB_ENCODING_DEFLATE ); foreach( $this->slurpChunks( $sha ) as $raw ) { $compressed = deflate_add( $deflate, $raw, ZLIB_NO_FLUSH ); if( $compressed !== '' ) { hash_update( $ctx, $compressed ); yield $compressed; } } $final = deflate_add( $deflate, '', ZLIB_FINISH ); if( $final !== '' ) { hash_update( $ctx, $final ); yield $final; } } yield hash_final( $ctx, true ); } private function slurpChunks( string $sha ): Generator { $path = $this->getLoosePath( $sha ); if( is_file( $path ) ) { yield from $this->looseObjectChunks( $path ); } else { $any = false; foreach( $this->packs->streamGenerator( $sha ) as $chunk ) { $any = true; yield $chunk; } if( !$any ) { $data = $this->packs->read( $sha ); if( $data !== '' ) { yield $data; } } } } private function looseObjectChunks( string $path ): Generator { $handle = fopen( $path, 'rb' ); $infl = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; if( !$handle || !$infl ) { return; } $found = false; $buffer = ''; while( !feof( $handle ) ) { $chunk = fread( $handle, 16384 ); $inflated = inflate_add( $infl, $chunk ); if( $inflated === false ) { break; } if( !$found ) { $buffer .= $inflated; $eos = strpos( $buffer, "\0" ); if( $eos !== false ) { $found = true; $body = substr( $buffer, $eos + 1 ); if( $body !== '' ) { yield $body; } $buffer = ''; } } elseif( $inflated !== '' ) { yield $inflated; } } fclose( $handle ); } private function streamCompressedObject( string $sha, $ctx ): Generator { $deflate = deflate_init( ZLIB_ENCODING_DEFLATE ); $buffer = ''; $this->slurp( $sha, function( $chunk ) use ( $deflate, $ctx, &$buffer ) { $compressed = deflate_add( $deflate, $chunk, ZLIB_NO_FLUSH ); if( $compressed !== '' ) { hash_update( $ctx, $compressed ); $buffer .= $compressed; } } ); $final = deflate_add( $deflate, '', ZLIB_FINISH ); if( $final !== '' ) { hash_update( $ctx, $final ); $buffer .= $final; } $pos = 0; $len = strlen( $buffer ); while( $pos < $len ) { $chunk = substr( $buffer, $pos, 32768 ); yield $chunk; $pos += 32768; } } 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 = [ 'sha' => '', 'mode' => '' ]; if( $part !== '' && $sha !== '' ) { $entry = $this->findTreeEntry( $sha, $part ); } $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 ) ) : ''; } private function slurp( string $sha, callable $callback ): void { $path = $this->getLoosePath( $sha ); if( is_file( $path ) ) { $this->slurpLooseObject( $path, $callback ); } else { $this->slurpPackedObject( $sha, $callback ); } } private function slurpLooseObject( string $path, callable $callback ): void { $this->iterateInflated( $path, function( $chunk ) use ( $callback ) { if( $chunk !== '' ) { $callback( $chunk ); } return true; } ); } private function slurpPackedObject( string $sha, callable $callback ): void { $streamed = $this->packs->stream( $sha, $callback ); if( !$streamed ) { $data = $this->packs->read( $sha ); if( $data !== '' ) { $callback( $data ); } } } private function iterateInflated( string $path, callable $processor ): void { $handle = fopen( $path, 'rb' ); $infl = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null; $found = false; $buffer = ''; if( $handle && $infl ) { while( !feof( $handle ) ) { $chunk = fread( $handle, 16384 ); $inflated = inflate_add( $infl, $chunk ); 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; } } } elseif( $processor( $inflated, null ) === false ) { break; } } fclose( $handle ); } } private function peekLooseObject( string $sha, int $length ): string { $path = $this->getLoosePath( $sha ); $buf = ''; if( is_file( $path ) ) { $this->iterateInflated( $path, function( $chunk ) use ( $length, &$buf ) { $buf .= $chunk; return strlen( $buf ) < $length; } ); } return substr( $buf, 0, $length ); } private function parseCommit( string $sha ): object { $data = $this->read( $sha ); $result = (object)[ 'sha' => '' ]; if( $data !== '' ) { $id = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' ); $result = (object)[ 'sha' => $sha, 'message' => $this->extractMessage( $data ), 'author' => $id['name'], 'email' => $id['email'], 'date' => $id['timestamp'], 'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 ) ]; } return $result; } private function walkTree( string $sha, callable $callback ): void { $data = $this->read( $sha ); $tree = $data; if( $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) ) { $tree = $this->read( $m[1] ); } 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 ) { $space = strpos( $data, ' ', $pos ); $eos = strpos( $data, "\0", $space ); $entry = null; if( $space !== false && $eos !== false && $eos + 21 <= $len ) { $mode = substr( $data, $pos, $space - $pos ); $sha = bin2hex( substr( $data, $eos + 1, 20 ) ); $dir = $mode === '40000' || $mode === '040000'; $isSub = $mode === '160000'; $entry = [ 'file' => new File( substr( $data, $space + 1, $eos - $space - 1 ), $sha, $mode, 0, $dir || $isSub ? 0 : $this->getObjectSize( $sha ), $dir || $isSub ? '' : $this->peek( $sha ) ), 'nextPosition' => $eos + 21 ]; } if( $entry === null ) { break; } $callback( $entry['file'] ); $pos = $entry['nextPosition']; } } 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->objPath}/" . substr( $sha, 0, 2 ) . "/" . substr( $sha, 2 ); } private function getLooseObjectSize( string $sha ): int { $path = $this->getLoosePath( $sha ); $size = 0; if( is_file( $path ) ) { $this->iterateInflated( $path, function( $c, $head ) use ( &$size ) { if( $head !== null ) { $parts = explode( ' ', $head ); $size = isset( $parts[1] ) ? (int)$parts[1] : 0; } return false; } ); } return $size; } public function collectObjects( array $wants, array $haves = [] ): array { $objs = $this->traverseObjects( $wants ); $result = []; if( !empty( $haves ) ) { $haveObjs = $this->traverseObjects( $haves ); foreach( $haveObjs as $sha => $type ) { if( isset( $objs[$sha] ) ) { unset( $objs[$sha] ); } } } $result = $objs; return $result; } 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] ) ) { continue; } $data = ''; if( $type !== 3 ) { $data = $this->read( $sha ); if( $type === 0 ) { $type = $this->getObjectType( $data ); } } $objs[$sha] = $type; if( $type === 1 ) { $hasTree = preg_match( '/^tree ([0-9a-f]{40})/m', $data, $m ); if( $hasTree ) { $queue[] = [ 'sha' => $m[1], 'type' => 2 ]; } $hasParents = preg_match_all( '/^parent ([0-9a-f]{40})/m', $data, $m ); if( $hasParents ) { foreach( $m[1] as $parentSha ) { $queue[] = [ 'sha' => $parentSha, 'type' => 1 ]; } } } elseif( $type === 2 ) { $pos = 0; $len = strlen( $data ); while( $pos < $len ) { $space = strpos( $data, ' ', $pos ); $eos = strpos( $data, "\0", $space ); if( $space === false || $eos === false ) { break; } $mode = substr( $data, $pos, $space - $pos ); $hash = bin2hex( substr( $data, $eos + 1, 20 ) ); if( $mode !== '160000' ) { $isDir = $mode === '40000' || $mode === '040000'; $queue[] = [ 'sha' => $hash, 'type' => $isDir ? 2 : 3 ]; } $pos = $eos + 21; } } elseif( $type === 4 ) { $isTagTgt = preg_match( '/^object ([0-9a-f]{40})/m', $data, $m ); if( $isTagTgt ) { $nextType = 1; if( preg_match( '/^type (commit|tree|blob|tag)/m', $data, $t ) ) { $map = [ 'commit' => 1, 'tree' => 2, 'blob' => 3, 'tag' => 4 ]; $nextType = $map[$t[1]] ?? 1; } $queue[] = [ 'sha' => $m[1], 'type' => $nextType ]; } } } return $objs; } private function getObjectType( string $data ): int { $isTree = strpos( $data, "tree " ) === 0; $isObj = strpos( $data, "object " ) === 0; $result = 3; if( $isTree ) { $result = 1; } elseif( $isObj ) { $result = 4; } elseif( $this->isTreeData( $data ) ) { $result = 2; } return $result; } } class MissingFile extends File { public function __construct() { parent::__construct( '', '', '0', 0, 0, '' ); } public function emitRawHeaders(): void { header( "HTTP/1.1 404 Not Found" ); exit; } }