Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
<?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;
  }
}