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 = 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;
  }
}