Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
<?php
require_once __DIR__ . '/../File.php';
require_once __DIR__ . '/../Tag.php';
require_once __DIR__ . '/GitRefs.php';
require_once __DIR__ . '/GitPacks.php';

class Git {
  private const MAX_READ_SIZE = 1048576;

  private string $repoPath;
  private string $objectsPath;

  private GitRefs $refs;
  private GitPacks $packs;

  public function __construct( string $repoPath ) {
    $this->setRepository( $repoPath );
  }

  public function setRepository( string $repoPath ): void {
    $this->repoPath    = rtrim( $repoPath, '/' );
    $this->objectsPath = $this->repoPath . '/objects';

    $this->refs  = new GitRefs( $this->repoPath );
    $this->packs = new GitPacks( $this->objectsPath );
  }

  public function resolve( string $reference ): string {
    return $this->refs->resolve( $reference );
  }

  public function getMainBranch(): array {
    return $this->refs->getMainBranch();
  }

  public function eachBranch( callable $callback ): void {
    $this->refs->scanRefs( 'refs/heads', $callback );
  }

  public function eachTag( callable $callback ): void {
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
      $callback
    ) {
      $data = $this->read( $sha );
      $tag  = $this->parseTagData( $name, $sha, $data );

      $callback( $tag );
    } );
  }

  private function parseTagData(
    string $name,
    string $sha,
    string $data
  ): Tag {
    $isAnnotated = strncmp( $data, 'object ', 7 ) === 0;

    $targetSha = $isAnnotated
      ? $this->extractPattern(
          $data,
          '/^object ([0-9a-f]{40})$/m',
          1,
          $sha
        )
      : $sha;

    $pattern = $isAnnotated
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';

    $identity = $this->parseIdentity( $data, $pattern );
    $message  = $this->extractMessage( $data );

    return new Tag(
      $name,
      $sha,
      $targetSha,
      $identity['timestamp'],
      $message,
      $identity['name']
    );
  }

  private function extractPattern(
    string $data,
    string $pattern,
    int $group,
    string $default = ''
  ): string {
    $matches = [];

    $result = preg_match( $pattern, $data, $matches )
      ? $matches[$group]
      : $default;

    return $result;
  }

  private function parseIdentity( string $data, string $pattern ): array {
    $matches = [];
    $found   = preg_match( $pattern, $data, $matches );

    return [
      'name'      => $found ? trim( $matches[1] ) : 'Unknown',
      'email'     => $found ? $matches[2] : '',
      'timestamp' => $found ? (int)$matches[3] : 0
    ];
  }

  private function extractMessage( string $data ): string {
    $pos = strpos( $data, "\n\n" );

    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
  }

  public function getObjectSize( string $sha ): int {
    $size = $this->packs->getSize( $sha );

    return $size !== null ? $size : $this->getLooseObjectSize( $sha );
  }

  public function peek( string $sha, int $length = 255 ): string {
    $size = $this->packs->getSize( $sha );

    return $size === null
      ? $this->peekLooseObject( $sha, $length )
      : $this->packs->peek( $sha, $length ) ?? '';
  }

  public function read( string $sha ): string {
    $size = $this->getObjectSize( $sha );

    if( $size > self::MAX_READ_SIZE ) {
      return '';
    }

    $content = '';

    $this->slurp( $sha, function( $chunk ) use ( &$content ) {
      $content .= $chunk;
    } );

    return $content;
  }

  public function readFile( string $hash, string $name ) {
    return new File(
      $name,
      $hash,
      '100644',
      0,
      $this->getObjectSize( $hash ),
      $this->peek( $hash )
    );
  }

  public function stream( string $sha, callable $callback ): void {
    $this->slurp( $sha, $callback );
  }

  private function slurp( string $sha, callable $callback ): void {
    $loosePath = $this->getLoosePath( $sha );

    if( is_file( $loosePath ) ) {
      $this->slurpLooseObject( $loosePath, $callback );
    } else {
      $this->slurpPackedObject( $sha, $callback );
    }
  }

  private function iterateInflated( string $path, callable $processor ): void {
    $this->withInflatedFile(
      $path,
      function( $fileHandle, $inflator ) use ( $processor ) {
        $headerFound = false;
        $buffer      = '';

        while( !feof( $fileHandle ) ) {
          $chunk    = fread( $fileHandle, 16384 );
          $inflated = inflate_add( $inflator, $chunk );

          if( $inflated === false ) {
            break;
          }

          if( !$headerFound ) {
            $buffer .= $inflated;
            $nullPos = strpos( $buffer, "\0" );

            if( $nullPos !== false ) {
              $headerFound = true;
              $header      = substr( $buffer, 0, $nullPos );
              $body        = substr( $buffer, $nullPos + 1 );

              if( $processor( $body, $header ) === false ) {
                return;
              }
            }
          } else {
            if( $processor( $inflated, null ) === false ) {
              return;
            }
          }
        }
      }
    );
  }

  private function slurpLooseObject(
    string $path,
    callable $callback
  ): void {
    $this->iterateInflated(
      $path,
      function( $chunk ) use ( $callback ) {
        if( $chunk !== '' ) {
          $callback( $chunk );
        }
        return true;
      }
    );
  }

  private function withInflatedFile( string $path, callable $callback ): void {
    $fileHandle = fopen( $path, 'rb' );
    $inflator   = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;

    if( $fileHandle && $inflator ) {
      $callback( $fileHandle, $inflator );
      fclose( $fileHandle );
    }
  }

  private function slurpPackedObject(
    string $sha,
    callable $callback
  ): void {
    $streamed = $this->packs->stream( $sha, $callback );

    if( !$streamed ) {
      $data = $this->packs->read( $sha );

      if( $data !== null && $data !== '' ) {
        $callback( $data );
      }
    }
  }

  private function peekLooseObject( string $sha, int $length ): string {
    $path = $this->getLoosePath( $sha );

    return is_file( $path )
      ? $this->inflateLooseObjectPrefix( $path, $length )
      : '';
  }

  private function inflateLooseObjectPrefix(
    string $path,
    int $length
  ): string {
    $buffer = '';

    $this->iterateInflated(
      $path,
      function( $chunk ) use ( $length, &$buffer ) {
        $buffer .= $chunk;
        return strlen( $buffer ) < $length;
      }
    );

    return substr( $buffer, 0, $length );
  }

  public function history( string $ref, int $limit, callable $callback ): void {
    $currentSha = $this->resolve( $ref );
    $count      = 0;

    while( $currentSha !== '' && $count < $limit ) {
      $commit = $this->parseCommit( $currentSha );

      if( $commit === null ) {
        break;
      }

      $callback( $commit );
      $currentSha = $commit->parentSha;
      $count++;
    }
  }

  private function parseCommit( string $sha ): ?object {
    $data = $this->read( $sha );

    return $data === '' ? null : $this->buildCommitObject( $sha, $data );
  }

  private function buildCommitObject( string $sha, string $data ): object {
    $identity  = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' );
    $message   = $this->extractMessage( $data );
    $parentSha = $this->extractPattern(
      $data,
      '/^parent ([0-9a-f]{40})$/m',
      1
    );

    return (object)[
      'sha'       => $sha,
      'message'   => $message,
      'author'    => $identity['name'],
      'email'     => $identity['email'],
      'date'      => $identity['timestamp'],
      'parentSha' => $parentSha
    ];
  }

  public function walk( string $refOrSha, callable $callback ): void {
    $sha = $this->resolve( $refOrSha );

    if( $sha !== '' ) {
      $this->walkTree( $sha, $callback );
    }
  }

  private function walkTree( string $sha, callable $callback ): void {
    $data     = $this->read( $sha );
    $treeData = $data !== '' && preg_match(
      '/^tree ([0-9a-f]{40})$/m',
      $data,
      $matches
    ) ? $this->read( $matches[1] ) : $data;

    if( $treeData !== '' && $this->isTreeData( $treeData ) ) {
      $this->processTree( $treeData, $callback );
    }
  }

  private function processTree( string $data, callable $callback ): void {
    $position = 0;
    $length   = strlen( $data );

    while( $position < $length ) {
      $result = $this->parseTreeEntry( $data, $position, $length );

      if( $result === null ) {
        break;
      }

      $callback( $result['file'] );
      $position = $result['nextPosition'];
    }
  }

  private function parseTreeEntry(
    string $data,
    int $position,
    int $length
  ): ?array {
    $spacePos = strpos( $data, ' ', $position );
    $nullPos  = strpos( $data, "\0", $spacePos );

    $hasValidPositions =
      $spacePos !== false &&
      $nullPos !== false &&
      $nullPos + 21 <= $length;

    return $hasValidPositions
      ? $this->buildTreeEntryResult( $data, $position, $spacePos, $nullPos )
      : null;
  }

  private function buildTreeEntryResult(
    string $data,
    int $position,
    int $spacePos,
    int $nullPos
  ): array {
    $mode = substr( $data, $position, $spacePos - $position );
    $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
    $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );

    $isDirectory = $mode === '40000' || $mode === '040000';
    $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
    $contents    = $isDirectory ? '' : $this->peek( $sha );

    $file = new File( $name, $sha, $mode, 0, $size, $contents );

    return [
      'file'         => $file,
      'nextPosition' => $nullPos + 21
    ];
  }

  private function isTreeData( string $data ): bool {
    $pattern        = '/^(40000|100644|100755|120000|160000) /';
    $minLength      = strlen( $data ) >= 25;
    $matchesPattern = $minLength && preg_match( $pattern, $data );
    $nullPos        = $matchesPattern ? strpos( $data, "\0" ) : false;

    return $matchesPattern &&
      $nullPos !== false &&
      $nullPos + 21 <= strlen( $data );
  }

  private function getLoosePath( string $sha ): string {
    return "{$this->objectsPath}/" .
      substr( $sha, 0, 2 ) . "/" .
      substr( $sha, 2 );
  }

  private function getLooseObjectSize( string $sha ): int {
    $path = $this->getLoosePath( $sha );

    return is_file( $path ) ? $this->readLooseObjectHeader( $path ) : 0;
  }

  private function readLooseObjectHeader( string $path ): int {
    $size = 0;

    $this->iterateInflated(
      $path,
      function( $chunk, $header ) use ( &$size ) {
        if( $header !== null ) {
          $parts = explode( ' ', $header );
          $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
        }
        return false;
      }
    );

    return $size;
  }

  public function streamRaw( string $subPath ): bool {
    return strpos( $subPath, '..' ) === false
      ? $this->streamRawFile( $subPath )
      : false;
  }

  private function streamRawFile( string $subPath ): bool {
    $fullPath = "{$this->repoPath}/$subPath";

    return file_exists( $fullPath )
      ? $this->streamIfPathValid( $fullPath )
      : false;
  }

  private function streamIfPathValid( string $fullPath ): bool {
    $realPath = realpath( $fullPath );
    $repoReal = realpath( $this->repoPath );
    $isValid  = $realPath && strpos( $realPath, $repoReal ) === 0;

    return $isValid ? readfile( $fullPath ) !== false : false;
  }

  public function eachRef( callable $callback ): void {
    $head = $this->resolve( 'HEAD' );

    if( $head !== '' ) {
      $callback( 'HEAD', $head );
    }

    $this->refs->scanRefs(
      'refs/heads',
      function( $name, $sha ) use ( $callback ) {
        $callback( "refs/heads/$name", $sha );
      }
    );

    $this->refs->scanRefs(
      'refs/tags',
      function( $name, $sha ) use ( $callback ) {
        $callback( "refs/tags/$name", $sha );
      }
    );
  }
}