Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M Config.php
3434
}
3535
36
D File.php
1
<?php
2
require_once __DIR__ . '/render/FileRenderer.php';
3
4
class File {
5
  private const CAT_IMAGE   = 'image';
6
  private const CAT_VIDEO   = 'video';
7
  private const CAT_AUDIO   = 'audio';
8
  private const CAT_TEXT    = 'text';
9
  private const CAT_ARCHIVE = 'archive';
10
  private const CAT_BINARY  = 'binary';
11
12
  private const ICON_FOLDER  = 'fa-folder';
13
  private const ICON_PDF     = 'fa-file-pdf';
14
  private const ICON_ARCHIVE = 'fa-file-archive';
15
  private const ICON_IMAGE   = 'fa-file-image';
16
  private const ICON_AUDIO   = 'fa-file-audio';
17
  private const ICON_VIDEO   = 'fa-file-video';
18
  private const ICON_CODE    = 'fa-file-code';
19
  private const ICON_FILE    = 'fa-file';
20
21
  private const MODE_DIR       = '40000';
22
  private const MODE_DIR_LONG  = '040000';
23
24
  private const MEDIA_EMPTY    = 'application/x-empty';
25
  private const MEDIA_OCTET    = 'application/octet-stream';
26
  private const MEDIA_PDF      = 'application/pdf';
27
  private const MEDIA_TEXT     = 'text/';
28
  private const MEDIA_SVG      = 'image/svg';
29
  private const MEDIA_APP_TEXT = [
30
    'application/javascript',
31
    'application/json',
32
    'application/xml',
33
    'application/x-httpd-php',
34
    'application/x-sh'
35
  ];
36
37
  private const ARCHIVE_EXT = [
38
    'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab',
39
    'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj',
40
    'zoo', 'rpm', 'deb', 'apk'
41
  ];
42
43
  private string $name;
44
  private string $sha;
45
  private string $mode;
46
  private int $timestamp;
47
  private int $size;
48
  private bool $isDir;
49
  private string $mediaType;
50
  private string $category;
51
  private bool $binary;
52
53
  private static ?finfo $finfo = null;
54
55
  public function __construct(
56
    string $name,
57
    string $sha,
58
    string $mode,
59
    int $timestamp,
60
    int $size,
61
    string $contents = ''
62
  ) {
63
    $this->name      = $name;
64
    $this->sha       = $sha;
65
    $this->mode      = $mode;
66
    $this->timestamp = $timestamp;
67
    $this->size      = $size;
68
    $this->isDir     = $mode === self::MODE_DIR ||
69
                       $mode === self::MODE_DIR_LONG;
70
71
    $buffer          = $this->isDir ? '' : $contents;
72
    $this->mediaType = $this->detectMediaType( $buffer );
73
    $this->category  = $this->detectCategory( $name );
74
    $this->binary    = $this->detectBinary();
75
  }
76
77
  public function isEmpty(): bool {
78
    return $this->size === 0;
79
  }
80
81
  public function compare( File $other ): int {
82
    return $this->isDir !== $other->isDir
83
      ? ($this->isDir ? -1 : 1)
84
      : strcasecmp( $this->name, $other->name );
85
  }
86
87
  public function renderListEntry( FileRenderer $renderer ): void {
88
    $renderer->renderListEntry(
89
      $this->name,
90
      $this->sha,
91
      $this->mode,
92
      $this->resolveIcon(),
93
      $this->timestamp,
94
      $this->size
95
    );
96
  }
97
98
  public function emitRawHeaders(): void {
99
    header( "Content-Type: " . $this->mediaType );
100
    header( "Content-Length: " . $this->size );
101
    header( "Content-Disposition: attachment; filename=\"" .
102
      addslashes( basename( $this->name ) ) . "\"" );
103
  }
104
105
  public function renderMedia( FileRenderer $renderer, string $url ): bool {
106
    return $renderer->renderMedia( $this, $url, $this->mediaType );
107
  }
108
109
  public function renderSize( FileRenderer $renderer ): void {
110
    $renderer->renderSize( $this->size );
111
  }
112
113
  public function highlight(
114
    FileRenderer $renderer,
115
    string $content
116
  ): string {
117
    return $renderer->highlight( $this->name, $content, $this->mediaType );
118
  }
119
120
  public function isDir(): bool {
121
    return $this->isDir;
122
  }
123
124
  public function isImage(): bool {
125
    return $this->category === self::CAT_IMAGE;
126
  }
127
128
  public function isVideo(): bool {
129
    return $this->category === self::CAT_VIDEO;
130
  }
131
132
  public function isAudio(): bool {
133
    return $this->category === self::CAT_AUDIO;
134
  }
135
136
  public function isText(): bool {
137
    return $this->category === self::CAT_TEXT;
138
  }
139
140
  public function isBinary(): bool {
141
    return $this->binary;
142
  }
143
144
  public function isName( string $name ): bool {
145
    return $this->name === $name;
146
  }
147
148
  private function resolveIcon(): string {
149
    return $this->isDir
150
      ? self::ICON_FOLDER
151
      : (str_contains( $this->mediaType, self::MEDIA_PDF )
152
        ? self::ICON_PDF
153
        : match( $this->category ) {
154
          self::CAT_ARCHIVE => self::ICON_ARCHIVE,
155
          self::CAT_IMAGE   => self::ICON_IMAGE,
156
          self::CAT_AUDIO   => self::ICON_AUDIO,
157
          self::CAT_VIDEO   => self::ICON_VIDEO,
158
          self::CAT_TEXT    => self::ICON_CODE,
159
          default           => self::ICON_FILE,
160
        });
161
  }
162
163
  private static function fileinfo(): finfo {
164
    return self::$finfo ??= new finfo( FILEINFO_MIME_TYPE );
165
  }
166
167
  private function detectMediaType( string $buffer ): string {
168
    return $buffer === ''
169
      ? self::MEDIA_EMPTY
170
      : (self::fileinfo()->buffer( substr( $buffer, 0, 128 ) )
171
        ?: self::MEDIA_OCTET);
172
  }
173
174
  private function detectCategory( string $filename ): string {
175
    $main = explode( '/', $this->mediaType )[0];
176
    $main = $this->isArchive( $filename ) ||
177
            str_contains( $this->mediaType, 'compressed' )
178
      ? self::CAT_ARCHIVE
179
      : $main;
180
181
    $main = $main !== self::CAT_ARCHIVE &&
182
            $this->isMediaTypeText()
183
      ? 'text'
184
      : $main;
185
186
    return match( $main ) {
187
      'image'           => self::CAT_IMAGE,
188
      'video'           => self::CAT_VIDEO,
189
      'audio'           => self::CAT_AUDIO,
190
      'text'            => self::CAT_TEXT,
191
      self::CAT_ARCHIVE => self::CAT_ARCHIVE,
192
      default           => self::CAT_BINARY,
193
    };
194
  }
195
196
  private function detectBinary(): bool {
197
    return $this->mediaType !== self::MEDIA_EMPTY &&
198
           !$this->isMediaTypeText() &&
199
           !str_contains( $this->mediaType, self::MEDIA_SVG );
200
  }
201
202
  private function isMediaTypeText(): bool {
203
    return str_starts_with( $this->mediaType, self::MEDIA_TEXT ) ||
204
           in_array( $this->mediaType, self::MEDIA_APP_TEXT, true );
205
  }
206
207
  private function isArchive( string $filename ): bool {
208
    return in_array(
209
      strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ),
210
      self::ARCHIVE_EXT,
211
      true
212
    );
213
  }
214
}
2151
D RepositoryList.php
1
<?php
2
class RepositoryList {
3
  private const GIT_EXT       = '.git';
4
  private const ORDER_FILE    = '/order.txt';
5
  private const GLOB_PATTERN  = '/*';
6
  private const HIDDEN_PREFIX = '.';
7
  private const EXCLUDE_CHAR  = '-';
8
  private const SORT_MAX      = PHP_INT_MAX;
9
10
  private const KEY_SAFE_NAME = 'safe_name';
11
  private const KEY_EXCLUDE   = 'exclude';
12
  private const KEY_ORDER     = 'order';
13
  private const KEY_PATH      = 'path';
14
  private const KEY_NAME      = 'name';
15
16
  private string $reposPath;
17
18
  public function __construct( string $path ) {
19
    $this->reposPath = $path;
20
  }
21
22
  public function eachRepository( callable $callback ): void {
23
    $repos = $this->sortRepositories( $this->loadRepositories() );
24
25
    foreach( $repos as $repo ) {
26
      $callback( $repo );
27
    }
28
  }
29
30
  private function loadRepositories(): array {
31
    $repos = [];
32
    $path  = $this->reposPath . self::GLOB_PATTERN;
33
    $dirs  = glob( $path, GLOB_ONLYDIR );
34
35
    if( $dirs !== false ) {
36
      $repos = $this->processDirectories( $dirs );
37
    }
38
39
    return $repos;
40
  }
41
42
  private function processDirectories( array $dirs ): array {
43
    $repos = [];
44
45
    foreach( $dirs as $dir ) {
46
      $data = $this->createRepositoryData( $dir );
47
48
      if( $data !== [] ) {
49
        $repos[$data[self::KEY_NAME]] = $data;
50
      }
51
    }
52
53
    return $repos;
54
  }
55
56
  private function createRepositoryData( string $dir ): array {
57
    $data = [];
58
    $base = basename( $dir );
59
60
    if( $base[0] !== self::HIDDEN_PREFIX ) {
61
      $name = $this->extractName( $base );
62
      $data = [
63
        self::KEY_NAME      => $name,
64
        self::KEY_SAFE_NAME => $name,
65
        self::KEY_PATH      => $dir,
66
      ];
67
    }
68
69
    return $data;
70
  }
71
72
  private function extractName( string $base ): string {
73
    $name = $base;
74
75
    if( str_ends_with( $base, self::GIT_EXT ) ) {
76
      $len  = strlen( self::GIT_EXT );
77
      $name = substr( $base, 0, -$len );
78
    }
79
80
    return $name;
81
  }
82
83
  private function sortRepositories( array $repos ): array {
84
    $file = __DIR__ . self::ORDER_FILE;
85
86
    if( file_exists( $file ) ) {
87
      $repos = $this->applyCustomOrder( $repos, $file );
88
    } else {
89
      ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE );
90
    }
91
92
    return $repos;
93
  }
94
95
  private function applyCustomOrder( array $repos, string $file ): array {
96
    $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
97
98
    if( $lines !== false ) {
99
      $config = $this->parseOrderFile( $lines );
100
      $repos  = $this->filterExcluded( $repos, $config[self::KEY_EXCLUDE] );
101
      $repos  = $this->sortWithConfig( $repos, $config[self::KEY_ORDER] );
102
    }
103
104
    return $repos;
105
  }
106
107
  private function parseOrderFile( array $lines ): array {
108
    $order   = [];
109
    $exclude = [];
110
111
    foreach( $lines as $line ) {
112
      $trim = trim( $line );
113
114
      if( $trim !== '' ) {
115
        if( str_starts_with( $trim, self::EXCLUDE_CHAR ) ) {
116
          $exclude = $this->addExclusion( $exclude, $trim );
117
        } else {
118
          $order[$trim] = count( $order );
119
        }
120
      }
121
    }
122
123
    return [ self::KEY_ORDER => $order, self::KEY_EXCLUDE => $exclude ];
124
  }
125
126
  private function addExclusion( array $exclude, string $line ): array {
127
    $name           = substr( $line, 1 );
128
    $exclude[$name] = true;
129
130
    return $exclude;
131
  }
132
133
  private function filterExcluded( array $repos, array $exclude ): array {
134
    foreach( $repos as $key => $repo ) {
135
      if( isset( $exclude[$repo[self::KEY_SAFE_NAME]] ) ) {
136
        unset( $repos[$key] );
137
      }
138
    }
139
140
    return $repos;
141
  }
142
143
  private function sortWithConfig( array $repos, array $order ): array {
144
    uasort( $repos, function( array $repoA, array $repoB ) use( $order ): int {
145
      return $this->compareRepositories( $repoA, $repoB, $order );
146
    } );
147
148
    return $repos;
149
  }
150
151
  private function compareRepositories(
152
    array $repoA,
153
    array $repoB,
154
    array $order
155
  ): int {
156
    $safeA = $repoA[self::KEY_SAFE_NAME];
157
    $safeB = $repoB[self::KEY_SAFE_NAME];
158
    $posA  = $order[$safeA] ?? self::SORT_MAX;
159
    $posB  = $order[$safeB] ?? self::SORT_MAX;
160
161
    $result = $posA === $posB
162
      ? strcasecmp( $safeA, $safeB )
163
      : $posA <=> $posB;
164
165
    return $result;
166
  }
167
}
1681
D Router.php
1
<?php
2
require_once __DIR__ . '/RepositoryList.php';
3
require_once __DIR__ . '/git/Git.php';
4
require_once __DIR__ . '/pages/CommitsPage.php';
5
require_once __DIR__ . '/pages/DiffPage.php';
6
require_once __DIR__ . '/pages/HomePage.php';
7
require_once __DIR__ . '/pages/FilePage.php';
8
require_once __DIR__ . '/pages/RawPage.php';
9
require_once __DIR__ . '/pages/TagsPage.php';
10
require_once __DIR__ . '/pages/ClonePage.php';
11
require_once __DIR__ . '/pages/ComparePage.php';
12
13
class Router {
14
  private const ACTION_TREE    = 'tree';
15
  private const ACTION_BLOB    = 'blob';
16
  private const ACTION_RAW     = 'raw';
17
  private const ACTION_COMMITS = 'commits';
18
  private const ACTION_COMMIT  = 'commit';
19
  private const ACTION_COMPARE = 'compare';
20
  private const ACTION_TAGS    = 'tags';
21
22
  private const GET_REPOSITORY = 'repo';
23
  private const GET_ACTION     = 'action';
24
  private const GET_HASH       = 'hash';
25
  private const GET_NAME       = 'name';
26
27
  private const REFERENCE_HEAD = 'HEAD';
28
  private const ROUTE_REPO     = 'repo';
29
  private const EXTENSION_GIT  = '.git';
30
31
  private array $repos = [];
32
  private Git $git;
33
34
  private string $repoName   = '';
35
  private array $repoData    = [];
36
  private string $action     = '';
37
  private string $commitHash = '';
38
  private string $filePath   = '';
39
  private string $baseHash   = '';
40
41
  public function __construct( string $reposPath ) {
42
    $this->git = new Git( $reposPath );
43
    $list      = new RepositoryList( $reposPath );
44
45
    $list->eachRepository( function( $repo ) {
46
      $this->repos[$repo['safe_name']] = $repo;
47
    });
48
  }
49
50
  public function route(): Page {
51
    $this->normalizeQueryString();
52
    $uriParts = $this->parseUriParts();
53
    $repoName = !empty( $uriParts ) ? array_shift( $uriParts ) : '';
54
    $page     = new HomePage( $this->repos, $this->git );
55
56
    if( $repoName !== '' ) {
57
      if( str_ends_with( $repoName, self::EXTENSION_GIT ) ) {
58
        $page = $this->handleCloneRoute( $repoName, $uriParts );
59
      } elseif( isset( $this->repos[$repoName] ) ) {
60
        $page = $this->resolveActionRoute( $repoName, $uriParts );
61
      }
62
    }
63
64
    return $page;
65
  }
66
67
  private function handleCloneRoute(
68
    string $repoName,
69
    array $uriParts
70
  ): Page {
71
    $realName = substr( $repoName, 0, -4 );
72
    $path     = '';
73
74
    if( isset( $this->repos[$realName]['path'] ) ) {
75
      $path = $this->repos[$realName]['path'];
76
    } elseif( isset( $this->repos[$repoName]['path'] ) ) {
77
      $path = $this->repos[$repoName]['path'];
78
    }
79
80
    if( $path === '' ) {
81
      http_response_code( 404 );
82
      exit( "Repository not found" );
83
    }
84
85
    $this->git->setRepository( $path );
86
87
    return new ClonePage( $this->git, implode( '/', $uriParts ) );
88
  }
89
90
  private function resolveActionRoute(
91
    string $repoName,
92
    array $uriParts
93
  ): Page {
94
    $this->repoData = $this->repos[$repoName];
95
    $this->repoName = $repoName;
96
97
    $this->git->setRepository( $this->repoData['path'] );
98
99
    $act = array_shift( $uriParts );
100
    $this->action = $act ?: self::ACTION_TREE;
101
102
    $this->commitHash = self::REFERENCE_HEAD;
103
    $this->filePath   = '';
104
    $this->baseHash   = '';
105
106
    $hasHash = [
107
      self::ACTION_TREE, self::ACTION_BLOB, self::ACTION_RAW,
108
      self::ACTION_COMMITS
109
    ];
110
111
    if( in_array( $this->action, $hasHash ) ) {
112
      $hash = array_shift( $uriParts );
113
      $this->commitHash = $hash ?: self::REFERENCE_HEAD;
114
      $this->filePath   = implode( '/', $uriParts );
115
    } elseif( $this->action === self::ACTION_COMMIT ) {
116
      $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD;
117
    } elseif( $this->action === self::ACTION_COMPARE ) {
118
      $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD;
119
      $this->baseHash   = array_shift( $uriParts ) ?? '';
120
    }
121
122
    $this->populateGet();
123
124
    return $this->createPage();
125
  }
126
127
  private function createPage(): Page {
128
    return match( $this->action ) {
129
      self::ACTION_TREE,
130
      self::ACTION_BLOB    => new FilePage(
131
        $this->repos, $this->repoData, $this->git, $this->commitHash,
132
        $this->filePath
133
      ),
134
      self::ACTION_RAW     => new RawPage(
135
        $this->git, $this->commitHash
136
      ),
137
      self::ACTION_COMMITS => new CommitsPage(
138
        $this->repos, $this->repoData, $this->git, $this->commitHash
139
      ),
140
      self::ACTION_COMMIT  => new DiffPage(
141
        $this->repos, $this->repoData, $this->git, $this->commitHash
142
      ),
143
      self::ACTION_TAGS    => new TagsPage(
144
        $this->repos, $this->repoData, $this->git
145
      ),
146
      self::ACTION_COMPARE => new ComparePage(
147
        $this->repos, $this->repoData, $this->git, $this->commitHash,
148
        $this->baseHash
149
      ),
150
      default              => new FilePage(
151
        $this->repos, $this->repoData, $this->git, self::REFERENCE_HEAD, ''
152
      )
153
    };
154
  }
155
156
  private function normalizeQueryString(): void {
157
    if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) {
158
      parse_str( $_SERVER['QUERY_STRING'], $_GET );
159
    }
160
  }
161
162
  private function parseUriParts(): array {
163
    $requestUri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
164
    $scriptName = dirname( $_SERVER['SCRIPT_NAME'] );
165
166
    if( $scriptName !== '/' && strpos( $requestUri, $scriptName ) === 0 ) {
167
      $requestUri = substr( $requestUri, strlen( $scriptName ) );
168
    }
169
170
    $requestUri = trim( $requestUri, '/' );
171
    $uriParts   = explode( '/', $requestUri );
172
173
    if( !empty( $uriParts ) && $uriParts[0] === self::ROUTE_REPO ) {
174
      array_shift( $uriParts );
175
    }
176
177
    return $uriParts;
178
  }
179
180
  private function populateGet(): void {
181
    $_GET[self::GET_REPOSITORY] = $this->repoName;
182
    $_GET[self::GET_ACTION]     = $this->action;
183
    $_GET[self::GET_HASH]       = $this->commitHash;
184
    $_GET[self::GET_NAME]       = $this->filePath;
185
  }
186
}
1871
D Tag.php
1
<?php
2
require_once __DIR__ . '/render/TagRenderer.php';
3
4
class Tag {
5
  private string $name;
6
  private string $sha;
7
  private string $targetSha;
8
  private int $timestamp;
9
  private string $message;
10
  private string $author;
11
12
  public function __construct(
13
    string $name,
14
    string $sha,
15
    string $targetSha,
16
    int $timestamp,
17
    string $message,
18
    string $author
19
  ) {
20
    $this->name      = $name;
21
    $this->sha       = $sha;
22
    $this->targetSha = $targetSha;
23
    $this->timestamp = $timestamp;
24
    $this->message   = $message;
25
    $this->author    = $author;
26
  }
27
28
  public function compare( Tag $other ): int {
29
    return $other->timestamp <=> $this->timestamp;
30
  }
31
32
  public function render( TagRenderer $renderer, ?Tag $prevTag = null ): void {
33
    $renderer->renderTagItem(
34
      $this->name,
35
      $this->sha,
36
      $this->targetSha,
37
      $prevTag ? $prevTag->targetSha : null,
38
      $this->timestamp,
39
      $this->message,
40
      $this->author
41
    );
42
  }
43
}
441
D UrlBuilder.php
1
<?php
2
class UrlBuilder {
3
  private const REPO_PREFIX = '/repo/';
4
  private const HEAD_REF    = '/HEAD';
5
  private const ACT_TREE    = 'tree';
6
7
  private $repo;
8
  private $action;
9
  private $hash;
10
  private $name;
11
  private $switcher;
12
13
  public function withRepo( $repo ) {
14
    $this->repo = $repo;
15
16
    return $this;
17
  }
18
19
  public function withAction( $action ) {
20
    $this->action = $action;
21
22
    return $this;
23
  }
24
25
  public function withHash( $hash ) {
26
    $this->hash = $hash;
27
28
    return $this;
29
  }
30
31
  public function withName( $name ) {
32
    $this->name = $name;
33
34
    return $this;
35
  }
36
37
  public function withSwitcher( $jsValue ) {
38
    $this->switcher = $jsValue;
39
40
    return $this;
41
  }
42
43
  public function build() {
44
    return $this->switcher
45
      ? "window.location.href='" . self::REPO_PREFIX . "' + " . $this->switcher
46
      : ($this->repo ? $this->assembleUrl() : '/');
47
  }
48
49
  private function assembleUrl() {
50
    $url = self::REPO_PREFIX . $this->repo;
51
    $act = !$this->action && $this->name ? self::ACT_TREE : $this->action;
52
53
    if( $act ) {
54
      $url .= '/' . $act . $this->resolveHashSegment( $act );
55
    }
56
57
    if( $this->name ) {
58
      $url .= '/' . ltrim( $this->name, '/' );
59
    }
60
61
    return $url;
62
  }
63
64
  private function resolveHashSegment( $act ) {
65
    return $this->hash
66
      ? '/' . $this->hash
67
      : (in_array( $act, ['tree', 'blob', 'raw', 'commits'] )
68
        ? self::HEAD_REF
69
        : '');
70
  }
71
}
72
731
D git/BufferedFileReader.php
1
<?php
2
class BufferedFileReader {
3
  private mixed $handle;
4
  private bool  $temporary;
5
6
  private function __construct( mixed $handle, bool $temporary ) {
7
    $this->handle    = $handle;
8
    $this->temporary = $temporary;
9
  }
10
11
  public static function open( string $path ): self {
12
    return new self( fopen( $path, 'rb' ), false );
13
  }
14
15
  public static function createTemp(): self {
16
    return new self( tmpfile(), true );
17
  }
18
19
  public function __destruct() {
20
    if( $this->isOpen() ) {
21
      fclose( $this->handle );
22
    }
23
  }
24
25
  public function isOpen(): bool {
26
    return is_resource( $this->handle );
27
  }
28
29
  public function read( int $length ): string {
30
    return $this->isOpen() && !feof( $this->handle )
31
      ? (string)fread( $this->handle, $length )
32
      : '';
33
  }
34
35
  public function write( string $data ): bool {
36
    return $this->temporary &&
37
           $this->isOpen() &&
38
           fwrite( $this->handle, $data ) !== false;
39
  }
40
41
  public function seek( int $offset, int $whence = SEEK_SET ): bool {
42
    return $this->isOpen() &&
43
           fseek( $this->handle, $offset, $whence ) === 0;
44
  }
45
46
  public function tell(): int {
47
    return $this->isOpen()
48
      ? (int)ftell( $this->handle )
49
      : 0;
50
  }
51
52
  public function eof(): bool {
53
    return $this->isOpen() ? feof( $this->handle ) : true;
54
  }
55
56
  public function rewind(): void {
57
    if( $this->isOpen() ) {
58
      rewind( $this->handle );
59
    }
60
  }
61
}
621
A git/BufferedReader.php
1
<?php
2
require_once __DIR__ . '/StreamReader.php';
3
4
class BufferedReader implements StreamReader {
5
  private const int CHUNK_SIZE = 65536;
6
7
  private mixed  $handle;
8
  private string $buffer    = '';
9
  private int    $bufferPos = 0;
10
  private int    $bufferLen = 0;
11
12
  private string $wBuffer    = '';
13
  private int    $wBufferLen = 0;
14
15
  private readonly bool $writable;
16
17
  public function __construct( string $path, string $mode = 'rb' ) {
18
    $this->handle   = @\fopen( $path, $mode );
19
    $this->writable = $mode !== 'rb';
20
  }
21
22
  public function __destruct() {
23
    if( $this->handle !== false ) {
24
      $this->flushWrites();
25
      \fclose( $this->handle );
26
27
      $this->handle = false;
28
    }
29
  }
30
31
  public function isOpen(): bool {
32
    return $this->handle !== false;
33
  }
34
35
  public function read( int $length ): string {
36
    $available = $this->bufferLen - $this->bufferPos;
37
38
    if( $available >= $length ) {
39
      $result           = \substr(
40
        $this->buffer, $this->bufferPos, $length
41
      );
42
      $this->bufferPos += $length;
43
44
      if( $this->bufferPos >= $this->bufferLen ) {
45
        $this->clearReadBuffer();
46
      }
47
48
      return $result;
49
    }
50
51
    if( $this->bufferPos > 0 ) {
52
      $this->buffer    = $available > 0
53
        ? \substr( $this->buffer, $this->bufferPos )
54
        : '';
55
      $this->bufferLen = $available;
56
      $this->bufferPos = 0;
57
    }
58
59
    if( $this->handle !== false && !\feof( $this->handle ) ) {
60
      $chunk = \fread(
61
        $this->handle,
62
        \max( $length - $available, self::CHUNK_SIZE )
63
      );
64
65
      if( $chunk !== false && $chunk !== '' ) {
66
        $this->buffer    .= $chunk;
67
        $this->bufferLen += \strlen( $chunk );
68
        $available        = $this->bufferLen;
69
      }
70
    }
71
72
    $take = \min( $available, $length );
73
74
    $result = $take > 0
75
      ? \substr( $this->buffer, $this->bufferPos, $take )
76
      : '';
77
78
    if( $take > 0 ) {
79
      $this->bufferPos += $take;
80
81
      if( $this->bufferPos >= $this->bufferLen ) {
82
        $this->clearReadBuffer();
83
      }
84
    }
85
86
    return $result;
87
  }
88
89
  public function write( string $data ): bool {
90
    if( !$this->writable || $this->handle === false ) {
91
      return false;
92
    }
93
94
    $this->clearReadBuffer();
95
96
    $this->wBuffer    .= $data;
97
    $this->wBufferLen += \strlen( $data );
98
99
    if( $this->wBufferLen < self::CHUNK_SIZE ) {
100
      return true;
101
    }
102
103
    return $this->flushWrites();
104
  }
105
106
  public function seek( int $offset, int $whence = SEEK_SET ): bool {
107
    $current = $this->tell();
108
    $target  = $whence === SEEK_CUR ? $current + $offset : $offset;
109
    $bufSt   = $current - $this->bufferPos;
110
    $bufEn   = $bufSt + $this->bufferLen;
111
    $success = false;
112
113
    if( $whence !== SEEK_END && $target >= $bufSt && $target <= $bufEn ) {
114
      $this->bufferPos = $target - $bufSt;
115
      $success         = true;
116
    } else {
117
      $seekTgt = $whence === SEEK_END ? $offset  : $target;
118
      $seekWh  = $whence === SEEK_END ? SEEK_END : SEEK_SET;
119
      $success = $this->handle !== false
120
        && \fseek( $this->handle, $seekTgt, $seekWh ) === 0;
121
122
      if( $success ) {
123
        $this->clearReadBuffer();
124
      }
125
    }
126
127
    return $success;
128
  }
129
130
  public function tell(): int {
131
    return $this->handle !== false
132
      ? (int)\ftell( $this->handle ) - $this->bufferLen + $this->bufferPos
133
      : 0;
134
  }
135
136
  public function eof(): bool {
137
    return $this->bufferPos >= $this->bufferLen
138
      && ($this->handle === false || \feof( $this->handle ));
139
  }
140
141
  public function rewind(): void {
142
    if( $this->handle !== false ) {
143
      $this->flushWrites();
144
      \rewind( $this->handle );
145
146
      $this->clearReadBuffer();
147
    }
148
  }
149
150
  private function clearReadBuffer(): void {
151
    $this->buffer    = '';
152
    $this->bufferPos = 0;
153
    $this->bufferLen = 0;
154
  }
155
156
  private function flushWrites(): bool {
157
    if( $this->wBufferLen === 0 ) {
158
      return true;
159
    }
160
161
    $ok               = \fwrite( $this->handle, $this->wBuffer ) !== false;
162
    $this->wBuffer    = '';
163
    $this->wBufferLen = 0;
164
165
    return $ok;
166
  }
167
}
1168
M git/CompressionStream.php
11
<?php
2
require_once __DIR__ . '/StreamReader.php';
3
24
class CompressionStream {
35
  private Closure $pumper;
...
1618
1719
  public static function createExtractor(): self {
18
    $context = inflate_init( ZLIB_ENCODING_DEFLATE );
20
    $context = \inflate_init( \ZLIB_ENCODING_DEFLATE );
1921
2022
    return new self(
2123
      function( string $chunk ) use ( $context ): string {
22
        $before  = inflate_get_read_len( $context );
23
        $discard = @inflate_add( $context, $chunk );
24
        $after   = inflate_get_read_len( $context );
25
        $length  = $after - $before;
24
        $before = \inflate_get_read_len( $context );
25
        @\inflate_add( $context, $chunk );
2626
27
        return substr( $chunk, 0, $length );
27
        return \substr(
28
          $chunk,
29
          0,
30
          \inflate_get_read_len( $context ) - $before
31
        );
2832
      },
2933
      function(): string {
3034
        return '';
3135
      },
3236
      function() use ( $context ): bool {
33
        return inflate_get_status( $context ) === ZLIB_STREAM_END;
37
        return \inflate_get_status( $context ) === \ZLIB_STREAM_END;
3438
      }
3539
    );
3640
  }
3741
3842
  public static function createInflater(): self {
39
    $context = inflate_init( ZLIB_ENCODING_DEFLATE );
43
    $context = \inflate_init( \ZLIB_ENCODING_DEFLATE );
4044
4145
    return new self(
4246
      function( string $chunk ) use ( $context ): string {
43
        $data = @inflate_add( $context, $chunk );
47
        $data = @\inflate_add( $context, $chunk );
4448
4549
        return $data === false ? '' : $data;
4650
      },
4751
      function(): string {
4852
        return '';
4953
      },
5054
      function() use ( $context ): bool {
51
        return inflate_get_status( $context ) === ZLIB_STREAM_END;
55
        return \inflate_get_status( $context ) === \ZLIB_STREAM_END;
5256
      }
5357
    );
5458
  }
5559
5660
  public static function createDeflater(): self {
57
    $context = deflate_init( ZLIB_ENCODING_DEFLATE );
61
    $context = \deflate_init( \ZLIB_ENCODING_DEFLATE );
5862
5963
    return new self(
6064
      function( string $chunk ) use ( $context ): string {
61
        $data = deflate_add( $context, $chunk, ZLIB_NO_FLUSH );
65
        $data = \deflate_add( $context, $chunk, \ZLIB_NO_FLUSH );
6266
6367
        return $data === false ? '' : $data;
6468
      },
6569
      function() use ( $context ): string {
66
        $data = deflate_add( $context, '', ZLIB_FINISH );
70
        $data = \deflate_add( $context, '', \ZLIB_FINISH );
6771
6872
        return $data === false ? '' : $data;
6973
      },
7074
      function(): bool {
7175
        return false;
7276
      }
7377
    );
7478
  }
7579
76
  public function stream( mixed $handle, int $chunkSize = 8192 ): Generator {
80
  public function stream(
81
    StreamReader $stream,
82
    int $chunkSize = 8192
83
  ): Generator {
7784
    $done = false;
7885
79
    while( !$done && !feof( $handle ) ) {
80
      $chunk = fread( $handle, $chunkSize );
81
      $done  = $chunk === false || $chunk === '';
86
    while( !$done && !$stream->eof() ) {
87
      $chunk = $stream->read( $chunkSize );
88
      $done  = $chunk === '';
8289
8390
      if( !$done ) {
M git/DeltaDecoder.php
11
<?php
22
require_once __DIR__ . '/CompressionStream.php';
3
require_once __DIR__ . '/StreamReader.php';
34
45
class DeltaDecoder {
6
  private const CHUNK_SIZE = 65536;
7
8
  private const array COPY_INSTRUCTION_SIZES = [
9
    0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,
10
    1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,
11
    1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,
12
    2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
13
    1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,
14
    2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
15
    2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,
16
    3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,
17
  ];
18
519
  public function apply( string $base, string $delta, int $cap ): string {
620
    $pos = 0;
7
    $res = $this->readDeltaSize( $delta, $pos );
8
    $pos += $res['used'];
9
    $res = $this->readDeltaSize( $delta, $pos );
10
    $pos += $res['used'];
21
    $this->readDeltaSize( $delta, $pos );
22
    $this->readDeltaSize( $delta, $pos );
1123
12
    $out  = '';
13
    $len  = strlen( $delta );
14
    $done = false;
24
    $chunks = [];
25
    $len    = strlen( $delta );
26
    $outLen = 0;
1527
16
    while( !$done && $pos < $len ) {
17
      if( $cap > 0 && strlen( $out ) >= $cap ) {
18
        $done = true;
19
      }
28
    while( $pos < $len ) {
29
      if( $cap > 0 && $outLen >= $cap ) break;
2030
21
      if( !$done ) {
22
        $op = ord( $delta[$pos++] );
31
      $op = ord( $delta[$pos++] );
2332
24
        if( $op & 128 ) {
25
          $info = $this->parseCopyInstruction( $op, $delta, $pos );
26
          $out .= substr( $base, $info['off'], $info['len'] );
27
          $pos += $info['used'];
28
        } else {
29
          $ln   = $op & 127;
30
          $out .= substr( $delta, $pos, $ln );
31
          $pos += $ln;
32
        }
33
      if( $op & 128 ) {
34
        $off = $ln = 0;
35
36
        $this->parseCopyInstruction( $op, $delta, $pos, $off, $ln );
37
38
        $chunks[] = substr( $base, $off, $ln );
39
        $outLen  += $ln;
40
      } else {
41
        $ln       = $op & 127;
42
        $chunks[] = substr( $delta, $pos, $ln );
43
        $outLen  += $ln;
44
        $pos     += $ln;
3345
      }
3446
    }
3547
36
    return $out;
48
    $result = implode( '', $chunks );
49
50
    return $cap > 0 && strlen( $result ) > $cap
51
      ? substr( $result, 0, $cap )
52
      : $result;
3753
  }
3854
3955
  public function applyStreamGenerator(
40
    mixed $handle,
56
    StreamReader $handle,
4157
    mixed $base
4258
  ): Generator {
43
    $stream = CompressionStream::createInflater();
44
    $state  = 0;
45
    $buffer = '';
46
    $isFile = is_resource( $base );
59
    $stream      = CompressionStream::createInflater();
60
    $state       = 0;
61
    $buffer      = '';
62
    $offset      = 0;
63
    $yieldBuffer = '';
64
    $yieldBufLen = 0;
65
    $isStream    = $base instanceof StreamReader;
4766
4867
    foreach( $stream->stream( $handle ) as $data ) {
49
      $buffer     .= $data;
50
      $doneBuffer  = false;
51
52
      while( !$doneBuffer ) {
53
        $len = strlen( $buffer );
68
      $buffer .= $data;
69
      $bufLen  = \strlen( $buffer );
5470
55
        if( $len === 0 ) {
56
          $doneBuffer = true;
57
        }
71
      while( $offset < $bufLen ) {
72
        $len = $bufLen - $offset;
5873
59
        if( !$doneBuffer ) {
60
          if( $state < 2 ) {
61
            $pos = 0;
74
        if( $state < 2 ) {
75
          $pos   = $offset;
76
          $found = false;
6277
63
            while( $pos < $len && (ord( $buffer[$pos] ) & 128) ) {
78
          while( $pos < $bufLen ) {
79
            if( !(ord( $buffer[$pos] ) & 128) ) {
80
              $found = true;
6481
              $pos++;
82
              break;
6583
            }
6684
67
            if( $pos === $len && (ord( $buffer[$pos - 1] ) & 128) ) {
68
              $doneBuffer = true;
69
            }
85
            $pos++;
86
          }
7087
71
            if( !$doneBuffer ) {
72
              $buffer = substr( $buffer, $pos + 1 );
73
              $state++;
74
            }
88
          if( $found ) {
89
            $offset = $pos;
90
            $state++;
7591
          } else {
76
            $op = ord( $buffer[0] );
92
            break;
93
          }
94
        } else {
95
          $op = ord( $buffer[$offset] );
7796
78
            if( $op & 128 ) {
79
              $need = $this->calculateCopyInstructionSize( $op );
97
          if( $op & 128 ) {
98
            $need = self::COPY_INSTRUCTION_SIZES[$op & 0x7F];
8099
81
              if( $len < 1 + $need ) {
82
                $doneBuffer = true;
83
              }
100
            if( $len < 1 + $need ) {
101
              break;
102
            }
84103
85
              if( !$doneBuffer ) {
86
                $info = $this->parseCopyInstruction( $op, $buffer, 1 );
104
            $off = $ln = 0;
105
            $ptr = $offset + 1;
87106
88
                if( $isFile ) {
89
                  fseek( $base, $info['off'] );
107
            $this->parseCopyInstruction( $op, $buffer, $ptr, $off, $ln );
90108
91
                  $rem = $info['len'];
109
            if( $isStream ) {
110
              $base->seek( $off );
111
              $rem = $ln;
92112
93
                  while( $rem > 0 ) {
94
                    $slc = fread( $base, min( 65536, $rem ) );
113
              while( $rem > 0 ) {
114
                $slc = $base->read( min( self::CHUNK_SIZE, $rem ) );
95115
96
                    if( $slc === false || $slc === '' ) {
97
                      $rem = 0;
98
                    } else {
99
                      yield $slc;
116
                if( $slc === '' ) {
117
                  $rem = 0;
118
                } else {
119
                  $slcLen       = strlen( $slc );
120
                  $yieldBuffer .= $slc;
121
                  $yieldBufLen += $slcLen;
122
                  $rem         -= $slcLen;
100123
101
                      $rem -= strlen( $slc );
102
                    }
124
                  if( $yieldBufLen >= self::CHUNK_SIZE ) {
125
                    yield $yieldBuffer;
126
127
                    $yieldBuffer = '';
128
                    $yieldBufLen = 0;
103129
                  }
104
                } else {
105
                  yield substr( $base, $info['off'], $info['len'] );
106130
                }
107
108
                $buffer = substr( $buffer, 1 + $need );
109131
              }
110132
            } else {
111
              $ln = $op & 127;
133
              $slc          = \substr( $base, $off, $ln );
134
              $yieldBuffer .= $slc;
135
              $yieldBufLen += \strlen( $slc );
112136
113
              if( $len < 1 + $ln ) {
114
                $doneBuffer = true;
137
              if( $yieldBufLen >= self::CHUNK_SIZE ) {
138
                yield $yieldBuffer;
139
140
                $yieldBuffer = '';
141
                $yieldBufLen = 0;
115142
              }
143
            }
116144
117
              if( !$doneBuffer ) {
118
                yield substr( $buffer, 1, $ln );
145
            $offset = $ptr;
146
          } else {
147
            $ln = $op & 127;
119148
120
                $buffer = substr( $buffer, 1 + $ln );
121
              }
149
            if( $len < 1 + $ln ) {
150
              break;
151
            }
152
153
            $yieldBuffer .= substr( $buffer, $offset + 1, $ln );
154
            $yieldBufLen += $ln;
155
            $offset      += 1 + $ln;
156
157
            if( $yieldBufLen >= self::CHUNK_SIZE ) {
158
              yield $yieldBuffer;
159
160
              $yieldBuffer = '';
161
              $yieldBufLen = 0;
122162
            }
123163
          }
124164
        }
165
      }
166
167
      if( $offset >= self::CHUNK_SIZE ) {
168
        $buffer = \substr( $buffer, $offset );
169
        $offset = 0;
125170
      }
171
    }
172
173
    if( $yieldBuffer !== '' ) {
174
      yield $yieldBuffer;
126175
    }
127176
  }
128177
129
  public function readDeltaTargetSize( mixed $handle, int $type ): int {
178
  public function readDeltaTargetSize( StreamReader $handle, int $type ): int {
130179
    if( $type === 6 ) {
131
      $byte = ord( fread( $handle, 1 ) );
180
      $byte = ord( $handle->read( 1 ) );
132181
133182
      while( $byte & 128 ) {
134
        $byte = ord( fread( $handle, 1 ) );
183
        $byte = ord( $handle->read( 1 ) );
135184
      }
136185
    } else {
137
      fseek( $handle, 20, SEEK_CUR );
186
      $handle->seek( 20, SEEK_CUR );
138187
    }
188
189
    $head = $this->readInflatedHead( $handle );
190
191
    if( strlen( $head ) === 0 ) return 0;
192
193
    $pos = 0;
194
    $this->readDeltaSize( $head, $pos );
195
196
    return $this->readDeltaSize( $head, $pos );
197
  }
198
199
  public function readDeltaBaseSize( StreamReader $handle ): int {
200
    $head = $this->readInflatedHead( $handle );
201
202
    if( strlen( $head ) === 0 ) return 0;
203
204
    $pos = 0;
205
206
    return $this->readDeltaSize( $head, $pos );
207
  }
139208
209
  private function readInflatedHead( StreamReader $handle ): string {
140210
    $stream = CompressionStream::createInflater();
141211
    $head   = '';
...
149219
        break;
150220
      }
151
    }
152
153
    $pos    = 0;
154
    $result = 0;
155
156
    if( strlen( $head ) > 0 ) {
157
      $res    = $this->readDeltaSize( $head, $pos );
158
      $pos   += $res['used'];
159
      $res    = $this->readDeltaSize( $head, $pos );
160
      $result = $res['val'];
161221
    }
162222
163
    return $result;
223
    return $head;
164224
  }
165225
166226
  private function parseCopyInstruction(
167227
    int $op,
168228
    string $data,
169
    int $pos
170
  ): array {
229
    int &$pos,
230
    int &$off,
231
    int &$len
232
  ): void {
171233
    $off = 0;
172234
    $len = 0;
173
    $ptr = $pos;
174
175
    if( $op & 0x01 ) {
176
      $off |= ord( $data[$ptr++] );
177
    }
178
179
    if( $op & 0x02 ) {
180
      $off |= ord( $data[$ptr++] ) << 8;
181
    }
182
183
    if( $op & 0x04 ) {
184
      $off |= ord( $data[$ptr++] ) << 16;
185
    }
186
187
    if( $op & 0x08 ) {
188
      $off |= ord( $data[$ptr++] ) << 24;
189
    }
190
191
    if( $op & 0x10 ) {
192
      $len |= ord( $data[$ptr++] );
193
    }
194
195
    if( $op & 0x20 ) {
196
      $len |= ord( $data[$ptr++] ) << 8;
197
    }
198
199
    if( $op & 0x40 ) {
200
      $len |= ord( $data[$ptr++] ) << 16;
201
    }
202235
203
    return [
204
      'off'  => $off,
205
      'len'  => $len === 0 ? 0x10000 : $len,
206
      'used' => $ptr - $pos
207
    ];
208
  }
236
    ($op & 0x01) ? $off |= ord( $data[$pos++] )       : null;
237
    ($op & 0x02) ? $off |= ord( $data[$pos++] ) << 8  : null;
238
    ($op & 0x04) ? $off |= ord( $data[$pos++] ) << 16 : null;
239
    ($op & 0x08) ? $off |= ord( $data[$pos++] ) << 24 : null;
209240
210
  private function calculateCopyInstructionSize( int $op ): int {
211
    $calc = $op & 0x7F;
212
    $calc = $calc - ($calc >> 1 & 0x55);
213
    $calc = ($calc >> 2 & 0x33) + ($calc & 0x33);
214
    $calc = (($calc >> 4) + $calc) & 0x0F;
241
    ($op & 0x10) ? $len |= ord( $data[$pos++] )       : null;
242
    ($op & 0x20) ? $len |= ord( $data[$pos++] ) << 8  : null;
243
    ($op & 0x40) ? $len |= ord( $data[$pos++] ) << 16 : null;
215244
216
    return $calc;
245
    $len = $len === 0 ? 0x10000 : $len;
217246
  }
218247
219
  private function readDeltaSize( string $data, int $pos ): array {
248
  private function readDeltaSize( string $data, int &$pos ): int {
220249
    $len   = strlen( $data );
221250
    $val   = 0;
222251
    $shift = 0;
223
    $start = $pos;
224252
    $done  = false;
225253
226254
    while( !$done && $pos < $len ) {
227
      $byte  = ord( $data[$pos++] );
228
      $val  |= ($byte & 0x7F) << $shift;
229
230
      if( !($byte & 0x80) ) {
231
        $done = true;
232
      }
233
234
      if( !$done ) {
235
        $shift += 7;
236
      }
255
      $byte   = ord( $data[$pos++] );
256
      $val   |= ($byte & 0x7F) << $shift;
257
      $done   = !($byte & 0x80);
258
      $shift += 7;
237259
    }
238260
239
    return [ 'val' => $val, 'used' => $pos - $start ];
261
    return $val;
240262
  }
241263
}
D git/FileHandlePool.php
1
<?php
2
class FileHandlePool {
3
  private array $handles;
4
5
  public function __construct() {
6
    $this->handles = [];
7
  }
8
9
  public function __destruct() {
10
    foreach( $this->handles as $handle ) {
11
      if( is_resource( $handle ) ) {
12
        fclose( $handle );
13
      }
14
    }
15
  }
16
17
  public function computeInt(
18
    string $path,
19
    callable $action,
20
    int $fallback = 0
21
  ): int {
22
    $result = $this->withHandle( $path, $action );
23
    return is_int( $result ) ? $result : $fallback;
24
  }
25
26
  public function computeString(
27
    string $path,
28
    callable $action,
29
    string $fallback = ''
30
  ): string {
31
    $result = $this->withHandle( $path, $action );
32
    return is_string( $result ) ? $result : $fallback;
33
  }
34
35
  public function computeVoid( string $path, callable $action ): void {
36
    $this->withHandle( $path, $action );
37
  }
38
39
  public function streamGenerator(
40
    string $path,
41
    callable $action
42
  ): Generator {
43
    $resultGenerator = $this->withHandle( $path, $action );
44
45
    if( $resultGenerator instanceof Generator ) {
46
      yield from $resultGenerator;
47
    }
48
  }
49
50
  private function withHandle( string $path, callable $action ) {
51
    if( !array_key_exists( $path, $this->handles ) ) {
52
      $this->handles[$path] = @fopen( $path, 'rb' ) ?: null;
53
    }
54
55
    $handle = $this->handles[$path] ?? null;
56
57
    return is_resource( $handle ) ? $action( $handle ) : null;
58
  }
59
}
601
M git/Git.php
11
<?php
2
require_once __DIR__ . '/../File.php';
3
require_once __DIR__ . '/../Tag.php';
4
require_once __DIR__ . '/GitRefs.php';
5
require_once __DIR__ . '/GitPacks.php';
6
require_once __DIR__ . '/BufferedFileReader.php';
7
8
class Git {
9
  private const MAX_READ = 1048576;
10
11
  private string   $repoPath;
12
  private string   $objPath;
13
  private GitRefs  $refs;
14
  private GitPacks $packs;
15
16
  public function __construct( string $repoPath ) {
17
    $this->setRepository( $repoPath );
18
  }
19
20
  public function setRepository( string $repoPath ): void {
21
    $this->repoPath = rtrim( $repoPath, '/' );
22
    $this->objPath  = $this->repoPath . '/objects';
23
    $this->refs     = new GitRefs( $this->repoPath );
24
    $this->packs    = new GitPacks( $this->objPath );
25
  }
26
27
  public function resolve( string $reference ): string {
28
    return $this->refs->resolve( $reference );
29
  }
30
31
  public function getMainBranch(): array {
32
    return $this->refs->getMainBranch();
33
  }
34
35
  public function eachBranch( callable $callback ): void {
36
    $this->refs->scanRefs( 'refs/heads', $callback );
37
  }
38
39
  public function eachTag( callable $callback ): void {
40
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
41
      $callback
42
    ) {
43
      $data = $this->read( $sha );
44
      $tag  = $this->parseTagData( $name, $sha, $data );
45
46
      $callback( $tag );
47
    } );
48
  }
49
50
  public function walk(
51
    string $refOrSha,
52
    callable $callback,
53
    string $path = ''
54
  ): void {
55
    $sha     = $this->resolve( $refOrSha );
56
    $treeSha = '';
57
58
    if( $sha !== '' ) {
59
      $treeSha = $this->getTreeSha( $sha );
60
    }
61
62
    if( $path !== '' && $treeSha !== '' ) {
63
      $info    = $this->resolvePath( $treeSha, $path );
64
      $treeSha = $info['isDir'] ? $info['sha'] : '';
65
    }
66
67
    if( $treeSha !== '' ) {
68
      $this->walkTree( $treeSha, $callback );
69
    }
70
  }
71
72
  public function readFile( string $ref, string $path ): File {
73
    $sha  = $this->resolve( $ref );
74
    $tree = $sha !== '' ? $this->getTreeSha( $sha ) : '';
75
    $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : [];
76
    $file = new MissingFile();
77
78
    if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) {
79
      $file = new File(
80
        basename( $path ),
81
        $info['sha'],
82
        $info['mode'],
83
        0,
84
        $this->getObjectSize( $info['sha'] ),
85
        $this->peek( $info['sha'] )
86
      );
87
    }
88
89
    return $file;
90
  }
91
92
  public function getObjectSize( string $sha, string $path = '' ): int {
93
    $target = $sha;
94
    $result = 0;
95
96
    if( $path !== '' ) {
97
      $info   = $this->resolvePath(
98
        $this->getTreeSha( $this->resolve( $sha ) ),
99
        $path
100
      );
101
      $target = $info['sha'] ?? '';
102
    }
103
104
    if( $target !== '' ) {
105
      $result = $this->packs->getSize( $target );
106
107
      if( $result === 0 ) {
108
        $result = $this->getLooseObjectSize( $target );
109
      }
110
    }
111
112
    return $result;
113
  }
114
115
  public function stream(
116
    string $sha,
117
    callable $callback,
118
    string $path = ''
119
  ): void {
120
    $target = $sha;
121
122
    if( $path !== '' ) {
123
      $info   = $this->resolvePath(
124
        $this->getTreeSha( $this->resolve( $sha ) ),
125
        $path
126
      );
127
      $target = isset( $info['isDir'] ) && !$info['isDir']
128
        ? $info['sha']
129
        : '';
130
    }
131
132
    if( $target !== '' ) {
133
      $this->slurp( $target, $callback );
134
    }
135
  }
136
137
  public function peek( string $sha, int $length = 255 ): string {
138
    $size = $this->packs->getSize( $sha );
139
140
    return $size === 0
141
      ? $this->peekLooseObject( $sha, $length )
142
      : $this->packs->peek( $sha, $length );
143
  }
144
145
  public function read( string $sha ): string {
146
    $size    = $this->getObjectSize( $sha );
147
    $content = '';
148
149
    if( $size > 0 && $size <= self::MAX_READ ) {
150
      $this->slurp( $sha, function( $chunk ) use ( &$content ) {
151
        $content .= $chunk;
152
      } );
153
    }
154
155
    return $content;
156
  }
157
158
  public function history(
159
    string $ref,
160
    int $limit,
161
    callable $callback
162
  ): void {
163
    $sha   = $this->resolve( $ref );
164
    $count = 0;
165
    $done  = false;
166
167
    while( !$done && $sha !== '' && $count < $limit ) {
168
      $commit = $this->parseCommit( $sha );
169
170
      if( $commit->sha === '' ) {
171
        $sha  = '';
172
        $done = true;
173
      }
174
175
      if( !$done && $sha !== '' ) {
176
        if( $callback( $commit ) === false ) {
177
          $done = true;
178
        }
179
180
        if( !$done ) {
181
          $sha   = $commit->parentSha;
182
          $count++;
183
        }
184
      }
185
    }
186
  }
187
188
  public function streamRaw( string $subPath ): bool {
189
    $result = false;
190
191
    if( strpos( $subPath, '..' ) === false ) {
192
      $path = "{$this->repoPath}/$subPath";
193
194
      if( is_file( $path ) ) {
195
        $real = realpath( $path );
196
        $repo = realpath( $this->repoPath );
197
198
        if( $real !== false && strpos( $real, $repo ) === 0 ) {
199
          $result = $this->streamFileContent( $path );
200
        }
201
      }
202
    }
203
204
    return $result;
205
  }
206
207
  private function streamFileContent( string $path ): bool {
208
    $result = false;
209
210
    if( $path !== '' ) {
211
      header( 'X-Accel-Redirect: ' . $path );
212
      header( 'Content-Type: application/octet-stream' );
213
214
      $result = true;
215
    }
216
217
    return $result;
218
  }
219
220
  public function eachRef( callable $callback ): void {
221
    $head = $this->resolve( 'HEAD' );
222
223
    if( $head !== '' ) {
224
      $callback( 'HEAD', $head );
225
    }
226
227
    $this->refs->scanRefs( 'refs/heads', function( $n, $s ) use ( $callback ) {
228
      $callback( "refs/heads/$n", $s );
229
    } );
230
231
    $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) {
232
      $callback( "refs/tags/$n", $s );
233
    } );
234
  }
235
236
  public function generatePackfile( array $objs ): Generator {
237
    $ctx  = hash_init( 'sha1' );
238
    $head = "PACK" . pack( 'N', 2 ) . pack( 'N', count( $objs ) );
239
240
    hash_update( $ctx, $head );
241
    yield $head;
242
243
    foreach( $objs as $sha => $type ) {
244
      $size = $this->getObjectSize( $sha );
245
      $byte = $type << 4 | $size & 0x0f;
246
      $sz   = $size >> 4;
247
      $hdr  = '';
248
249
      while( $sz > 0 ) {
250
        $hdr  .= chr( $byte | 0x80 );
251
        $byte  = $sz & 0x7f;
252
        $sz  >>= 7;
253
      }
254
255
      $hdr .= chr( $byte );
256
      hash_update( $ctx, $hdr );
257
      yield $hdr;
258
259
      foreach( $this->streamCompressed( $sha ) as $compressed ) {
260
        hash_update( $ctx, $compressed );
261
        yield $compressed;
262
      }
263
    }
264
265
    yield hash_final( $ctx, true );
266
  }
267
268
  private function streamCompressed( string $sha ): Generator {
269
    $yielded = false;
270
271
    foreach( $this->packs->streamRawCompressed( $sha ) as $chunk ) {
272
      $yielded = true;
273
      yield $chunk;
274
    }
275
276
    if( !$yielded ) {
277
      $deflate = deflate_init( ZLIB_ENCODING_DEFLATE );
278
279
      foreach( $this->slurpChunks( $sha ) as $raw ) {
280
        $compressed = deflate_add( $deflate, $raw, ZLIB_NO_FLUSH );
281
282
        if( $compressed !== '' ) {
283
          yield $compressed;
284
        }
285
      }
286
287
      $final = deflate_add( $deflate, '', ZLIB_FINISH );
288
289
      if( $final !== '' ) {
290
        yield $final;
291
      }
292
    }
293
  }
294
295
  private function slurpChunks( string $sha ): Generator {
296
    $path = $this->getLoosePath( $sha );
297
298
    if( is_file( $path ) ) {
299
      yield from $this->looseObjectChunks( $path );
300
    } else {
301
      $any = false;
302
303
      foreach( $this->packs->streamGenerator( $sha ) as $chunk ) {
304
        $any = true;
305
        yield $chunk;
306
      }
307
308
      if( !$any ) {
309
        $data = $this->packs->read( $sha );
310
311
        if( $data !== '' ) {
312
          yield $data;
313
        }
314
      }
315
    }
316
  }
317
318
  private function looseObjectChunks( string $path ): Generator {
319
    $reader = BufferedFileReader::open( $path );
320
    $infl   = $reader->isOpen()
321
      ? inflate_init( ZLIB_ENCODING_DEFLATE )
322
      : false;
323
324
    if( $reader->isOpen() && $infl !== false ) {
325
      $found  = false;
326
      $buffer = '';
327
328
      while( !$reader->eof() ) {
329
        $chunk    = $reader->read( 16384 );
330
        $inflated = inflate_add( $infl, $chunk );
331
332
        if( $inflated === false ) {
333
          break;
334
        }
335
336
        if( !$found ) {
337
          $buffer .= $inflated;
338
          $eos     = strpos( $buffer, "\0" );
339
340
          if( $eos !== false ) {
341
            $found  = true;
342
            $body   = substr( $buffer, $eos + 1 );
343
344
            if( $body !== '' ) {
345
              yield $body;
346
            }
347
348
            $buffer = '';
349
          }
350
        } elseif( $inflated !== '' ) {
351
          yield $inflated;
352
        }
353
      }
354
    }
355
  }
356
357
  private function streamCompressedObject( string $sha, $ctx ): Generator {
358
    $stream = CompressionStream::createDeflater();
359
    $buffer = '';
360
361
    $this->slurp( $sha, function( $chunk ) use (
362
      $stream,
363
      $ctx,
364
      &$buffer
365
    ) {
366
      $compressed = $stream->pump( $chunk );
367
368
      if( $compressed !== '' ) {
369
        hash_update( $ctx, $compressed );
370
        $buffer .= $compressed;
371
      }
372
    } );
373
374
    $final = $stream->finish();
375
376
    if( $final !== '' ) {
377
      hash_update( $ctx, $final );
378
      $buffer .= $final;
379
    }
380
381
    $pos = 0;
382
    $len = strlen( $buffer );
383
384
    while( $pos < $len ) {
385
      $chunk = substr( $buffer, $pos, 32768 );
386
387
      yield $chunk;
388
      $pos += 32768;
389
    }
390
  }
391
392
  private function getTreeSha( string $commitOrTreeSha ): string {
393
    $data = $this->read( $commitOrTreeSha );
394
    $sha  = $commitOrTreeSha;
395
396
    if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) {
397
      $sha = $this->getTreeSha( $matches[1] );
398
    }
399
400
    if( $sha === $commitOrTreeSha &&
401
        preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
402
      $sha = $matches[1];
403
    }
404
405
    return $sha;
406
  }
407
408
  private function resolvePath( string $treeSha, string $path ): array {
409
    $parts = explode( '/', trim( $path, '/' ) );
410
    $sha   = $treeSha;
411
    $mode  = '40000';
412
413
    foreach( $parts as $part ) {
414
      $entry = [ 'sha' => '', 'mode' => '' ];
415
416
      if( $part !== '' && $sha !== '' ) {
417
        $entry = $this->findTreeEntry( $sha, $part );
418
      }
419
420
      $sha  = $entry['sha'];
421
      $mode = $entry['mode'];
422
    }
423
424
    return [
425
      'sha'   => $sha,
426
      'mode'  => $mode,
427
      'isDir' => $mode === '40000' || $mode === '040000'
428
    ];
429
  }
430
431
  private function findTreeEntry( string $treeSha, string $name ): array {
432
    $data  = $this->read( $treeSha );
433
    $entry = [ 'sha' => '', 'mode' => '' ];
434
435
    $this->parseTreeData(
436
      $data,
437
      function( $file, $n, $sha, $mode ) use ( $name, &$entry ) {
438
        if( $file->isName( $name ) ) {
439
          $entry = [ 'sha' => $sha, 'mode' => $mode ];
440
441
          return false;
442
        }
443
      }
444
    );
445
446
    return $entry;
447
  }
448
449
  private function parseTagData(
450
    string $name,
451
    string $sha,
452
    string $data
453
  ): Tag {
454
    $isAnn   = strncmp( $data, 'object ', 7 ) === 0;
455
    $pattern = $isAnn
456
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
457
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
458
    $id      = $this->parseIdentity( $data, $pattern );
459
    $target  = $isAnn
460
      ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha )
461
      : $sha;
462
463
    return new Tag(
464
      $name,
465
      $sha,
466
      $target,
467
      $id['timestamp'],
468
      $this->extractMessage( $data ),
469
      $id['name']
470
    );
471
  }
472
473
  private function extractPattern(
474
    string $data,
475
    string $pattern,
476
    int $group,
477
    string $default = ''
478
  ): string {
479
    return preg_match( $pattern, $data, $matches )
480
      ? $matches[$group]
481
      : $default;
482
  }
483
484
  private function parseIdentity( string $data, string $pattern ): array {
485
    $found = preg_match( $pattern, $data, $matches );
486
487
    return [
488
      'name'      => $found ? trim( $matches[1] ) : 'Unknown',
489
      'email'     => $found ? $matches[2] : '',
490
      'timestamp' => $found ? (int)$matches[3] : 0
491
    ];
492
  }
493
494
  private function extractMessage( string $data ): string {
495
    $pos = strpos( $data, "\n\n" );
496
497
    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
498
  }
499
500
  private function slurp( string $sha, callable $callback ): void {
501
    $path = $this->getLoosePath( $sha );
502
503
    if( is_file( $path ) ) {
504
      $this->slurpLooseObject( $path, $callback );
505
    } else {
506
      $this->slurpPackedObject( $sha, $callback );
507
    }
508
  }
509
510
  private function slurpLooseObject( string $path, callable $callback ): void {
511
    $this->iterateInflated(
512
      $path,
513
      function( $chunk ) use ( $callback ) {
514
        if( $chunk !== '' ) {
515
          $callback( $chunk );
516
        }
517
518
        return true;
519
      }
520
    );
521
  }
522
523
  private function slurpPackedObject( string $sha, callable $callback ): void {
524
    $streamed = $this->packs->stream( $sha, $callback );
525
526
    if( !$streamed ) {
527
      $data = $this->packs->read( $sha );
528
529
      if( $data !== '' ) {
530
        $callback( $data );
531
      }
532
    }
533
  }
534
535
  private function iterateInflated(
536
    string $path,
537
    callable $processor,
538
    int $bufferSize = 16384
539
  ): void {
540
    $reader = BufferedFileReader::open( $path );
541
    $infl   = $reader->isOpen()
542
      ? inflate_init( ZLIB_ENCODING_DEFLATE )
543
      : false;
544
    $found  = false;
545
    $buffer = '';
546
547
    if( $reader->isOpen() && $infl !== false ) {
548
      while( !$reader->eof() ) {
549
        $chunk    = $reader->read( $bufferSize );
550
        $inflated = inflate_add( $infl, $chunk );
551
552
        if( $inflated === false ) {
553
          break;
554
        }
555
556
        if( !$found ) {
557
          $buffer .= $inflated;
558
          $eos     = strpos( $buffer, "\0" );
559
560
          if( $eos !== false ) {
561
            $found = true;
562
            $body  = substr( $buffer, $eos + 1 );
563
            $head  = substr( $buffer, 0, $eos );
564
565
            if( $processor( $body, $head ) === false ) {
566
              break;
567
            }
568
          }
569
        } elseif( $processor( $inflated, '' ) === false ) {
570
          break;
571
        }
572
      }
573
    }
574
  }
575
576
  private function peekLooseObject( string $sha, int $length ): string {
577
    $path = $this->getLoosePath( $sha );
578
    $buf  = '';
579
580
    if( is_file( $path ) ) {
581
      $this->iterateInflated(
582
        $path,
583
        function( $chunk ) use ( $length, &$buf ) {
584
          $buf .= $chunk;
585
586
          return strlen( $buf ) < $length;
587
        },
588
        8192
589
      );
590
    }
591
592
    return substr( $buf, 0, $length );
593
  }
594
595
  private function parseCommit( string $sha ): object {
596
    $data   = $this->read( $sha );
597
    $result = (object)[ 'sha' => '' ];
598
599
    if( $data !== '' ) {
600
      $id = $this->parseIdentity(
601
        $data,
602
        '/^author (.*) <(.*)> (\d+)/m'
603
      );
604
605
      $result = (object)[
606
        'sha'       => $sha,
607
        'message'   => $this->extractMessage( $data ),
608
        'author'    => $id['name'],
609
        'email'     => $id['email'],
610
        'date'      => $id['timestamp'],
611
        'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 )
612
      ];
613
    }
614
615
    return $result;
616
  }
617
618
  private function walkTree( string $sha, callable $callback ): void {
619
    $data = $this->read( $sha );
620
    $tree = $data;
621
622
    if( $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) ) {
623
      $tree = $this->read( $m[1] );
624
    }
625
626
    if( $tree !== '' && $this->isTreeData( $tree ) ) {
627
      $this->processTree( $tree, $callback );
628
    }
629
  }
630
631
  private function processTree( string $data, callable $callback ): void {
632
    $this->parseTreeData(
633
      $data,
634
      function( $file, $n, $s, $m ) use ( $callback ) {
635
        $callback( $file );
636
      }
637
    );
638
  }
639
640
  public function parseTreeData( string $data, callable $callback ): void {
641
    $pos = 0;
642
    $len = strlen( $data );
643
644
    while( $pos < $len ) {
645
      $space = strpos( $data, ' ', $pos );
646
      $eos   = strpos( $data, "\0", $space );
647
648
      if( $space === false || $eos === false || $eos + 21 > $len ) {
649
        break;
650
      }
651
652
      $mode  = substr( $data, $pos, $space - $pos );
653
      $name  = substr( $data, $space + 1, $eos - $space - 1 );
654
      $sha   = bin2hex( substr( $data, $eos + 1, 20 ) );
655
      $dir   = $mode === '40000' || $mode === '040000';
656
      $isSub = $mode === '160000';
657
658
      $file = new File(
659
        $name,
660
        $sha,
661
        $mode,
662
        0,
663
        $dir || $isSub ? 0 : $this->getObjectSize( $sha ),
664
        $dir || $isSub ? '' : $this->peek( $sha )
665
      );
666
667
      if( $callback( $file, $name, $sha, $mode ) === false ) {
668
        break;
669
      }
670
671
      $pos = $eos + 21;
672
    }
673
  }
674
675
  private function isTreeData( string $data ): bool {
676
    $len   = strlen( $data );
677
    $patt  = '/^(40000|100644|100755|120000|160000) /';
678
    $match = $len >= 25 && preg_match( $patt, $data );
679
    $eos   = $match ? strpos( $data, "\0" ) : false;
680
681
    return $match && $eos !== false && $eos + 21 <= $len;
682
  }
683
684
  private function getLoosePath( string $sha ): string {
685
    return "{$this->objPath}/" . substr( $sha, 0, 2 ) . "/" .
686
      substr( $sha, 2 );
687
  }
688
689
  private function getLooseObjectSize( string $sha ): int {
690
    $path = $this->getLoosePath( $sha );
691
    $size = 0;
692
693
    if( is_file( $path ) ) {
694
      $this->iterateInflated(
695
        $path,
696
        function( $c, $head ) use ( &$size ) {
697
          if( $head !== '' ) {
698
            $parts = explode( ' ', $head );
699
            $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
700
          }
701
702
          return false;
703
        }
704
      );
705
    }
706
707
    return $size;
708
  }
709
710
  public function collectObjects( array $wants, array $haves = [] ): array {
711
    $objs   = $this->traverseObjects( $wants );
712
    $result = [];
713
714
    if( !empty( $haves ) ) {
715
      $haveObjs = $this->traverseObjects( $haves );
716
717
      foreach( $haveObjs as $sha => $type ) {
718
        if( isset( $objs[$sha] ) ) {
719
          unset( $objs[$sha] );
720
        }
721
      }
722
    }
723
724
    $result = $objs;
725
726
    return $result;
727
  }
728
729
  private function traverseObjects( array $roots ): array {
730
    $objs  = [];
731
    $queue = [];
732
733
    foreach( $roots as $sha ) {
734
      $queue[] = [ 'sha' => $sha, 'type' => 0 ];
735
    }
736
737
    while( !empty( $queue ) ) {
738
      $item = array_pop( $queue );
739
      $sha  = $item['sha'];
740
      $type = $item['type'];
741
742
      if( isset( $objs[$sha] ) ) {
743
        continue;
744
      }
745
746
      $data = '';
747
748
      if( $type !== 3 ) {
749
        $data = $this->read( $sha );
750
751
        if( $type === 0 ) {
752
          $type = $this->getObjectType( $data );
753
        }
754
      }
755
756
      $objs[$sha] = $type;
757
758
      if( $type === 1 ) {
759
        $hasTree = preg_match( '/^tree ([0-9a-f]{40})/m', $data, $m );
760
761
        if( $hasTree ) {
762
          $queue[] = [ 'sha' => $m[1], 'type' => 2 ];
763
        }
764
765
        $hasParents = preg_match_all(
766
          '/^parent ([0-9a-f]{40})/m',
767
          $data,
768
          $m
769
        );
770
771
        if( $hasParents ) {
772
          foreach( $m[1] as $parentSha ) {
773
            $queue[] = [ 'sha' => $parentSha, 'type' => 1 ];
774
          }
775
        }
776
      } elseif( $type === 2 ) {
777
        $pos = 0;
778
        $len = strlen( $data );
779
780
        while( $pos < $len ) {
781
          $space = strpos( $data, ' ', $pos );
782
          $eos   = strpos( $data, "\0", $space );
783
784
          if( $space === false || $eos === false ) {
785
            break;
786
          }
787
788
          $mode = substr( $data, $pos, $space - $pos );
789
          $hash = bin2hex( substr( $data, $eos + 1, 20 ) );
790
791
          if( $mode !== '160000' ) {
792
            $isDir   = $mode === '40000' || $mode === '040000';
793
            $queue[] = [ 'sha' => $hash, 'type' => $isDir ? 2 : 3 ];
794
          }
795
796
          $pos = $eos + 21;
797
        }
798
      } elseif( $type === 4 ) {
799
        $isTagTgt = preg_match( '/^object ([0-9a-f]{40})/m', $data, $m );
800
801
        if( $isTagTgt ) {
802
          $nextType = 1;
803
804
          if( preg_match( '/^type (commit|tree|blob|tag)/m', $data, $t ) ) {
805
            $map      = [
806
              'commit' => 1,
807
              'tree'   => 2,
808
              'blob'   => 3,
809
              'tag'    => 4
810
            ];
811
            $nextType = $map[$t[1]] ?? 1;
812
          }
813
814
          $queue[] = [ 'sha' => $m[1], 'type' => $nextType ];
815
        }
816
      }
817
    }
818
819
    return $objs;
820
  }
821
822
  private function getObjectType( string $data ): int {
823
    $isTree = strpos( $data, "tree " ) === 0;
824
    $isObj  = strpos( $data, "object " ) === 0;
825
    $result = 3;
826
827
    if( $isTree ) {
828
      $result = 1;
829
    } elseif( $isObj ) {
830
      $result = 4;
831
    } elseif( $this->isTreeData( $data ) ) {
832
      $result = 2;
833
    }
834
835
    return $result;
836
  }
837
}
838
839
class MissingFile extends File {
840
  public function __construct() {
841
    parent::__construct( '', '', '0', 0, 0, '' );
842
  }
843
844
  public function emitRawHeaders(): void {
845
    header( "HTTP/1.1 404 Not Found" );
846
    exit;
847
  }
848
}
849
2
require_once __DIR__ . '/../model/File.php';
3
require_once __DIR__ . '/../model/Tag.php';
4
require_once __DIR__ . '/../model/Commit.php';
5
require_once __DIR__ . '/GitRefs.php';
6
require_once __DIR__ . '/GitPacks.php';
7
require_once __DIR__ . '/LooseObjects.php';
8
require_once __DIR__ . '/PackfileWriter.php';
9
10
class Git {
11
  private const MAX_READ = 1048576;
12
13
  private string         $repoPath;
14
  private GitRefs        $refs;
15
  private GitPacks       $packs;
16
  private LooseObjects   $loose;
17
  private PackfileWriter $packWriter;
18
19
  public function __construct( string $repoPath ) {
20
    $this->setRepository( $repoPath );
21
  }
22
23
  public function setRepository(
24
    string $repoPath
25
  ): void {
26
    $this->repoPath = \rtrim( $repoPath, '/' );
27
28
    $objPath          = $this->repoPath . '/objects';
29
    $this->refs       = new GitRefs( $this->repoPath );
30
    $this->packs      = new GitPacks( $objPath );
31
    $this->loose      = new LooseObjects( $objPath );
32
    $this->packWriter = new PackfileWriter(
33
      $this->packs, $this->loose
34
    );
35
  }
36
37
  public function resolve(
38
    string $reference
39
  ): string {
40
    return $this->refs->resolve( $reference );
41
  }
42
43
  public function getMainBranch(): array {
44
    return $this->refs->getMainBranch();
45
  }
46
47
  public function eachBranch(
48
    callable $callback
49
  ): void {
50
    $this->refs->scanRefs(
51
      'refs/heads', $callback
52
    );
53
  }
54
55
  public function eachTag(
56
    callable $callback
57
  ): void {
58
    $this->refs->scanRefs(
59
      'refs/tags',
60
      function( $name, $sha ) use ( $callback ) {
61
        $callback(
62
          $this->parseTagData(
63
            $name, $sha, $this->read( $sha )
64
          )
65
        );
66
      }
67
    );
68
  }
69
70
  public function walk(
71
    string $refOrSha,
72
    callable $callback,
73
    string $path = ''
74
  ): void {
75
    $sha     = $this->resolve( $refOrSha );
76
    $treeSha = $sha !== ''
77
      ? $this->getTreeSha( $sha )
78
      : '';
79
80
    if( $path !== '' && $treeSha !== '' ) {
81
      $info    = $this->resolvePath(
82
        $treeSha, $path
83
      );
84
      $treeSha = $info['isDir'] ? $info['sha'] : '';
85
    }
86
87
    if( $treeSha !== '' ) {
88
      $this->walkTree( $treeSha, $callback );
89
    }
90
  }
91
92
  public function readFile(
93
    string $ref,
94
    string $path
95
  ): File {
96
    $sha  = $this->resolve( $ref );
97
    $tree = $sha !== ''
98
      ? $this->getTreeSha( $sha )
99
      : '';
100
    $info = $tree !== ''
101
      ? $this->resolvePath( $tree, $path )
102
      : [];
103
104
    return isset( $info['sha'] )
105
      && !$info['isDir']
106
      && $info['sha'] !== ''
107
      ? new File(
108
        \basename( $path ),
109
        $info['sha'],
110
        $info['mode'],
111
        0,
112
        $this->getObjectSize( $info['sha'] ),
113
        $this->peek( $info['sha'] )
114
      )
115
      : new MissingFile();
116
  }
117
118
  public function getObjectSize(
119
    string $sha,
120
    string $path = ''
121
  ): int {
122
    $target = $sha;
123
124
    if( $path !== '' ) {
125
      $info   = $this->resolvePath(
126
        $this->getTreeSha(
127
          $this->resolve( $sha )
128
        ),
129
        $path
130
      );
131
      $target = $info['sha'] ?? '';
132
    }
133
134
    return $target !== ''
135
      ? $this->packs->getSize( $target )
136
        ?: $this->loose->getSize( $target )
137
      : 0;
138
  }
139
140
  public function stream(
141
    string $sha,
142
    callable $callback,
143
    string $path = ''
144
  ): void {
145
    $target = $sha;
146
147
    if( $path !== '' ) {
148
      $info   = $this->resolvePath(
149
        $this->getTreeSha(
150
          $this->resolve( $sha )
151
        ),
152
        $path
153
      );
154
      $target = isset( $info['isDir'] )
155
        && !$info['isDir']
156
        ? $info['sha']
157
        : '';
158
    }
159
160
    if( $target !== '' ) {
161
      $this->slurp( $target, $callback );
162
    }
163
  }
164
165
  public function peek(
166
    string $sha,
167
    int $length = 255
168
  ): string {
169
    return $this->packs->getSize( $sha ) > 0
170
      ? $this->packs->peek( $sha, $length )
171
      : $this->loose->peek( $sha, $length );
172
  }
173
174
  public function read( string $sha ): string {
175
    $size    = $this->getObjectSize( $sha );
176
    $content = '';
177
178
    if( $size > 0 && $size <= self::MAX_READ ) {
179
      $this->slurp(
180
        $sha,
181
        function( $chunk ) use ( &$content ) {
182
          $content .= $chunk;
183
        }
184
      );
185
    }
186
187
    return $content;
188
  }
189
190
  public function history(
191
    string $ref,
192
    int $limit,
193
    callable $callback
194
  ): void {
195
    $sha   = $this->resolve( $ref );
196
    $count = 0;
197
    $done  = false;
198
199
    while(
200
      !$done && $sha !== '' && $count < $limit
201
    ) {
202
      $data = $this->read( $sha );
203
204
      if( $data === '' ) {
205
        $done = true;
206
      } else {
207
        $id        = $this->parseIdentity(
208
          $data, '/^author (.*) <(.*)> (\d+)/m'
209
        );
210
        $parentSha = $this->extractPattern(
211
          $data, '/^parent (.*)$/m', 1
212
        );
213
214
        $commit = new Commit(
215
          $sha,
216
          $this->extractMessage( $data ),
217
          $id['name'],
218
          $id['email'],
219
          $id['timestamp'],
220
          $parentSha
221
        );
222
223
        if( $callback( $commit ) === false ) {
224
          $done = true;
225
        } else {
226
          $sha = $parentSha;
227
          $count++;
228
        }
229
      }
230
    }
231
  }
232
233
  public function streamRaw(
234
    string $subPath
235
  ): bool {
236
    $result = false;
237
238
    if( \strpos( $subPath, '..' ) === false ) {
239
      $path = "{$this->repoPath}/$subPath";
240
241
      if( \is_file( $path ) ) {
242
        $real = \realpath( $path );
243
        $repo = \realpath( $this->repoPath );
244
245
        if(
246
          $real !== false
247
          && \strpos( $real, $repo ) === 0
248
        ) {
249
          \header(
250
            'X-Accel-Redirect: ' . $path
251
          );
252
          \header(
253
            'Content-Type: application/octet-stream'
254
          );
255
          $result = true;
256
        }
257
      }
258
    }
259
260
    return $result;
261
  }
262
263
  public function eachRef(
264
    callable $callback
265
  ): void {
266
    $head = $this->resolve( 'HEAD' );
267
268
    if( $head !== '' ) {
269
      $callback( 'HEAD', $head );
270
    }
271
272
    $this->refs->scanRefs(
273
      'refs/heads',
274
      function( $n, $s ) use ( $callback ) {
275
        $callback( "refs/heads/$n", $s );
276
      }
277
    );
278
279
    $this->refs->scanRefs(
280
      'refs/tags',
281
      function( $n, $s ) use ( $callback ) {
282
        $callback( "refs/tags/$n", $s );
283
      }
284
    );
285
  }
286
287
  public function generatePackfile(
288
    array $objs
289
  ): Generator {
290
    yield from $this->packWriter->generate(
291
      $objs
292
    );
293
  }
294
295
  public function collectObjects(
296
    array $wants,
297
    array $haves = []
298
  ): array {
299
    $objs = $this->traverseObjects( $wants );
300
301
    if( !empty( $haves ) ) {
302
      foreach(
303
        $this->traverseObjects(
304
          $haves
305
        ) as $sha => $type
306
      ) {
307
        unset( $objs[$sha] );
308
      }
309
    }
310
311
    return $objs;
312
  }
313
314
  public function parseTreeData(
315
    string $data,
316
    callable $callback
317
  ): void {
318
    $pos = 0;
319
    $len = \strlen( $data );
320
321
    while( $pos < $len ) {
322
      $space = \strpos( $data, ' ', $pos );
323
      $eos   = \strpos( $data, "\0", $space );
324
325
      if(
326
        $space === false
327
        || $eos === false
328
        || $eos + 21 > $len
329
      ) {
330
        break;
331
      }
332
333
      $mode = \substr(
334
        $data, $pos, $space - $pos
335
      );
336
      $name = \substr(
337
        $data, $space + 1, $eos - $space - 1
338
      );
339
      $sha  = \bin2hex(
340
        \substr( $data, $eos + 1, 20 )
341
      );
342
343
      if(
344
        $callback( $name, $sha, $mode ) === false
345
      ) {
346
        break;
347
      }
348
349
      $pos = $eos + 21;
350
    }
351
  }
352
353
  private function slurp(
354
    string $sha,
355
    callable $callback
356
  ): void {
357
    if(
358
      !$this->loose->stream( $sha, $callback )
359
      && !$this->packs->stream(
360
        $sha, $callback
361
      )
362
    ) {
363
      $data = $this->packs->read( $sha );
364
365
      if( $data !== '' ) {
366
        $callback( $data );
367
      }
368
    }
369
  }
370
371
  private function walkTree(
372
    string $sha,
373
    callable $callback
374
  ): void {
375
    $data = $this->read( $sha );
376
    $tree = $data !== ''
377
      && \preg_match(
378
        '/^tree (.*)$/m', $data, $m
379
      )
380
      ? $this->read( $m[1] )
381
      : $data;
382
383
    if(
384
      $tree !== ''
385
      && $this->isTreeData( $tree )
386
    ) {
387
      $this->parseTreeData(
388
        $tree,
389
        function(
390
          $n, $s, $m
391
        ) use ( $callback ) {
392
          $dir   = $m === '40000'
393
            || $m === '040000';
394
          $isSub = $m === '160000';
395
396
          $callback( new File(
397
            $n,
398
            $s,
399
            $m,
400
            0,
401
            $dir || $isSub
402
              ? 0
403
              : $this->getObjectSize( $s ),
404
            $dir || $isSub
405
              ? ''
406
              : $this->peek( $s )
407
          ) );
408
        }
409
      );
410
    }
411
  }
412
413
  private function isTreeData(
414
    string $data
415
  ): bool {
416
    $len   = \strlen( $data );
417
    $match = $len >= 25
418
      && \preg_match(
419
        '/^(40000|100644|100755|120000|160000) /',
420
        $data
421
      );
422
    $eos   = $match
423
      ? \strpos( $data, "\0" )
424
      : false;
425
426
    return $match
427
      && $eos !== false
428
      && $eos + 21 <= $len;
429
  }
430
431
  private function getTreeSha(
432
    string $commitOrTreeSha
433
  ): string {
434
    $data = $this->read( $commitOrTreeSha );
435
    $sha  = $commitOrTreeSha;
436
437
    if(
438
      \preg_match(
439
        '/^object ([0-9a-f]{40})/m',
440
        $data,
441
        $matches
442
      )
443
    ) {
444
      $sha = $this->getTreeSha( $matches[1] );
445
    }
446
447
    if(
448
      $sha === $commitOrTreeSha
449
      && \preg_match(
450
        '/^tree ([0-9a-f]{40})/m',
451
        $data,
452
        $matches
453
      )
454
    ) {
455
      $sha = $matches[1];
456
    }
457
458
    return $sha;
459
  }
460
461
  private function resolvePath(
462
    string $treeSha,
463
    string $path
464
  ): array {
465
    $parts = \explode(
466
      '/', \trim( $path, '/' )
467
    );
468
469
    $sha  = $treeSha;
470
    $mode = '40000';
471
472
    foreach( $parts as $part ) {
473
      $entry = $part !== '' && $sha !== ''
474
        ? $this->findTreeEntry( $sha, $part )
475
        : [ 'sha' => '', 'mode' => '' ];
476
477
      $sha  = $entry['sha'];
478
      $mode = $entry['mode'];
479
    }
480
481
    return [
482
      'sha'   => $sha,
483
      'mode'  => $mode,
484
      'isDir' => $mode === '40000'
485
        || $mode === '040000'
486
    ];
487
  }
488
489
  private function findTreeEntry(
490
    string $treeSha,
491
    string $name
492
  ): array {
493
    $entry = [ 'sha' => '', 'mode' => '' ];
494
495
    $this->parseTreeData(
496
      $this->read( $treeSha ),
497
      function(
498
        $n, $s, $m
499
      ) use ( $name, &$entry ) {
500
        if( $n === $name ) {
501
          $entry = [
502
            'sha'  => $s,
503
            'mode' => $m
504
          ];
505
506
          return false;
507
        }
508
      }
509
    );
510
511
    return $entry;
512
  }
513
514
  private function parseTagData(
515
    string $name,
516
    string $sha,
517
    string $data
518
  ): Tag {
519
    $isAnn = \strncmp(
520
      $data, 'object ', 7
521
    ) === 0;
522
523
    $id = $this->parseIdentity(
524
      $data,
525
      $isAnn
526
        ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
527
        : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
528
    );
529
530
    return new Tag(
531
      $name,
532
      $sha,
533
      $isAnn
534
        ? $this->extractPattern(
535
            $data,
536
            '/^object (.*)$/m',
537
            1,
538
            $sha
539
          )
540
        : $sha,
541
      $id['timestamp'],
542
      $this->extractMessage( $data ),
543
      $id['name']
544
    );
545
  }
546
547
  private function extractPattern(
548
    string $data,
549
    string $pattern,
550
    int $group,
551
    string $default = ''
552
  ): string {
553
    return \preg_match(
554
      $pattern, $data, $matches
555
    )
556
      ? $matches[$group]
557
      : $default;
558
  }
559
560
  private function parseIdentity(
561
    string $data,
562
    string $pattern
563
  ): array {
564
    $found = \preg_match(
565
      $pattern, $data, $matches
566
    );
567
568
    return [
569
      'name'      => $found
570
        ? \trim( $matches[1] )
571
        : 'Unknown',
572
      'email'     => $found
573
        ? $matches[2]
574
        : '',
575
      'timestamp' => $found
576
        ? (int)$matches[3]
577
        : 0
578
    ];
579
  }
580
581
  private function extractMessage(
582
    string $data
583
  ): string {
584
    $pos = \strpos( $data, "\n\n" );
585
586
    return $pos !== false
587
      ? \trim( \substr( $data, $pos + 2 ) )
588
      : '';
589
  }
590
591
  private function traverseObjects(
592
    array $roots
593
  ): array {
594
    $objs  = [];
595
    $queue = [];
596
597
    foreach( $roots as $sha ) {
598
      $queue[] = [
599
        'sha'  => $sha,
600
        'type' => 0
601
      ];
602
    }
603
604
    while( !empty( $queue ) ) {
605
      $item = \array_pop( $queue );
606
      $sha  = $item['sha'];
607
      $type = $item['type'];
608
609
      if( !isset( $objs[$sha] ) ) {
610
        $data = $type !== 3
611
          ? $this->read( $sha )
612
          : '';
613
        $type = $type === 0
614
          ? $this->getObjectType( $data )
615
          : $type;
616
617
        $objs[$sha] = $type;
618
619
        if( $type === 1 ) {
620
          if(
621
            \preg_match(
622
              '/^tree ([0-9a-f]{40})/m',
623
              $data,
624
              $m
625
            )
626
          ) {
627
            $queue[] = [
628
              'sha'  => $m[1],
629
              'type' => 2
630
            ];
631
          }
632
633
          if(
634
            \preg_match_all(
635
              '/^parent ([0-9a-f]{40})/m',
636
              $data,
637
              $m
638
            )
639
          ) {
640
            foreach( $m[1] as $parentSha ) {
641
              $queue[] = [
642
                'sha'  => $parentSha,
643
                'type' => 1
644
              ];
645
            }
646
          }
647
        } elseif( $type === 2 ) {
648
          $this->parseTreeData(
649
            $data,
650
            function(
651
              $n, $s, $m
652
            ) use ( &$queue ) {
653
              if( $m !== '160000' ) {
654
                $queue[] = [
655
                  'sha'  => $s,
656
                  'type' => $m === '40000'
657
                    || $m === '040000'
658
                    ? 2
659
                    : 3
660
                ];
661
              }
662
            }
663
          );
664
        } elseif( $type === 4 ) {
665
          if(
666
            \preg_match(
667
              '/^object ([0-9a-f]{40})/m',
668
              $data,
669
              $m
670
            )
671
          ) {
672
            $nextType = 1;
673
674
            if(
675
              \preg_match(
676
                '/^type (commit|tree|blob|tag)/m',
677
                $data,
678
                $t
679
              )
680
            ) {
681
              $map = [
682
                'commit' => 1,
683
                'tree'   => 2,
684
                'blob'   => 3,
685
                'tag'    => 4
686
              ];
687
688
              $nextType = $map[$t[1]] ?? 1;
689
            }
690
691
            $queue[] = [
692
              'sha'  => $m[1],
693
              'type' => $nextType
694
            ];
695
          }
696
        }
697
      }
698
    }
699
700
    return $objs;
701
  }
702
703
  private function getObjectType(
704
    string $data
705
  ): int {
706
    $result = 3;
707
708
    if( \strpos( $data, "tree " ) === 0 ) {
709
      $result = 1;
710
    } elseif(
711
      \strpos( $data, "object " ) === 0
712
    ) {
713
      $result = 4;
714
    } elseif( $this->isTreeData( $data ) ) {
715
      $result = 2;
716
    }
717
718
    return $result;
719
  }
720
}
721
722
class MissingFile extends File {
723
  public function __construct() {
724
    parent::__construct(
725
      '', '', '0', 0, 0, ''
726
    );
727
  }
728
729
  public function emitRawHeaders(): void {
730
    \header( "HTTP/1.1 404 Not Found" );
731
    exit;
732
  }
733
}
850734
M git/GitDiff.php
11
<?php
2
require_once __DIR__ . '/../File.php';
3
4
class GitDiff {
5
  private Git $git;
6
  private const MAX_DIFF_SIZE = 262144;
7
8
  public function __construct( Git $git ) {
9
    $this->git = $git;
10
  }
11
12
  public function diff( string $oldSha, string $newSha ): Generator {
13
    $oldTree = $oldSha !== '' ? $this->getTreeHash( $oldSha ) : '';
14
    $newTree = $newSha !== '' ? $this->getTreeHash( $newSha ) : '';
15
16
    yield from $this->diffTrees( $oldTree, $newTree );
17
  }
18
19
  public function compare( string $commitHash ): Generator {
20
    $commitData = $this->git->read( $commitHash );
21
    $parentHash = '';
22
23
    if( preg_match( '/^parent ([0-9a-f]{40})/m', $commitData, $m ) ) {
24
      $parentHash = $m[1];
25
    }
26
27
    $newTree = $this->getTreeHash( $commitHash );
28
    $oldTree = $parentHash !== '' ? $this->getTreeHash( $parentHash ) : '';
29
30
    yield from $this->diffTrees( $oldTree, $newTree );
31
  }
32
33
  private function getTreeHash( string $commitSha ): string {
34
    $data   = $this->git->read( $commitSha );
35
    $result = '';
36
37
    if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
38
      $result = $matches[1];
39
    }
40
41
    return $result;
42
  }
43
44
  private function diffTrees(
45
    string $oldTreeSha,
46
    string $newTreeSha,
47
    string $path = ''
48
  ): Generator {
49
    if( $oldTreeSha !== $newTreeSha ) {
50
      $oldEntries = $oldTreeSha !== ''
51
        ? $this->parseTree( $oldTreeSha )
52
        : [];
53
      $newEntries = $newTreeSha !== ''
54
        ? $this->parseTree( $newTreeSha )
55
        : [];
56
      $allNames   = array_unique(
57
        array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) )
58
      );
59
60
      sort( $allNames );
61
62
      foreach( $allNames as $name ) {
63
        $old         = $oldEntries[$name] ?? null;
64
        $new         = $newEntries[$name] ?? null;
65
        $currentPath = $path !== '' ? "$path/$name" : $name;
66
67
        if( !$old && $new ) {
68
          if( $new['file']->isDir() ) {
69
            yield from $this->diffTrees( '', $new['sha'], $currentPath );
70
          } else {
71
            yield $this->createChange(
72
              'A',
73
              $currentPath,
74
              '',
75
              $new['sha'],
76
              null,
77
              $new['file']
78
            );
79
          }
80
        } elseif( !$new && $old ) {
81
          if( $old['file']->isDir() ) {
82
            yield from $this->diffTrees( $old['sha'], '', $currentPath );
83
          } else {
84
            yield $this->createChange(
85
              'D',
86
              $currentPath,
87
              $old['sha'],
88
              '',
89
              $old['file'],
90
              null
91
            );
92
          }
93
        } elseif( $old && $new && $old['sha'] !== $new['sha'] ) {
94
          if( $old['file']->isDir() && $new['file']->isDir() ) {
95
            yield from $this->diffTrees(
96
              $old['sha'],
97
              $new['sha'],
98
              $currentPath
99
            );
100
          } elseif( !$old['file']->isDir() && !$new['file']->isDir() ) {
101
            yield $this->createChange(
102
              'M',
103
              $currentPath,
104
              $old['sha'],
105
              $new['sha'],
106
              $old['file'],
107
              $new['file']
108
            );
109
          }
110
        }
111
      }
112
    }
113
  }
114
115
  private function parseTree( string $sha ): array {
116
    $data    = $this->git->read( $sha );
117
    $entries = [];
118
119
    $this->git->parseTreeData(
120
      $data,
121
      function( $file, $name, $hash, $mode ) use ( &$entries ) {
122
        $entries[$name] = [
123
          'file' => $file,
124
          'sha'  => $hash
125
        ];
126
      }
127
    );
128
129
    return $entries;
130
  }
131
132
  private function createChange(
133
    string $type,
134
    string $path,
135
    string $oldSha,
136
    string $newSha,
137
    ?File $oldFile = null,
138
    ?File $newFile = null
139
  ): array {
140
    $oldSize = $oldSha !== '' ? $this->git->getObjectSize( $oldSha ) : 0;
141
    $newSize = $newSha !== '' ? $this->git->getObjectSize( $newSha ) : 0;
142
    $result  = [];
143
144
    if( $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE ) {
145
      $result = [
146
        'type'      => $type,
147
        'path'      => $path,
148
        'is_binary' => true,
149
        'hunks'     => []
150
      ];
151
    } else {
152
      $oldContent = $oldSha !== '' ? $this->git->read( $oldSha ) : '';
153
      $newContent = $newSha !== '' ? $this->git->read( $newSha ) : '';
154
      $isBinary   = false;
155
156
      if( $newFile !== null ) {
157
        $isBinary = $newFile->isBinary();
158
      } elseif( $oldFile !== null ) {
159
        $isBinary = $oldFile->isBinary();
160
      }
161
162
      $result = [
163
        'type'      => $type,
164
        'path'      => $path,
165
        'is_binary' => $isBinary,
166
        'hunks'     => $isBinary
167
          ? null
168
          : $this->calculateDiff( $oldContent, $newContent )
169
      ];
170
    }
171
172
    return $result;
173
  }
174
175
  private function calculateDiff( string $old, string $new ): array {
176
    $oldLines = explode( "\n", str_replace( "\r\n", "\n", $old ) );
177
    $newLines = explode( "\n", str_replace( "\r\n", "\n", $new ) );
178
    $m        = count( $oldLines );
179
    $n        = count( $newLines );
180
    $start    = 0;
181
    $end      = 0;
182
183
    while( $start < $m && $start < $n &&
184
           $oldLines[$start] === $newLines[$start] ) {
185
      $start++;
186
    }
187
188
    while( $m - $end > $start && $n - $end > $start &&
189
           $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end] ) {
190
      $end++;
191
    }
192
193
    $context = 2;
194
    $limit   = 100000;
195
    $stream  = [];
196
197
    $pStart = max( 0, $start - $context );
198
199
    for( $i = $pStart; $i < $start; $i++ ) {
200
      $stream[] = [
201
        't'  => ' ',
202
        'l'  => $oldLines[$i],
203
        'no' => $i + 1,
204
        'nn' => $i + 1
205
      ];
206
    }
207
208
    $oldSlice = array_slice( $oldLines, $start, $m - $start - $end );
209
    $newSlice = array_slice( $newLines, $start, $n - $start - $end );
210
    $mid      = [];
211
212
    if( (count( $oldSlice ) * count( $newSlice )) > $limit ) {
213
      $mid = $this->buildFallbackDiff( $oldSlice, $newSlice, $start );
214
    } else {
215
      $ops = $this->computeLCS( $oldSlice, $newSlice );
216
      $mid = $this->buildDiffStream( $ops, $start );
217
    }
218
219
    foreach( $mid as $line ) {
220
      $stream[] = $line;
221
    }
222
223
    $sLimit = min( $end, $context );
224
225
    for( $i = 0; $i < $sLimit; $i++ ) {
226
      $idxO = $m - $end + $i;
227
      $idxN = $n - $end + $i;
228
      $stream[] = [
229
        't'  => ' ',
230
        'l'  => $oldLines[$idxO],
231
        'no' => $idxO + 1,
232
        'nn' => $idxN + 1
233
      ];
234
    }
235
236
    return $this->formatDiffOutput( $stream );
237
  }
238
239
  private function formatDiffOutput( array $stream ): array {
240
    $n       = count( $stream );
241
    $keep    = array_fill( 0, $n, false );
242
    $context = 2;
243
244
    for( $i = 0; $i < $n; $i++ ) {
245
      if( $stream[$i]['t'] !== ' ' ) {
246
        $low  = max( 0, $i - $context );
247
        $high = min( $n - 1, $i + $context );
248
249
        for( $j = $low; $j <= $high; $j++ ) {
250
          $keep[$j] = true;
251
        }
252
      }
253
    }
254
255
    $result = [];
256
    $buffer = [];
257
258
    for( $i = 0; $i < $n; $i++ ) {
259
      if( $keep[$i] ) {
260
        $cnt = count( $buffer );
261
262
        if( $cnt > 0 ) {
263
          if( $cnt > 5 ) {
264
            $result[] = [ 't' => 'gap' ];
265
          } else {
266
            foreach( $buffer as $bufLine ) {
267
              $result[] = $bufLine;
268
            }
269
          }
270
271
          $buffer = [];
272
        }
273
274
        $result[] = $stream[$i];
275
      } else {
276
        $buffer[] = $stream[$i];
277
      }
278
    }
279
280
    $cnt = count( $buffer );
281
282
    if( $cnt > 0 ) {
283
      if( $cnt > 5 ) {
284
        $result[] = [ 't' => 'gap' ];
285
      } else {
286
        foreach( $buffer as $bufLine ) {
287
          $result[] = $bufLine;
288
        }
289
      }
290
    }
291
292
    return $result;
293
  }
294
295
  private function buildFallbackDiff(
296
    array $old,
297
    array $new,
298
    int $offset
299
  ): array {
300
    $stream = [];
301
    $currO  = $offset + 1;
302
    $currN  = $offset + 1;
303
304
    foreach( $old as $line ) {
305
      $stream[] = [
306
        't'  => '-',
307
        'l'  => $line,
308
        'no' => $currO++,
309
        'nn' => null
310
      ];
311
    }
312
313
    foreach( $new as $line ) {
314
      $stream[] = [
315
        't'  => '+',
316
        'l'  => $line,
317
        'no' => null,
318
        'nn' => $currN++
319
      ];
320
    }
321
322
    return $stream;
323
  }
324
325
  private function buildDiffStream( array $ops, int $start ): array {
326
    $stream = [];
327
    $currO  = $start + 1;
328
    $currN  = $start + 1;
329
330
    foreach( $ops as $op ) {
331
      $stream[] = [
332
        't'  => $op['t'],
333
        'l'  => $op['l'],
334
        'no' => $op['t'] === '+' ? null : $currO++,
335
        'nn' => $op['t'] === '-' ? null : $currN++
336
      ];
337
    }
338
339
    return $stream;
340
  }
341
342
  private function computeLCS( array $old, array $new ): array {
343
    $m = count( $old );
344
    $n = count( $new );
345
    $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) );
346
347
    for( $i = 1; $i <= $m; $i++ ) {
348
      for( $j = 1; $j <= $n; $j++ ) {
349
        $c[$i][$j] = ($old[$i - 1] === $new[$j - 1])
2
class GitDiff {
3
  private const MAX_DIFF_SIZE = 262144;
4
5
  private Git $git;
6
7
  public function __construct( Git $git ) {
8
    $this->git = $git;
9
  }
10
11
  public function diff( string $oldSha, string $newSha ): Generator {
12
    $oldTree = $oldSha === '' ? '' : $this->getTreeHash( $oldSha );
13
    $newTree = $newSha === '' ? '' : $this->getTreeHash( $newSha );
14
15
    yield from $this->diffTrees( $oldTree, $newTree );
16
  }
17
18
  public function compare( string $commitHash ): Generator {
19
    $commitData = $this->git->read( $commitHash );
20
    $parentHash = '';
21
22
    if( preg_match( '/^parent ([0-9a-f]{40})/m', $commitData, $m ) ) {
23
      $parentHash = $m[1];
24
    }
25
26
    $newTree = $this->getTreeHash( $commitHash );
27
    $oldTree = $parentHash === '' ? '' : $this->getTreeHash( $parentHash );
28
29
    yield from $this->diffTrees( $oldTree, $newTree );
30
  }
31
32
  private function getTreeHash( string $commitSha ): string {
33
    $data   = $this->git->read( $commitSha );
34
    $result = '';
35
36
    if( preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
37
      $result = $matches[1];
38
    }
39
40
    return $result;
41
  }
42
43
  private function diffTrees(
44
    string $oldTreeSha,
45
    string $newTreeSha,
46
    string $path = ''
47
  ): Generator {
48
    if( $oldTreeSha !== $newTreeSha ) {
49
      $oldEntries = $oldTreeSha === '' ? [] : $this->parseTree( $oldTreeSha );
50
      $newEntries = $newTreeSha === '' ? [] : $this->parseTree( $newTreeSha );
51
      $allNames   = array_unique(
52
        array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) )
53
      );
54
55
      sort( $allNames );
56
57
      foreach( $allNames as $name ) {
58
        $old     = $oldEntries[$name] ?? [];
59
        $new     = $newEntries[$name] ?? [];
60
        $oldSha  = $old['sha'] ?? '';
61
        $newSha  = $new['sha'] ?? '';
62
        $oldDir  = $old['is_dir'] ?? false;
63
        $newDir  = $new['is_dir'] ?? false;
64
        $curPath = $path === '' ? $name : "$path/$name";
65
66
        if( $oldSha === '' && $newSha !== '' ) {
67
          if( $newDir ) {
68
            yield from $this->diffTrees( '', $newSha, $curPath );
69
          } else {
70
            yield $this->createChange( 'A', $curPath, $old, $new );
71
          }
72
        } elseif( $newSha === '' && $oldSha !== '' ) {
73
          if( $oldDir ) {
74
            yield from $this->diffTrees( $oldSha, '', $curPath );
75
          } else {
76
            yield $this->createChange( 'D', $curPath, $old, $new );
77
          }
78
        } elseif( $oldSha !== '' && $newSha !== '' && $oldSha !== $newSha ) {
79
          if( $oldDir && $newDir ) {
80
            yield from $this->diffTrees( $oldSha, $newSha, $curPath );
81
          } elseif( !$oldDir && !$newDir ) {
82
            yield $this->createChange( 'M', $curPath, $old, $new );
83
          }
84
        }
85
      }
86
    }
87
  }
88
89
  private function parseTree( string $sha ): array {
90
    $data    = $this->git->read( $sha );
91
    $entries = [];
92
93
    $this->git->parseTreeData(
94
      $data,
95
      function( string $name, string $hash, string $mode ) use ( &$entries ) {
96
        $isDir = $mode === '40000' || $mode === '040000' || $mode === '160000';
97
98
        $entries[$name] = [
99
          'sha'    => $hash,
100
          'is_dir' => $isDir,
101
          'size'   => $isDir ? 0 : $this->git->getObjectSize( $hash ),
102
          'peek'   => $isDir ? '' : $this->git->peek( $hash )
103
        ];
104
      }
105
    );
106
107
    return $entries;
108
  }
109
110
  private function createChange(
111
    string $type,
112
    string $path,
113
    array $old,
114
    array $new
115
  ): array {
116
    $oldSha  = $old['sha'] ?? '';
117
    $newSha  = $new['sha'] ?? '';
118
    $oldSize = $old['size'] ?? 0;
119
    $newSize = $new['size'] ?? 0;
120
    $oldPeek = $old['peek'] ?? '';
121
    $newPeek = $new['peek'] ?? '';
122
    $isBin   = str_contains( $oldPeek, "\0" ) || str_contains( $newPeek, "\0" );
123
    $tooBig  = $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE;
124
    $result  = [];
125
126
    if( $tooBig || $isBin ) {
127
      $result = [
128
        'type'      => $type,
129
        'path'      => $path,
130
        'is_binary' => true,
131
        'hunks'     => []
132
      ];
133
    } else {
134
      $oldContent = $oldSha === '' ? '' : $this->git->read( $oldSha );
135
      $newContent = $newSha === '' ? '' : $this->git->read( $newSha );
136
137
      $result = [
138
        'type'      => $type,
139
        'path'      => $path,
140
        'is_binary' => false,
141
        'hunks'     => $this->calculateDiff( $oldContent, $newContent )
142
      ];
143
    }
144
145
    return $result;
146
  }
147
148
  private function calculateDiff( string $old, string $new ): array {
149
    $oldLines = explode( "\n", str_replace( "\r\n", "\n", $old ) );
150
    $newLines = explode( "\n", str_replace( "\r\n", "\n", $new ) );
151
    $m        = count( $oldLines );
152
    $n        = count( $newLines );
153
    $start    = 0;
154
    $end      = 0;
155
156
    while( $start < $m && $start < $n &&
157
           $oldLines[$start] === $newLines[$start]
158
    ) {
159
      $start++;
160
    }
161
162
    while( $m - $end > $start && $n - $end > $start &&
163
           $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]
164
    ) {
165
      $end++;
166
    }
167
168
    $stream = $this->buildFullStream(
169
      $oldLines,
170
      $newLines,
171
      $m,
172
      $n,
173
      $start,
174
      $end
175
    );
176
177
    return $this->formatDiffOutput( $stream );
178
  }
179
180
  private function buildFullStream(
181
    array $oldLines,
182
    array $newLines,
183
    int $m,
184
    int $n,
185
    int $start,
186
    int $end
187
  ): array {
188
    $context = 2;
189
    $limit   = 100000;
190
    $stream  = [];
191
    $pStart  = max( 0, $start - $context );
192
193
    for( $i = $pStart; $i < $start; $i++ ) {
194
      $stream[] = [
195
        't'  => ' ',
196
        'l'  => $oldLines[$i],
197
        'no' => (string)($i + 1),
198
        'nn' => (string)($i + 1)
199
      ];
200
    }
201
202
    $oldSlice = array_slice( $oldLines, $start, $m - $start - $end );
203
    $newSlice = array_slice( $newLines, $start, $n - $start - $end );
204
    $mid      = count( $oldSlice ) * count( $newSlice ) > $limit
205
      ? $this->buildFallbackDiff( $oldSlice, $newSlice, $start )
206
      : $this->buildDiffStream(
207
          $this->computeLCS( $oldSlice, $newSlice ),
208
          $start
209
        );
210
211
    foreach( $mid as $line ) {
212
      $stream[] = $line;
213
    }
214
215
    $sLimit = min( $end, $context );
216
217
    for( $i = 0; $i < $sLimit; $i++ ) {
218
      $idxO = $m - $end + $i;
219
      $idxN = $n - $end + $i;
220
221
      $stream[] = [
222
        't'  => ' ',
223
        'l'  => $oldLines[$idxO],
224
        'no' => (string)($idxO + 1),
225
        'nn' => (string)($idxN + 1)
226
      ];
227
    }
228
229
    return $stream;
230
  }
231
232
  private function formatDiffOutput( array $stream ): array {
233
    $n       = count( $stream );
234
    $keep    = array_fill( 0, $n, false );
235
    $context = 2;
236
237
    for( $i = 0; $i < $n; $i++ ) {
238
      if( $stream[$i]['t'] !== ' ' ) {
239
        $low  = max( 0, $i - $context );
240
        $high = min( $n - 1, $i + $context );
241
242
        for( $j = $low; $j <= $high; $j++ ) {
243
          $keep[$j] = true;
244
        }
245
      }
246
    }
247
248
    $result = [];
249
    $buffer = [];
250
251
    for( $i = 0; $i < $n; $i++ ) {
252
      if( $keep[$i] ) {
253
        $this->flushBuffer( $result, $buffer );
254
255
        $result[] = $stream[$i];
256
      } else {
257
        $buffer[] = $stream[$i];
258
      }
259
    }
260
261
    $this->flushBuffer( $result, $buffer );
262
263
    return $result;
264
  }
265
266
  private function flushBuffer( array &$result, array &$buffer ): void {
267
    $cnt = count( $buffer );
268
269
    if( $cnt > 0 ) {
270
      if( $cnt > 5 ) {
271
        $result[] = [
272
          't'  => 'gap',
273
          'l'  => '',
274
          'no' => '',
275
          'nn' => ''
276
        ];
277
      } else {
278
        foreach( $buffer as $bufLine ) {
279
          $result[] = $bufLine;
280
        }
281
      }
282
283
      $buffer = [];
284
    }
285
  }
286
287
  private function buildFallbackDiff(
288
    array $old,
289
    array $new,
290
    int $offset
291
  ): array {
292
    $stream = [];
293
    $currO  = $offset + 1;
294
    $currN  = $offset + 1;
295
296
    foreach( $old as $line ) {
297
      $stream[] = [
298
        't'  => '-',
299
        'l'  => $line,
300
        'no' => (string)$currO++,
301
        'nn' => ''
302
      ];
303
    }
304
305
    foreach( $new as $line ) {
306
      $stream[] = [
307
        't'  => '+',
308
        'l'  => $line,
309
        'no' => '',
310
        'nn' => (string)$currN++
311
      ];
312
    }
313
314
    return $stream;
315
  }
316
317
  private function buildDiffStream( array $ops, int $start ): array {
318
    $stream = [];
319
    $currO  = $start + 1;
320
    $currN  = $start + 1;
321
322
    foreach( $ops as $op ) {
323
      $stream[] = [
324
        't'  => $op['t'],
325
        'l'  => $op['l'],
326
        'no' => $op['t'] === '+' ? '' : (string)$currO++,
327
        'nn' => $op['t'] === '-' ? '' : (string)$currN++
328
      ];
329
    }
330
331
    return $stream;
332
  }
333
334
  private function computeLCS( array $old, array $new ): array {
335
    $m = count( $old );
336
    $n = count( $new );
337
    $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) );
338
339
    for( $i = 1; $i <= $m; $i++ ) {
340
      for( $j = 1; $j <= $n; $j++ ) {
341
        $c[$i][$j] = $old[$i - 1] === $new[$j - 1]
350342
          ? $c[$i - 1][$j - 1] + 1
351343
          : max( $c[$i][$j - 1], $c[$i - 1][$j] );
M git/GitPacks.php
11
<?php
2
require_once __DIR__ . '/FileHandlePool.php';
2
require_once __DIR__ . '/PackStreamManager.php';
33
require_once __DIR__ . '/PackLocator.php';
44
require_once __DIR__ . '/DeltaDecoder.php';
55
require_once __DIR__ . '/PackEntryReader.php';
6
require_once __DIR__ . '/PackContext.php';
67
78
class GitPacks {
8
  private const MAX_RAM = 1048576;
9
  private const MAX_RAM = 8388608;
910
10
  private FileHandlePool  $pool;
11
  private PackLocator     $locator;
12
  private PackEntryReader $reader;
11
  private PackStreamManager $manager;
12
  private PackLocator       $locator;
13
  private PackEntryReader   $reader;
14
  private array             $cacheLoc = [
15
    'sha'  => '',
16
    'file' => '',
17
    'off'  => 0
18
  ];
1319
1420
  public function __construct( string $objectsPath ) {
15
    $this->pool    = new FileHandlePool();
16
    $this->locator = new PackLocator( $objectsPath );
17
    $this->reader  = new PackEntryReader( new DeltaDecoder() );
21
    $this->manager = new PackStreamManager();
22
    $this->locator = new PackLocator(
23
      $this->manager, $objectsPath
24
    );
25
    $this->reader  = new PackEntryReader(
26
      new DeltaDecoder()
27
    );
1828
  }
1929
20
  public function peek( string $sha, int $len = 12 ): string {
30
  private function locate(
31
    string $sha,
32
    callable $callback
33
  ): void {
34
    if( $this->cacheLoc['sha'] === $sha ) {
35
      $callback(
36
        $this->cacheLoc['file'],
37
        $this->cacheLoc['off']
38
      );
39
    } else {
40
      $this->locator->locate(
41
        $sha,
42
        function(
43
          string $packFile,
44
          int $offset
45
        ) use ( $sha, $callback ): void {
46
          $this->cacheLoc = [
47
            'sha'  => $sha,
48
            'file' => $packFile,
49
            'off'  => $offset
50
          ];
51
52
          $callback( $packFile, $offset );
53
        }
54
      );
55
    }
56
  }
57
58
  public function getEntryMeta( string $sha ): array {
59
    $result = [
60
      'type'   => 0,
61
      'size'   => 0,
62
      'file'   => '',
63
      'offset' => 0,
64
    ];
65
66
    $this->locate(
67
      $sha,
68
      function(
69
        string $packFile,
70
        int $offset
71
      ) use ( &$result ): void {
72
        $context = $this->createContext(
73
          $packFile, $offset, 0
74
        );
75
        $meta    = $this->reader->getEntryMeta(
76
          $context
77
        );
78
79
        $result           = $meta;
80
        $result['file']   = $packFile;
81
        $result['offset'] = $offset;
82
      }
83
    );
84
85
    return $result;
86
  }
87
88
  public function peek(
89
    string $sha,
90
    int $len = 12
91
  ): string {
2192
    $result = '';
2293
23
    $this->locator->locate(
24
      $this->pool,
94
    $this->locate(
2595
      $sha,
26
      function( string $packFile, int $offset ) use ( &$result, $len ): void {
27
        $result = $this->reader->read(
28
          $this->pool,
29
          $packFile,
30
          $offset,
96
      function(
97
        string $packFile,
98
        int $offset
99
      ) use ( &$result, $len ): void {
100
        $context = $this->createContext(
101
          $packFile, $offset, 0
102
        );
103
        $result  = $this->reader->read(
104
          $context,
31105
          $len,
32
          function( string $baseSha, int $cap ): string {
106
          function(
107
            string $baseSha,
108
            int $cap
109
          ): string {
33110
            return $this->peek( $baseSha, $cap );
34111
          }
...
43120
    $result = '';
44121
45
    $this->locator->locate(
46
      $this->pool,
122
    $this->locate(
47123
      $sha,
48
      function( string $packFile, int $offset ) use ( &$result ): void {
49
        $size = $this->reader->getSize( $this->pool, $packFile, $offset );
124
      function(
125
        string $packFile,
126
        int $offset
127
      ) use ( &$result ): void {
128
        $context = $this->createContext(
129
          $packFile, $offset, 0
130
        );
131
        $size    = $this->reader->getSize( $context );
50132
51133
        if( $size <= self::MAX_RAM ) {
52134
          $result = $this->reader->read(
53
            $this->pool,
54
            $packFile,
55
            $offset,
135
            $context,
56136
            0,
57
            function( string $baseSha, int $cap ): string {
58
              $val = '';
59
60
              if( $cap > 0 ) {
61
                $val = $this->peek( $baseSha, $cap );
62
              } else {
63
                $val = $this->read( $baseSha );
64
              }
65
66
              return $val;
137
            function(
138
              string $baseSha,
139
              int $cap
140
            ): string {
141
              return $cap > 0
142
                ? $this->peek( $baseSha, $cap )
143
                : $this->read( $baseSha );
67144
            }
68145
          );
69146
        }
70147
      }
71148
    );
72149
73150
    return $result;
74151
  }
75152
76
  public function stream( string $sha, callable $callback ): bool {
153
  public function stream(
154
    string $sha,
155
    callable $callback
156
  ): bool {
77157
    $result = false;
78158
79
    foreach( $this->streamGenerator( $sha ) as $chunk ) {
159
    foreach(
160
      $this->streamGenerator( $sha ) as $chunk
161
    ) {
80162
      $callback( $chunk );
81163
82164
      $result = true;
83165
    }
84166
85167
    return $result;
86168
  }
87169
88
  public function streamGenerator( string $sha ): Generator {
170
  public function streamGenerator(
171
    string $sha
172
  ): Generator {
89173
    yield from $this->streamShaGenerator( $sha, 0 );
90174
  }
91175
92
  public function streamRawCompressed( string $sha ): Generator {
176
  public function streamRawCompressed(
177
    string $sha
178
  ): Generator {
93179
    $found = false;
94180
    $file  = '';
95181
    $off   = 0;
96182
97
    $this->locator->locate(
98
      $this->pool,
183
    $this->locate(
99184
      $sha,
100
      function( string $packFile, int $offset ) use (
101
        &$found,
102
        &$file,
103
        &$off
104
      ): void {
185
      function(
186
        string $packFile,
187
        int $offset
188
      ) use ( &$found, &$file, &$off ): void {
105189
        $found = true;
106190
        $file  = $packFile;
107191
        $off   = $offset;
108192
      }
109193
    );
110194
111195
    if( $found ) {
196
      $context = $this->createContext(
197
        $file, $off, 0
198
      );
199
112200
      yield from $this->reader->streamRawCompressed(
113
        $this->pool,
114
        $file,
115
        $off
201
        $context
116202
      );
117203
    }
118204
  }
119205
120
  private function streamShaGenerator( string $sha, int $depth ): Generator {
206
  public function streamRawDelta(
207
    string $sha
208
  ): Generator {
121209
    $found = false;
122210
    $file  = '';
123211
    $off   = 0;
124212
125
    $this->locator->locate(
126
      $this->pool,
213
    $this->locate(
127214
      $sha,
128
      function( string $packFile, int $offset ) use (
129
        &$found,
130
        &$file,
131
        &$off
132
      ): void {
215
      function(
216
        string $packFile,
217
        int $offset
218
      ) use ( &$found, &$file, &$off ): void {
219
        $found = true;
220
        $file  = $packFile;
221
        $off   = $offset;
222
      }
223
    );
224
225
    if( $found ) {
226
      $context = $this->createContext(
227
        $file, $off, 0
228
      );
229
230
      yield from $this->reader->streamRawDelta(
231
        $context
232
      );
233
    }
234
  }
235
236
  private function streamShaGenerator(
237
    string $sha,
238
    int $depth
239
  ): Generator {
240
    $found = false;
241
    $file  = '';
242
    $off   = 0;
243
244
    $this->locate(
245
      $sha,
246
      function(
247
        string $packFile,
248
        int $offset
249
      ) use ( &$found, &$file, &$off ): void {
133250
        $found = true;
134251
        $file  = $packFile;
135252
        $off   = $offset;
136253
      }
137254
    );
138255
139256
    if( $found ) {
257
      $context = $this->createContext(
258
        $file, $off, $depth
259
      );
260
140261
      yield from $this->reader->streamEntryGenerator(
141
        $this->pool,
142
        $file,
143
        $off,
144
        $depth,
145
        function( string $baseSha ): int {
146
          return $this->getSize( $baseSha );
147
        },
148
        function( string $baseSha, int $baseDepth ): Generator {
149
          yield from $this->streamShaGenerator( $baseSha, $baseDepth );
150
        }
262
        $context
151263
      );
152264
    }
153265
  }
154266
155267
  public function getSize( string $sha ): int {
156268
    $result = 0;
157269
158
    $this->locator->locate(
159
      $this->pool,
270
    $this->locate(
160271
      $sha,
161
      function( string $packFile, int $offset ) use ( &$result ): void {
162
        $result = $this->reader->getSize( $this->pool, $packFile, $offset );
272
      function(
273
        string $packFile,
274
        int $offset
275
      ) use ( &$result ): void {
276
        $context = $this->createContext(
277
          $packFile, $offset, 0
278
        );
279
        $result  = $this->reader->getSize( $context );
163280
      }
164281
    );
165282
166283
    return $result;
284
  }
285
286
  private function createContext(
287
    string $packFile,
288
    int $offset,
289
    int $depth
290
  ): PackContext {
291
    return new PackContext(
292
      $this->manager,
293
      $packFile,
294
      $offset,
295
      $depth,
296
      function( string $baseSha ): int {
297
        return $this->getSize( $baseSha );
298
      },
299
      function(
300
        string $baseSha,
301
        int $baseDepth
302
      ): Generator {
303
        yield from $this->streamShaGenerator(
304
          $baseSha, $baseDepth
305
        );
306
      }
307
    );
167308
  }
168309
}
M git/GitRefs.php
11
<?php
2
require_once __DIR__ . '/BufferedFileReader.php';
2
require_once __DIR__ . '/BufferedReader.php';
33
44
class GitRefs {
...
2222
2323
        if( $size > 0 ) {
24
          $reader = BufferedFileReader::open( $headFile );
24
          $reader = new BufferedReader( $headFile );
2525
          $head   = trim( $reader->read( $size ) );
2626
        }
...
9090
9191
          if( $size > 0 ) {
92
            $reader = BufferedFileReader::open( $path );
92
            $reader = new BufferedReader( $path );
9393
            $sha    = trim( $reader->read( $size ) );
9494
...
113113
114114
        if( $size > 0 ) {
115
          $reader = BufferedFileReader::open( $path );
115
          $reader = new BufferedReader( $path );
116116
          $result = trim( $reader->read( $size ) );
117117
        }
...
143143
144144
    if( $size > 0 ) {
145
      $reader = BufferedFileReader::open( $path );
145
      $reader = new BufferedReader( $path );
146146
      $lines  = explode( "\n", $reader->read( $size ) );
147147
    }
A git/LooseObjects.php
1
<?php
2
require_once __DIR__ . '/BufferedReader.php';
3
4
class LooseObjects {
5
  private string $objPath;
6
7
  public function __construct( string $objPath ) {
8
    $this->objPath = $objPath;
9
  }
10
11
  public function getSize( string $sha ): int {
12
    $path = $this->getPath( $sha );
13
    $size = 0;
14
15
    if( \is_file( $path ) ) {
16
      foreach( $this->streamInflated( $path ) as $chunk ) {
17
        $parts = \explode( ' ', $chunk['head'] );
18
19
        $size = isset( $parts[1] )
20
          ? (int)$parts[1]
21
          : 0;
22
23
        break;
24
      }
25
    }
26
27
    return $size;
28
  }
29
30
  public function peek(
31
    string $sha,
32
    int $length = 255
33
  ): string {
34
    $path = $this->getPath( $sha );
35
    $buf  = '';
36
37
    if( \is_file( $path ) ) {
38
      foreach(
39
        $this->streamInflated( $path, 8192 ) as $chunk
40
      ) {
41
        $buf .= $chunk['body'];
42
43
        if( \strlen( $buf ) >= $length ) {
44
          break;
45
        }
46
      }
47
    }
48
49
    return \substr( $buf, 0, $length );
50
  }
51
52
  public function stream(
53
    string $sha,
54
    callable $callback
55
  ): bool {
56
    $found = false;
57
58
    foreach( $this->streamChunks( $sha ) as $chunk ) {
59
      $found = true;
60
      $callback( $chunk );
61
    }
62
63
    return $found;
64
  }
65
66
  public function streamChunks(
67
    string $sha
68
  ): Generator {
69
    $path = $this->getPath( $sha );
70
71
    if( \is_file( $path ) ) {
72
      foreach(
73
        $this->streamInflated( $path ) as $chunk
74
      ) {
75
        if( $chunk['body'] !== '' ) {
76
          yield $chunk['body'];
77
        }
78
      }
79
    }
80
  }
81
82
  private function getPath( string $sha ): string {
83
    return "{$this->objPath}/"
84
      . \substr( $sha, 0, 2 ) . "/"
85
      . \substr( $sha, 2 );
86
  }
87
88
  private function streamInflated(
89
    string $path,
90
    int $bufSz = 16384
91
  ): Generator {
92
    $reader = new BufferedReader( $path );
93
    $infl   = $reader->isOpen()
94
      ? \inflate_init( \ZLIB_ENCODING_DEFLATE )
95
      : false;
96
97
    if( $reader->isOpen() && $infl !== false ) {
98
      $found  = false;
99
      $buffer = '';
100
101
      while( !$reader->eof() ) {
102
        $chunk    = $reader->read( $bufSz );
103
        $inflated = \inflate_add( $infl, $chunk );
104
105
        if( $inflated === false ) {
106
          break;
107
        }
108
109
        if( !$found ) {
110
          $buffer .= $inflated;
111
          $eos     = \strpos( $buffer, "\0" );
112
113
          if( $eos !== false ) {
114
            $found = true;
115
116
            yield [
117
              'head' => \substr(
118
                $buffer, 0, $eos
119
              ),
120
              'body' => \substr(
121
                $buffer, $eos + 1
122
              )
123
            ];
124
          }
125
        } elseif( $inflated !== '' ) {
126
          yield [
127
            'head' => '',
128
            'body' => $inflated
129
          ];
130
        }
131
      }
132
    }
133
  }
134
}
1135
A git/PackContext.php
1
<?php
2
require_once __DIR__ . '/StreamReader.php';
3
require_once __DIR__ . '/PackStreamManager.php';
4
5
class PackContext {
6
  private PackStreamManager $manager;
7
  private string            $packFile;
8
  private int               $offset;
9
  private int               $depth;
10
  private Closure           $sizeResolver;
11
  private Closure           $streamResolver;
12
13
  public function __construct(
14
    PackStreamManager $manager,
15
    string $packFile,
16
    int $offset,
17
    int $depth,
18
    Closure $sizeResolver,
19
    Closure $streamResolver
20
  ) {
21
    $this->manager        = $manager;
22
    $this->packFile       = $packFile;
23
    $this->offset         = $offset;
24
    $this->depth          = $depth;
25
    $this->sizeResolver   = $sizeResolver;
26
    $this->streamResolver = $streamResolver;
27
  }
28
29
  public function deriveOffsetContext(
30
    int $negativeOffset
31
  ): self {
32
    return new self(
33
      $this->manager,
34
      $this->packFile,
35
      $this->offset - $negativeOffset,
36
      $this->depth,
37
      $this->sizeResolver,
38
      $this->streamResolver
39
    );
40
  }
41
42
  public function computeInt(
43
    callable $callback,
44
    int $default
45
  ): int {
46
    return $this->manager->computeInt(
47
      $this->packFile,
48
      function( StreamReader $stream ) use (
49
        $callback
50
      ): int {
51
        return $callback( $stream, $this->offset );
52
      },
53
      $default
54
    );
55
  }
56
57
  public function computeIntDedicated(
58
    callable $callback,
59
    int $default
60
  ): int {
61
    return $this->manager->computeIntDedicated(
62
      $this->packFile,
63
      function( StreamReader $stream ) use (
64
        $callback
65
      ): int {
66
        return $callback( $stream, $this->offset );
67
      },
68
      $default
69
    );
70
  }
71
72
  public function computeStringDedicated(
73
    callable $callback,
74
    string $default
75
  ): string {
76
    return $this->manager->computeStringDedicated(
77
      $this->packFile,
78
      function( StreamReader $stream ) use (
79
        $callback
80
      ): string {
81
        return $callback( $stream, $this->offset );
82
      },
83
      $default
84
    );
85
  }
86
87
  public function computeArray(
88
    callable $callback,
89
    array $default
90
  ): array {
91
    return $this->manager->computeArray(
92
      $this->packFile,
93
      function( StreamReader $stream ) use (
94
        $callback
95
      ): array {
96
        return $callback( $stream, $this->offset );
97
      },
98
      $default
99
    );
100
  }
101
102
  public function streamGenerator(
103
    callable $callback
104
  ): Generator {
105
    yield from $this->manager->streamGenerator(
106
      $this->packFile,
107
      function( StreamReader $stream ) use (
108
        $callback
109
      ): Generator {
110
        yield from $callback(
111
          $stream, $this->offset
112
        );
113
      }
114
    );
115
  }
116
117
  public function streamGeneratorDedicated(
118
    callable $callback
119
  ): Generator {
120
    yield from $this->manager->streamGeneratorDedicated(
121
      $this->packFile,
122
      function( StreamReader $stream ) use (
123
        $callback
124
      ): Generator {
125
        yield from $callback(
126
          $stream, $this->offset
127
        );
128
      }
129
    );
130
  }
131
132
  public function resolveBaseSize( string $sha ): int {
133
    return ($this->sizeResolver)( $sha );
134
  }
135
136
  public function resolveBaseStream(
137
    string $sha
138
  ): Generator {
139
    yield from ($this->streamResolver)(
140
      $sha, $this->depth + 1
141
    );
142
  }
143
144
  public function isWithinDepth( int $maxDepth ): bool {
145
    return $this->depth < $maxDepth;
146
  }
147
}
1148
M git/PackEntryReader.php
11
<?php
2
require_once __DIR__ . '/FileHandlePool.php';
3
require_once __DIR__ . '/DeltaDecoder.php';
4
require_once __DIR__ . '/CompressionStream.php';
5
6
class PackEntryReader {
7
  private const MAX_DEPTH    = 200;
8
  private const MAX_BASE_RAM = 2097152;
9
10
  private DeltaDecoder $decoder;
11
12
  public function __construct( DeltaDecoder $decoder ) {
13
    $this->decoder = $decoder;
14
  }
15
16
  public function getSize(
17
    FileHandlePool $pool,
18
    string $packFile,
19
    int $offset
20
  ): int {
21
    return $pool->computeInt(
22
      $packFile,
23
      function( mixed $handle ) use ( $offset ): int {
24
        fseek( $handle, $offset );
25
26
        $header = $this->readVarInt( $handle );
27
        $size   = $header['value'];
28
        $type   = $header['byte'] >> 4 & 7;
29
30
        if( $type === 6 || $type === 7 ) {
31
          $size = $this->decoder->readDeltaTargetSize( $handle, $type );
32
        }
33
34
        return $size;
35
      },
36
      0
37
    );
38
  }
39
40
  public function read(
41
    FileHandlePool $pool,
42
    string $packFile,
43
    int $offset,
44
    int $cap,
45
    callable $readShaBaseFn
46
  ): string {
47
    return $pool->computeString(
48
      $packFile,
49
      function( mixed $handle ) use (
50
        $offset,
51
        $cap,
52
        $pool,
53
        $packFile,
54
        $readShaBaseFn
55
      ): string {
56
        fseek( $handle, $offset );
57
58
        $header = $this->readVarInt( $handle );
59
        $type   = $header['byte'] >> 4 & 7;
60
        $result = '';
61
62
        if( $type === 6 ) {
63
          $neg    = $this->readOffsetDelta( $handle );
64
          $cur    = ftell( $handle );
65
          $base   = $offset - $neg;
66
          $bData  = $this->read(
67
            $pool,
68
            $packFile,
69
            $base,
70
            $cap,
71
            $readShaBaseFn
72
          );
73
74
          fseek( $handle, $cur );
75
76
          $delta  = $this->inflate( $handle );
77
          $result = $this->decoder->apply( $bData, $delta, $cap );
78
        } elseif( $type === 7 ) {
79
          $sha    = bin2hex( fread( $handle, 20 ) );
80
          $bas    = $readShaBaseFn( $sha, $cap );
81
          $del    = $this->inflate( $handle );
82
          $result = $this->decoder->apply( $bas, $del, $cap );
83
        } else {
84
          $result = $this->inflate( $handle, $cap );
85
        }
86
87
        return $result;
88
      },
89
      ''
90
    );
91
  }
92
93
  public function streamRawCompressed(
94
    FileHandlePool $pool,
95
    string $packFile,
96
    int $offset
97
  ): Generator {
98
    yield from $pool->streamGenerator(
99
      $packFile,
100
      function( mixed $handle ) use ( $offset ): Generator {
101
        fseek( $handle, $offset );
102
103
        $header = $this->readVarInt( $handle );
104
        $type   = $header['byte'] >> 4 & 7;
105
106
        if( $type !== 6 && $type !== 7 ) {
107
          $stream = CompressionStream::createExtractor();
108
109
          yield from $stream->stream( $handle );
110
        }
111
      }
112
    );
113
  }
114
115
  public function streamEntryGenerator(
116
    FileHandlePool $pool,
117
    string $packFile,
118
    int $offset,
119
    int $depth,
120
    callable $getSizeShaFn,
121
    callable $streamShaFn
122
  ): Generator {
123
    yield from $pool->streamGenerator(
124
      $packFile,
125
      function( mixed $handle ) use (
126
        $pool,
127
        $packFile,
128
        $offset,
129
        $depth,
130
        $getSizeShaFn,
131
        $streamShaFn
132
      ): Generator {
133
        fseek( $handle, $offset );
134
135
        $header = $this->readVarInt( $handle );
136
        $type   = $header['byte'] >> 4 & 7;
137
138
        if( $type === 6 || $type === 7 ) {
139
          yield from $this->streamDeltaObjectGenerator(
140
            $handle,
141
            $pool,
142
            $packFile,
143
            $offset,
144
            $type,
145
            $depth,
146
            $getSizeShaFn,
147
            $streamShaFn
148
          );
149
        } else {
150
          $stream = CompressionStream::createInflater();
151
152
          yield from $stream->stream( $handle );
153
        }
154
      }
155
    );
156
  }
157
158
  private function streamDeltaObjectGenerator(
159
    mixed $handle,
160
    FileHandlePool $pool,
161
    string $packFile,
162
    int $offset,
163
    int $type,
164
    int $depth,
165
    callable $getSizeShaFn,
166
    callable $streamShaFn
167
  ): Generator {
168
    if( $depth < self::MAX_DEPTH ) {
169
      if( $type === 6 ) {
170
        $neg      = $this->readOffsetDelta( $handle );
171
        $deltaPos = ftell( $handle );
172
        $baseSize = $this->getSize( $pool, $packFile, $offset - $neg );
173
174
        if( $baseSize > self::MAX_BASE_RAM ) {
175
          $tmpHandle = $this->resolveBaseToTempFile(
176
            $pool,
177
            $packFile,
178
            $offset - $neg,
179
            $depth,
180
            $getSizeShaFn,
181
            $streamShaFn
182
          );
183
184
          if( $tmpHandle !== false ) {
185
            fseek( $handle, $deltaPos );
186
187
            yield from $this->decoder->applyStreamGenerator(
188
              $handle,
189
              $tmpHandle
190
            );
191
192
            fclose( $tmpHandle );
193
          }
194
        } else {
195
          $base = '';
196
197
          foreach( $this->streamEntryGenerator(
198
            $pool,
199
            $packFile,
200
            $offset - $neg,
201
            $depth + 1,
202
            $getSizeShaFn,
203
            $streamShaFn
204
          ) as $chunk ) {
205
            $base .= $chunk;
206
          }
207
208
          fseek( $handle, $deltaPos );
209
210
          yield from $this->decoder->applyStreamGenerator(
211
            $handle,
212
            $base
213
          );
214
        }
215
      } else {
216
        $baseSha  = bin2hex( fread( $handle, 20 ) );
217
        $baseSize = $getSizeShaFn( $baseSha );
218
219
        if( $baseSize > self::MAX_BASE_RAM ) {
220
          $tmpHandle = tmpfile();
221
222
          if( $tmpHandle !== false ) {
223
            $written = false;
224
225
            foreach( $streamShaFn( $baseSha, $depth + 1 ) as $chunk ) {
226
              fwrite( $tmpHandle, $chunk );
227
228
              $written = true;
229
            }
230
231
            if( $written ) {
232
              rewind( $tmpHandle );
233
234
              yield from $this->decoder->applyStreamGenerator(
235
                $handle,
236
                $tmpHandle
237
              );
238
            }
239
240
            fclose( $tmpHandle );
241
          }
242
        } else {
243
          $base    = '';
244
          $written = false;
245
246
          foreach( $streamShaFn( $baseSha, $depth + 1 ) as $chunk ) {
247
            $base    .= $chunk;
248
            $written  = true;
249
          }
250
251
          if( $written ) {
252
            yield from $this->decoder->applyStreamGenerator(
253
              $handle,
254
              $base
255
            );
256
          }
257
        }
258
      }
259
    }
260
  }
261
262
  private function resolveBaseToTempFile(
263
    FileHandlePool $pool,
264
    string $packFile,
265
    int $baseOffset,
266
    int $depth,
267
    callable $getSizeShaFn,
268
    callable $streamShaFn
269
  ) {
270
    $tmpHandle = tmpfile();
271
272
    if( $tmpHandle !== false ) {
273
      foreach( $this->streamEntryGenerator(
274
        $pool,
275
        $packFile,
276
        $baseOffset,
277
        $depth + 1,
278
        $getSizeShaFn,
279
        $streamShaFn
280
      ) as $chunk ) {
281
        fwrite( $tmpHandle, $chunk );
282
      }
283
284
      rewind( $tmpHandle );
285
    }
286
287
    return $tmpHandle;
288
  }
289
290
  private function readVarInt( mixed $handle ): array {
291
    $byte = ord( fread( $handle, 1 ) );
292
    $val  = $byte & 15;
293
    $shft = 4;
294
    $fst  = $byte;
295
296
    while( $byte & 128 ) {
297
      $byte  = ord( fread( $handle, 1 ) );
298
      $val  |= ($byte & 127) << $shft;
299
      $shft += 7;
300
    }
301
302
    return [ 'value' => $val, 'byte' => $fst ];
303
  }
304
305
  private function readOffsetDelta( mixed $handle ): int {
306
    $byte = ord( fread( $handle, 1 ) );
307
    $neg  = $byte & 127;
308
309
    while( $byte & 128 ) {
310
      $byte = ord( fread( $handle, 1 ) );
311
      $neg  = ($neg + 1) << 7 | $byte & 127;
312
    }
313
314
    return $neg;
315
  }
316
317
  private function inflate( mixed $handle, int $cap = 0 ): string {
318
    $stream = CompressionStream::createInflater();
319
    $result = '';
320
321
    foreach( $stream->stream( $handle ) as $data ) {
322
      $result .= $data;
323
324
      if( $cap > 0 && strlen( $result ) >= $cap ) {
325
        $result = substr( $result, 0, $cap );
326
327
        break;
328
      }
329
    }
330
331
    return $result;
2
require_once __DIR__ . '/PackStreamManager.php';
3
require_once __DIR__ . '/DeltaDecoder.php';
4
require_once __DIR__ . '/CompressionStream.php';
5
require_once __DIR__ . '/BufferedReader.php';
6
require_once __DIR__ . '/PackContext.php';
7
8
class PackEntryReader {
9
  private const MAX_DEPTH    = 200;
10
  private const MAX_BASE_RAM = 8388608;
11
  private const MAX_CACHE    = 1024;
12
13
  private DeltaDecoder $decoder;
14
  private array        $cache;
15
16
  public function __construct( DeltaDecoder $decoder ) {
17
    $this->decoder = $decoder;
18
    $this->cache   = [];
19
  }
20
21
  public function getEntryMeta(
22
    PackContext $context
23
  ): array {
24
    return $context->computeArray(
25
      function(
26
        StreamReader $stream,
27
        int $offset
28
      ): array {
29
        $hdr    = $this->readEntryHeader(
30
          $stream, $offset
31
        );
32
        $result = [
33
          'type' => $hdr['type'],
34
          'size' => $hdr['size'],
35
        ];
36
37
        if( $hdr['type'] === 6 ) {
38
          $neg                  = $this->readOffsetDelta(
39
            $stream
40
          );
41
          $result['baseOffset'] = $offset - $neg;
42
        } elseif( $hdr['type'] === 7 ) {
43
          $result['baseSha'] = \bin2hex(
44
            $stream->read( 20 )
45
          );
46
        }
47
48
        return $result;
49
      },
50
      [ 'type' => 0, 'size' => 0 ]
51
    );
52
  }
53
54
  public function getSize( PackContext $context ): int {
55
    return $context->computeIntDedicated(
56
      function(
57
        StreamReader $stream,
58
        int $offset
59
      ): int {
60
        $hdr = $this->readEntryHeader(
61
          $stream, $offset
62
        );
63
64
        return $hdr['type'] === 6 || $hdr['type'] === 7
65
          ? $this->decoder->readDeltaTargetSize(
66
              $stream, $hdr['type']
67
            )
68
          : $hdr['size'];
69
      },
70
      0
71
    );
72
  }
73
74
  public function read(
75
    PackContext $context,
76
    int $cap,
77
    callable $readShaBaseFn
78
  ): string {
79
    return $context->computeStringDedicated(
80
      function(
81
        StreamReader $s,
82
        int $o
83
      ) use ( $cap, $readShaBaseFn ): string {
84
        return $this->readWithStream(
85
          $s, $o, $cap, $readShaBaseFn
86
        );
87
      },
88
      ''
89
    );
90
  }
91
92
  private function readWithStream(
93
    StreamReader $stream,
94
    int $offset,
95
    int $cap,
96
    callable $readShaBaseFn
97
  ): string {
98
    $result = '';
99
100
    if( isset( $this->cache[$offset] ) ) {
101
      $result = $cap > 0
102
        && \strlen( $this->cache[$offset] ) > $cap
103
        ? \substr( $this->cache[$offset], 0, $cap )
104
        : $this->cache[$offset];
105
    } else {
106
      $hdr  = $this->readEntryHeader(
107
        $stream, $offset
108
      );
109
      $type = $hdr['type'];
110
111
      if( $type === 6 ) {
112
        $neg   = $this->readOffsetDelta( $stream );
113
        $cur   = $stream->tell();
114
        $bData = $this->readWithStream(
115
          $stream,
116
          $offset - $neg,
117
          $cap,
118
          $readShaBaseFn
119
        );
120
121
        $stream->seek( $cur );
122
123
        $result = $this->decoder->apply(
124
          $bData,
125
          $this->inflate( $stream ),
126
          $cap
127
        );
128
      } elseif( $type === 7 ) {
129
        $sha = \bin2hex( $stream->read( 20 ) );
130
        $cur = $stream->tell();
131
        $bas = $readShaBaseFn( $sha, $cap );
132
133
        $stream->seek( $cur );
134
135
        $result = $this->decoder->apply(
136
          $bas,
137
          $this->inflate( $stream ),
138
          $cap
139
        );
140
      } else {
141
        $result = $this->inflate( $stream, $cap );
142
      }
143
144
      if( $cap === 0 ) {
145
        $this->cache[$offset] = $result;
146
147
        if( \count( $this->cache ) > self::MAX_CACHE ) {
148
          unset(
149
            $this->cache[
150
              \array_key_first( $this->cache )
151
            ]
152
          );
153
        }
154
      }
155
    }
156
157
    return $result;
158
  }
159
160
  public function streamRawCompressed(
161
    PackContext $context
162
  ): Generator {
163
    yield from $context->streamGenerator(
164
      function(
165
        StreamReader $stream,
166
        int $offset
167
      ): Generator {
168
        $hdr = $this->readEntryHeader(
169
          $stream, $offset
170
        );
171
172
        yield from $hdr['type'] !== 6
173
          && $hdr['type'] !== 7
174
          ? CompressionStream::createExtractor()->stream(
175
              $stream
176
            )
177
          : [];
178
      }
179
    );
180
  }
181
182
  public function streamRawDelta(
183
    PackContext $context
184
  ): Generator {
185
    yield from $context->streamGenerator(
186
      function(
187
        StreamReader $stream,
188
        int $offset
189
      ): Generator {
190
        $hdr = $this->readEntryHeader(
191
          $stream, $offset
192
        );
193
194
        if( $hdr['type'] === 6 ) {
195
          $this->readOffsetDelta( $stream );
196
        } elseif( $hdr['type'] === 7 ) {
197
          $stream->read( 20 );
198
        }
199
200
        yield from CompressionStream::createExtractor()
201
          ->stream( $stream );
202
      }
203
    );
204
  }
205
206
  public function streamEntryGenerator(
207
    PackContext $context
208
  ): Generator {
209
    yield from $context->streamGeneratorDedicated(
210
      function(
211
        StreamReader $stream,
212
        int $offset
213
      ) use ( $context ): Generator {
214
        $hdr = $this->readEntryHeader(
215
          $stream, $offset
216
        );
217
218
        yield from $hdr['type'] === 6
219
          || $hdr['type'] === 7
220
          ? $this->streamDeltaObjectGenerator(
221
              $stream,
222
              $context,
223
              $hdr['type'],
224
              $offset
225
            )
226
          : CompressionStream::createInflater()->stream(
227
              $stream
228
            );
229
      }
230
    );
231
  }
232
233
  private function readEntryHeader(
234
    StreamReader $stream,
235
    int $offset
236
  ): array {
237
    $stream->seek( $offset );
238
239
    $header = $this->readVarInt( $stream );
240
241
    return [
242
      'type' => $header['byte'] >> 4 & 7,
243
      'size' => $header['value']
244
    ];
245
  }
246
247
  private function streamDeltaObjectGenerator(
248
    StreamReader $stream,
249
    PackContext $context,
250
    int $type,
251
    int $offset
252
  ): Generator {
253
    $gen = $context->isWithinDepth( self::MAX_DEPTH )
254
      ? ( $type === 6
255
          ? $this->processOffsetDelta(
256
              $stream, $context, $offset
257
            )
258
          : $this->processRefDelta( $stream, $context )
259
        )
260
      : [];
261
262
    yield from $gen;
263
  }
264
265
  private function readSizeWithStream(
266
    StreamReader $stream,
267
    int $offset
268
  ): int {
269
    $result = 0;
270
271
    if( isset( $this->cache[$offset] ) ) {
272
      $result = \strlen( $this->cache[$offset] );
273
    } else {
274
      $cur = $stream->tell();
275
      $hdr = $this->readEntryHeader(
276
        $stream, $offset
277
      );
278
279
      $result = $hdr['type'] === 6
280
        || $hdr['type'] === 7
281
        ? $this->decoder->readDeltaTargetSize(
282
            $stream, $hdr['type']
283
          )
284
        : $hdr['size'];
285
286
      $stream->seek( $cur );
287
    }
288
289
    return $result;
290
  }
291
292
  private function processOffsetDelta(
293
    StreamReader $stream,
294
    PackContext $context,
295
    int $offset
296
  ): Generator {
297
    $neg     = $this->readOffsetDelta( $stream );
298
    $cur     = $stream->tell();
299
    $baseOff = $offset - $neg;
300
    $baseSrc = '';
301
302
    if( isset( $this->cache[$baseOff] ) ) {
303
      $baseSrc = $this->cache[$baseOff];
304
    } elseif(
305
      $this->readSizeWithStream(
306
        $stream, $baseOff
307
      ) <= self::MAX_BASE_RAM
308
    ) {
309
      $baseSrc = $this->readWithStream(
310
        $stream,
311
        $baseOff,
312
        0,
313
        function(
314
          string $sha,
315
          int $cap
316
        ) use ( $context ): string {
317
          return $this->resolveBaseSha(
318
            $sha, $cap, $context
319
          );
320
        }
321
      );
322
    } else {
323
      $baseCtx   = $context->deriveOffsetContext(
324
        $neg
325
      );
326
      [$b, $tmp] = $this->collectBase(
327
        $this->streamEntryGenerator( $baseCtx )
328
      );
329
      $baseSrc   = $tmp instanceof BufferedReader
330
        ? $tmp
331
        : $b;
332
    }
333
334
    $stream->seek( $cur );
335
336
    yield from $this->decoder->applyStreamGenerator(
337
      $stream, $baseSrc
338
    );
339
  }
340
341
  private function processRefDelta(
342
    StreamReader $stream,
343
    PackContext $context
344
  ): Generator {
345
    $baseSha = \bin2hex( $stream->read( 20 ) );
346
    $cur     = $stream->tell();
347
    $size    = $context->resolveBaseSize( $baseSha );
348
    $baseSrc = '';
349
350
    if( $size <= self::MAX_BASE_RAM ) {
351
      $baseSrc = $this->resolveBaseSha(
352
        $baseSha, 0, $context
353
      );
354
    } else {
355
      [$b, $tmp] = $this->collectBase(
356
        $context->resolveBaseStream( $baseSha )
357
      );
358
      $baseSrc   = $tmp instanceof BufferedReader
359
        ? $tmp
360
        : $b;
361
    }
362
363
    $stream->seek( $cur );
364
365
    yield from $this->decoder->applyStreamGenerator(
366
      $stream, $baseSrc
367
    );
368
  }
369
370
  private function collectBase(
371
    iterable $chunks
372
  ): array {
373
    $parts = [];
374
    $total = 0;
375
    $tmp   = false;
376
377
    foreach( $chunks as $chunk ) {
378
      $total += \strlen( $chunk );
379
380
      if( $tmp instanceof BufferedReader ) {
381
        $tmp->write( $chunk );
382
      } elseif( $total > self::MAX_BASE_RAM ) {
383
        $tmp = new BufferedReader(
384
          'php://temp/maxmemory:65536', 'w+b'
385
        );
386
387
        foreach( $parts as $part ) {
388
          $tmp->write( $part );
389
        }
390
391
        $tmp->write( $chunk );
392
        $parts = [];
393
      } else {
394
        $parts[] = $chunk;
395
      }
396
    }
397
398
    if( $tmp instanceof BufferedReader ) {
399
      $tmp->rewind();
400
    }
401
402
    return [
403
      $tmp === false ? \implode( '', $parts ) : '',
404
      $tmp
405
    ];
406
  }
407
408
  private function resolveBaseSha(
409
    string $sha,
410
    int $cap,
411
    PackContext $context
412
  ): string {
413
    $chunks = [];
414
415
    foreach(
416
      $context->resolveBaseStream( $sha ) as $chunk
417
    ) {
418
      $chunks[] = $chunk;
419
    }
420
421
    $result = \implode( '', $chunks );
422
423
    return $cap > 0 && \strlen( $result ) > $cap
424
      ? \substr( $result, 0, $cap )
425
      : $result;
426
  }
427
428
  private function readVarInt(
429
    StreamReader $stream
430
  ): array {
431
    $data = $stream->read( 12 );
432
    $byte = isset( $data[0] ) ? \ord( $data[0] ) : 0;
433
    $val  = $byte & 15;
434
    $shft = 4;
435
    $fst  = $byte;
436
    $pos  = 1;
437
438
    while( $byte & 128 ) {
439
      $byte  = isset( $data[$pos] )
440
        ? \ord( $data[$pos++] )
441
        : 0;
442
      $val  |= ( $byte & 127 ) << $shft;
443
      $shft += 7;
444
    }
445
446
    $rem = \strlen( $data ) - $pos;
447
448
    if( $rem > 0 ) {
449
      $stream->seek( -$rem, SEEK_CUR );
450
    }
451
452
    return [ 'value' => $val, 'byte' => $fst ];
453
  }
454
455
  private function readOffsetDelta(
456
    StreamReader $stream
457
  ): int {
458
    $data   = $stream->read( 12 );
459
    $byte   = isset( $data[0] ) ? \ord( $data[0] ) : 0;
460
    $result = $byte & 127;
461
    $pos    = 1;
462
463
    while( $byte & 128 ) {
464
      $byte   = isset( $data[$pos] )
465
        ? \ord( $data[$pos++] )
466
        : 0;
467
      $result = ( $result + 1 ) << 7 | $byte & 127;
468
    }
469
470
    $rem = \strlen( $data ) - $pos;
471
472
    if( $rem > 0 ) {
473
      $stream->seek( -$rem, SEEK_CUR );
474
    }
475
476
    return $result;
477
  }
478
479
  private function inflate(
480
    StreamReader $stream,
481
    int $cap = 0
482
  ): string {
483
    $inflater = CompressionStream::createInflater();
484
    $chunks   = [];
485
    $len      = 0;
486
487
    foreach( $inflater->stream( $stream ) as $data ) {
488
      $chunks[]  = $data;
489
      $len      += \strlen( $data );
490
491
      if( $cap > 0 && $len >= $cap ) {
492
        break;
493
      }
494
    }
495
496
    $result = \implode( '', $chunks );
497
498
    return $cap > 0 && \strlen( $result ) > $cap
499
      ? \substr( $result, 0, $cap )
500
      : $result;
332501
  }
333502
}
M git/PackIndex.php
11
<?php
2
require_once __DIR__ . '/StreamReader.php';
3
require_once __DIR__ . '/PackStreamManager.php';
4
25
class PackIndex {
36
  private string $indexFile;
47
  private string $packFile;
58
  private array  $fanoutCache;
9
  private string $buffer;
10
  private int    $bufferOffset;
611
712
  public function __construct( string $indexFile ) {
8
    $this->indexFile   = $indexFile;
9
    $this->packFile    = str_replace( '.idx', '.pack', $indexFile );
10
    $this->fanoutCache = [];
13
    $this->indexFile    = $indexFile;
14
    $this->packFile     = str_replace( '.idx', '.pack', $indexFile );
15
    $this->fanoutCache  = [];
16
    $this->buffer       = '';
17
    $this->bufferOffset = -1;
1118
  }
1219
1320
  public function search(
14
    FileHandlePool $pool,
21
    PackStreamManager $manager,
1522
    string $sha,
1623
    callable $onFound
1724
  ): void {
18
    $pool->computeVoid(
25
    $manager->computeInt(
1926
      $this->indexFile,
20
      function( mixed $handle ) use ( $sha, $onFound ): void {
21
        $this->ensureFanout( $handle );
27
      function( StreamReader $stream ) use ( $sha, $onFound ): int {
28
        $this->ensureFanout( $stream );
2229
2330
        if( !empty( $this->fanoutCache ) ) {
24
          $this->binarySearch( $handle, $sha, $onFound );
31
          $this->binarySearch( $stream, $sha, $onFound );
2532
        }
26
      }
33
34
        return 0;
35
      },
36
      0
2737
    );
2838
  }
2939
30
  private function ensureFanout( mixed $handle ): void {
40
  private function ensureFanout( StreamReader $stream ): void {
3141
    if( empty( $this->fanoutCache ) ) {
32
      fseek( $handle, 0 );
42
      $stream->seek( 0 );
3343
34
      $head = fread( $handle, 8 );
44
      $head = $stream->read( 8 );
3545
3646
      if( $head === "\377tOc\0\0\0\2" ) {
37
        $this->fanoutCache = array_values(
38
          unpack( 'N*', fread( $handle, 1024 ) )
39
        );
47
        $data = $stream->read( 1024 );
48
49
        $this->fanoutCache = array_values( unpack( 'N*', $data ) );
4050
      }
4151
    }
4252
  }
4353
4454
  private function binarySearch(
45
    mixed $handle,
55
    StreamReader $stream,
4656
    string $sha,
4757
    callable $onFound
4858
  ): void {
49
    $byte   = ord( $sha[0] );
50
    $start  = $byte === 0 ? 0 : $this->fanoutCache[$byte - 1];
51
    $end    = $this->fanoutCache[$byte];
52
    $result = 0;
59
    $byte  = ord( $sha[0] );
60
    $start = $byte === 0 ? 0 : $this->fanoutCache[$byte - 1];
61
    $end   = $this->fanoutCache[$byte];
5362
5463
    if( $end > $start ) {
55
      $low  = $start;
56
      $high = $end - 1;
64
      $low    = $start;
65
      $high   = $end - 1;
66
      $result = 0;
5767
5868
      while( $result === 0 && $low <= $high ) {
5969
        $mid = ($low + $high) >> 1;
60
61
        fseek( $handle, 1032 + $mid * 20 );
62
63
        $cmp = fread( $handle, 20 );
70
        $pos = 1032 + $mid * 20;
71
        $cmp = $this->readShaBytes( $stream, $pos );
6472
6573
        if( $cmp < $sha ) {
6674
          $low = $mid + 1;
6775
        } elseif( $cmp > $sha ) {
6876
          $high = $mid - 1;
6977
        } else {
70
          $result = $this->readOffset( $handle, $mid );
78
          $result = $this->readOffset( $stream, $mid );
7179
        }
80
      }
81
82
      if( $result !== 0 ) {
83
        $onFound( $this->packFile, $result );
7284
      }
7385
    }
86
  }
7487
75
    if( $result !== 0 ) {
76
      $onFound( $this->packFile, $result );
88
  private function readShaBytes( StreamReader $stream, int $pos ): string {
89
    if(
90
      $this->bufferOffset === -1 ||
91
      $pos < $this->bufferOffset ||
92
      $pos + 20 > $this->bufferOffset + 8192
93
    ) {
94
      $stream->seek( $pos );
95
96
      $this->bufferOffset = $pos;
97
      $this->buffer       = $stream->read( 8192 );
7798
    }
99
100
    $offset = $pos - $this->bufferOffset;
101
102
    return substr( $this->buffer, $offset, 20 );
78103
  }
79104
80
  private function readOffset( mixed $handle, int $mid ): int {
81
    $total  = $this->fanoutCache[255];
82
    $pos    = 1032 + $total * 24 + $mid * 4;
83
    $result = 0;
105
  private function readOffset( StreamReader $stream, int $mid ): int {
106
    $total = $this->fanoutCache[255];
107
    $pos   = 1032 + $total * 24 + $mid * 4;
84108
85
    fseek( $handle, $pos );
109
    $stream->seek( $pos );
86110
87
    $packed = fread( $handle, 4 );
111
    $packed = $stream->read( 4 );
88112
    $offset = unpack( 'N', $packed )[1];
89113
90114
    if( $offset & 0x80000000 ) {
91115
      $pos64 = 1032 + $total * 28 + ($offset & 0x7FFFFFFF) * 8;
92116
93
      fseek( $handle, $pos64 );
117
      $stream->seek( $pos64 );
94118
95
      $offset = unpack( 'J', fread( $handle, 8 ) )[1];
119
      $packed64 = $stream->read( 8 );
120
      $offset   = unpack( 'J', $packed64 )[1];
96121
    }
97
98
    $result = (int)$offset;
99122
100
    return $result;
123
    return (int)$offset;
101124
  }
102125
}
M git/PackLocator.php
11
<?php
22
require_once __DIR__ . '/PackIndex.php';
3
require_once __DIR__ . '/FileHandlePool.php';
3
require_once __DIR__ . '/PackStreamManager.php';
44
55
class PackLocator {
6
  private array $indexes;
6
  private PackStreamManager $manager;
7
  private array             $indexes;
8
  private array             $cache;
79
8
  public function __construct( string $objectsPath ) {
10
  public function __construct(
11
    PackStreamManager $manager,
12
    string $objectsPath
13
  ) {
14
    $this->manager = $manager;
915
    $this->indexes = [];
10
    $packFiles     = glob( "{$objectsPath}/pack/*.idx" ) ?: [];
16
    $this->cache   = [];
17
    $packFiles     = glob( "{$objectsPath}/pack/*.idx" );
1118
12
    foreach( $packFiles as $indexFile ) {
13
      $this->indexes[] = new PackIndex( $indexFile );
19
    if( $packFiles !== false ) {
20
      foreach( $packFiles as $indexFile ) {
21
        $this->indexes[] = new PackIndex( $indexFile );
22
      }
1423
    }
1524
  }
1625
17
  public function locate(
18
    FileHandlePool $pool,
19
    string $sha,
20
    callable $action
21
  ): void {
26
  public function locate( string $sha, callable $action ): void {
2227
    if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) {
2328
      $binarySha = hex2bin( $sha );
24
      $found     = false;
25
      $count     = count( $this->indexes );
26
      $index     = 0;
2729
28
      while( !$found && $index < $count ) {
29
        $this->indexes[$index]->search(
30
          $pool,
31
          $binarySha,
32
          function(
33
            string $packFile,
34
            int $offset
35
          ) use (
36
            &$found,
37
            $index,
38
            $action
39
          ): void {
40
            $found = true;
30
      if( array_key_exists( $binarySha, $this->cache ) ) {
31
        $cached = $this->cache[$binarySha];
4132
42
            if( $index > 0 ) {
43
              $temp                  = $this->indexes[0];
44
              $this->indexes[0]      = $this->indexes[$index];
45
              $this->indexes[$index] = $temp;
46
            }
33
        unset( $this->cache[$binarySha] );
4734
48
            $action( $packFile, $offset );
49
          }
50
        );
35
        $this->cache[$binarySha] = $cached;
5136
52
        $index++;
37
        $action( $cached['pack'], $cached['offset'] );
38
      } else {
39
        $found = false;
40
        $count = count( $this->indexes );
41
        $index = 0;
42
43
        while( !$found && $index < $count ) {
44
          $this->indexes[$index]->search(
45
            $this->manager,
46
            $binarySha,
47
            function(
48
              string $packFile,
49
              int $offset
50
            ) use (
51
              &$found,
52
              $index,
53
              $action,
54
              $binarySha
55
            ): void {
56
              $found = true;
57
58
              if( $index > 0 ) {
59
                $temp                  = $this->indexes[0];
60
                $this->indexes[0]      = $this->indexes[$index];
61
                $this->indexes[$index] = $temp;
62
              }
63
64
              $this->cache[$binarySha] = [
65
                'pack'   => $packFile,
66
                'offset' => $offset
67
              ];
68
69
              if( count( $this->cache ) > 512 ) {
70
                $keyToDrop = array_key_first( $this->cache );
71
72
                unset( $this->cache[$keyToDrop] );
73
              }
74
75
              $action( $packFile, $offset );
76
            }
77
          );
78
79
          $index++;
80
        }
5381
      }
5482
    }
A git/PackStreamManager.php
1
<?php
2
require_once __DIR__ . '/BufferedReader.php';
3
4
class PackStreamManager {
5
  private array $readers = [];
6
7
  public function computeInt(
8
    string $path,
9
    callable $callback,
10
    int $default
11
  ): int {
12
    $result = $default;
13
    $reader = $this->acquire( $path );
14
15
    if( $reader->isOpen() ) {
16
      try {
17
        $result = $callback( $reader );
18
      } finally {
19
        $this->release( $path, $reader );
20
      }
21
    }
22
23
    return $result;
24
  }
25
26
  public function computeIntDedicated(
27
    string $path,
28
    callable $callback,
29
    int $default
30
  ): int {
31
    $reader = new BufferedReader( $path );
32
33
    return $reader->isOpen() ? $callback( $reader ) : $default;
34
  }
35
36
  public function computeStringDedicated(
37
    string $path,
38
    callable $callback,
39
    string $default
40
  ): string {
41
    $reader = new BufferedReader( $path );
42
43
    return $reader->isOpen() ? $callback( $reader ) : $default;
44
  }
45
46
  public function computeArray(
47
    string $path,
48
    callable $callback,
49
    array $default
50
  ): array {
51
    $result = $default;
52
    $reader = $this->acquire( $path );
53
54
    if( $reader->isOpen() ) {
55
      try {
56
        $result = $callback( $reader );
57
      } finally {
58
        $this->release( $path, $reader );
59
      }
60
    }
61
62
    return $result;
63
  }
64
65
  public function streamGenerator(
66
    string $path,
67
    callable $callback
68
  ): Generator {
69
    $reader = $this->acquire( $path );
70
71
    if( $reader->isOpen() ) {
72
      try {
73
        yield from $callback( $reader );
74
      } finally {
75
        $this->release( $path, $reader );
76
      }
77
    }
78
  }
79
80
  public function streamGeneratorDedicated(
81
    string $path,
82
    callable $callback
83
  ): Generator {
84
    $reader = new BufferedReader( $path );
85
86
    if( $reader->isOpen() ) {
87
      yield from $callback( $reader );
88
    }
89
  }
90
91
  private function acquire( string $path ): BufferedReader {
92
    return !empty( $this->readers[$path] )
93
      ? \array_pop( $this->readers[$path] )
94
      : new BufferedReader( $path );
95
  }
96
97
  private function release(
98
    string $path,
99
    BufferedReader $reader
100
  ): void {
101
    if( !isset( $this->readers[$path] ) ) {
102
      $this->readers[$path] = [];
103
    }
104
105
    $this->readers[$path][] = $reader;
106
  }
107
}
1108
A git/PackfileWriter.php
1
<?php
2
require_once __DIR__ . '/GitPacks.php';
3
require_once __DIR__ . '/LooseObjects.php';
4
5
class PackfileWriter {
6
  private GitPacks     $packs;
7
  private LooseObjects $loose;
8
9
  public function __construct(
10
    GitPacks $packs,
11
    LooseObjects $loose
12
  ) {
13
    $this->packs = $packs;
14
    $this->loose = $loose;
15
  }
16
17
  public function generate( array $objs ): Generator {
18
    $entries = $this->buildEntries( $objs );
19
    $ctx     = \hash_init( 'sha1' );
20
    $head    = "PACK"
21
      . \pack( 'N', 2 )
22
      . \pack( 'N', \count( $objs ) );
23
24
    \hash_update( $ctx, $head );
25
    yield $head;
26
27
    $written = [];
28
    $outPos  = 12;
29
30
    foreach( $entries as $sha => $entry ) {
31
      $written[$sha] = $outPos;
32
      $baseSha       = $entry['baseSha'];
33
34
      $reuse = $baseSha !== ''
35
        && isset( $written[$baseSha] );
36
37
      if( $reuse ) {
38
        $hdr  = $this->encodeEntryHeader(
39
          6, $entry['deltaSize']
40
        );
41
        $hdr .= $this->encodeOffsetDelta(
42
          $outPos - $written[$baseSha]
43
        );
44
45
        \hash_update( $ctx, $hdr );
46
        $outPos += \strlen( $hdr );
47
        yield $hdr;
48
49
        foreach(
50
          $this->packs->streamRawDelta(
51
            $sha
52
          ) as $chunk
53
        ) {
54
          \hash_update( $ctx, $chunk );
55
          $outPos += \strlen( $chunk );
56
          yield $chunk;
57
        }
58
      } else {
59
        $size = $this->getObjectSize( $sha );
60
        $hdr  = $this->encodeEntryHeader(
61
          $entry['logicalType'], $size
62
        );
63
64
        \hash_update( $ctx, $hdr );
65
        $outPos += \strlen( $hdr );
66
        yield $hdr;
67
68
        foreach(
69
          $this->streamCompressed(
70
            $sha
71
          ) as $chunk
72
        ) {
73
          \hash_update( $ctx, $chunk );
74
          $outPos += \strlen( $chunk );
75
          yield $chunk;
76
        }
77
      }
78
    }
79
80
    yield \hash_final( $ctx, true );
81
  }
82
83
  private function buildEntries(
84
    array $objs
85
  ): array {
86
    $entries  = [];
87
    $offToSha = [];
88
89
    foreach( $objs as $sha => $logicalType ) {
90
      $meta = $this->packs->getEntryMeta( $sha );
91
92
      $entries[$sha] = [
93
        'logicalType' => $logicalType,
94
        'packType'    => $meta['type'],
95
        'deltaSize'   => $meta['size'],
96
        'packFile'    => $meta['file'],
97
        'offset'      => $meta['offset'],
98
        'baseOffset'  => $meta['baseOffset'] ?? 0,
99
        'baseSha'     => $meta['baseSha'] ?? '',
100
      ];
101
102
      if( $meta['file'] !== '' ) {
103
        $offToSha[$meta['file']][$meta['offset']]
104
          = $sha;
105
      }
106
    }
107
108
    foreach( $entries as &$e ) {
109
      if(
110
        $e['packType'] === 6
111
        && $e['baseOffset'] > 0
112
      ) {
113
        $e['baseSha']
114
          = $offToSha[$e['packFile']][$e['baseOffset']]
115
            ?? '';
116
      }
117
    }
118
119
    unset( $e );
120
121
    \uasort(
122
      $entries,
123
      function( array $a, array $b ): int {
124
        $cmp = $a['packFile'] <=> $b['packFile'];
125
126
        return $cmp !== 0
127
          ? $cmp
128
          : $a['offset'] <=> $b['offset'];
129
      }
130
    );
131
132
    return $entries;
133
  }
134
135
  private function getObjectSize(
136
    string $sha
137
  ): int {
138
    return $this->packs->getSize( $sha )
139
      ?: $this->loose->getSize( $sha );
140
  }
141
142
  private function streamCompressed(
143
    string $sha
144
  ): Generator {
145
    $yielded = false;
146
147
    foreach(
148
      $this->packs->streamRawCompressed(
149
        $sha
150
      ) as $chunk
151
    ) {
152
      $yielded = true;
153
      yield $chunk;
154
    }
155
156
    if( !$yielded ) {
157
      $deflate = \deflate_init(
158
        \ZLIB_ENCODING_DEFLATE
159
      );
160
161
      foreach(
162
        $this->getDecompressedChunks(
163
          $sha
164
        ) as $raw
165
      ) {
166
        $compressed = \deflate_add(
167
          $deflate, $raw, \ZLIB_NO_FLUSH
168
        );
169
170
        if( $compressed !== '' ) {
171
          yield $compressed;
172
        }
173
      }
174
175
      $final = \deflate_add(
176
        $deflate, '', \ZLIB_FINISH
177
      );
178
179
      if( $final !== '' ) {
180
        yield $final;
181
      }
182
    }
183
  }
184
185
  private function getDecompressedChunks(
186
    string $sha
187
  ): Generator {
188
    $any = false;
189
190
    foreach(
191
      $this->loose->streamChunks( $sha ) as $chunk
192
    ) {
193
      $any = true;
194
      yield $chunk;
195
    }
196
197
    if( !$any ) {
198
      foreach(
199
        $this->packs->streamGenerator(
200
          $sha
201
        ) as $chunk
202
      ) {
203
        $any = true;
204
        yield $chunk;
205
      }
206
    }
207
208
    if( !$any ) {
209
      $data = $this->packs->read( $sha );
210
211
      if( $data !== '' ) {
212
        yield $data;
213
      }
214
    }
215
  }
216
217
  private function encodeEntryHeader(
218
    int $type,
219
    int $size
220
  ): string {
221
    $byte = $type << 4 | $size & 0x0f;
222
    $sz   = $size >> 4;
223
    $hdr  = '';
224
225
    while( $sz > 0 ) {
226
      $hdr  .= \chr( $byte | 0x80 );
227
      $byte  = $sz & 0x7f;
228
      $sz  >>= 7;
229
    }
230
231
    $hdr .= \chr( $byte );
232
233
    return $hdr;
234
  }
235
236
  private function encodeOffsetDelta(
237
    int $offset
238
  ): string {
239
    $buf = \chr( $offset & 0x7F );
240
    $n   = $offset >> 7;
241
242
    while( $n > 0 ) {
243
      $n--;
244
      $buf = \chr( 0x80 | ($n & 0x7F) ) . $buf;
245
      $n >>= 7;
246
    }
247
248
    return $buf;
249
  }
250
}
1251
A git/StreamReader.php
1
<?php
2
interface StreamReader {
3
  public function read( int $length ): string;
4
5
  public function write( string $data ): bool;
6
7
  public function seek( int $offset, int $whence = SEEK_SET ): bool;
8
9
  public function tell(): int;
10
11
  public function eof(): bool;
12
13
  public function rewind(): void;
14
}
115
M index.php
11
<?php
22
require_once __DIR__ . '/Config.php';
3
require_once __DIR__ . '/Router.php';
3
require_once __DIR__ . '/model/Router.php';
44
55
$config = new Config();
A model/Commit.php
1
<?php
2
require_once __DIR__ . '/../render/CommitRenderer.php';
3
require_once __DIR__ . '/UrlBuilder.php';
4
5
class Commit {
6
  private string $sha;
7
  private string $message;
8
  private string $author;
9
  private string $email;
10
  private int    $date;
11
  private string $parentSha;
12
13
  public function __construct(
14
    string $sha,
15
    string $message,
16
    string $author,
17
    string $email,
18
    int    $date,
19
    string $parentSha
20
  ) {
21
    $this->sha       = $sha;
22
    $this->message   = $message;
23
    $this->author    = $author;
24
    $this->email     = $email;
25
    $this->date      = $date;
26
    $this->parentSha = $parentSha;
27
  }
28
29
  public function hasParent(): bool {
30
    return $this->parentSha !== '';
31
  }
32
33
  public function isSha( string $sha ): bool {
34
    return $this->sha === $sha;
35
  }
36
37
  public function toUrl( UrlBuilder $builder ): string {
38
    return $builder->withHash( $this->sha )->build();
39
  }
40
41
  public function isEmpty(): bool {
42
    return $this->sha === '';
43
  }
44
45
  public function render( CommitRenderer $renderer ): void {
46
    $renderer->render(
47
      $this->sha,
48
      $this->message,
49
      $this->author,
50
      $this->date
51
    );
52
  }
53
54
  public function renderTime( CommitRenderer $renderer ): void {
55
    $renderer->renderTime( $this->date );
56
  }
57
}
158
A model/File.php
1
<?php
2
require_once __DIR__ . '/../render/FileRenderer.php';
3
4
class File {
5
  private const CAT_IMAGE   = 'image';
6
  private const CAT_VIDEO   = 'video';
7
  private const CAT_AUDIO   = 'audio';
8
  private const CAT_TEXT    = 'text';
9
  private const CAT_ARCHIVE = 'archive';
10
  private const CAT_BINARY  = 'binary';
11
12
  private const ICON_FOLDER  = 'fa-folder';
13
  private const ICON_PDF     = 'fa-file-pdf';
14
  private const ICON_ARCHIVE = 'fa-file-archive';
15
  private const ICON_IMAGE   = 'fa-file-image';
16
  private const ICON_AUDIO   = 'fa-file-audio';
17
  private const ICON_VIDEO   = 'fa-file-video';
18
  private const ICON_CODE    = 'fa-file-code';
19
  private const ICON_FILE    = 'fa-file';
20
21
  private const MODE_DIR       = '40000';
22
  private const MODE_DIR_LONG  = '040000';
23
24
  private const MEDIA_EMPTY    = 'application/x-empty';
25
  private const MEDIA_OCTET    = 'application/octet-stream';
26
  private const MEDIA_PDF      = 'application/pdf';
27
  private const MEDIA_TEXT     = 'text/';
28
  private const MEDIA_SVG      = 'image/svg';
29
  private const MEDIA_APP_TEXT = [
30
    'application/javascript',
31
    'application/json',
32
    'application/xml',
33
    'application/x-httpd-php',
34
    'application/x-sh'
35
  ];
36
37
  private const ARCHIVE_EXT = [
38
    'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab',
39
    'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj',
40
    'zoo', 'rpm', 'deb', 'apk'
41
  ];
42
43
  private string $name;
44
  private string $sha;
45
  private string $mode;
46
  private int $timestamp;
47
  private int $size;
48
  private bool $isDir;
49
  private string $mediaType;
50
  private string $category;
51
  private bool $binary;
52
53
  private static ?finfo $finfo = null;
54
55
  public function __construct(
56
    string $name,
57
    string $sha,
58
    string $mode,
59
    int $timestamp,
60
    int $size,
61
    string $contents = ''
62
  ) {
63
    $this->name      = $name;
64
    $this->sha       = $sha;
65
    $this->mode      = $mode;
66
    $this->timestamp = $timestamp;
67
    $this->size      = $size;
68
    $this->isDir     = $mode === self::MODE_DIR ||
69
                       $mode === self::MODE_DIR_LONG;
70
71
    $buffer          = $this->isDir ? '' : $contents;
72
    $this->mediaType = $this->detectMediaType( $buffer );
73
    $this->category  = $this->detectCategory( $name );
74
    $this->binary    = $this->detectBinary();
75
  }
76
77
  public function isEmpty(): bool {
78
    return $this->size === 0;
79
  }
80
81
  public function compare( File $other ): int {
82
    return $this->isDir !== $other->isDir
83
      ? ($this->isDir ? -1 : 1)
84
      : strcasecmp( $this->name, $other->name );
85
  }
86
87
  public function renderListEntry( FileRenderer $renderer ): void {
88
    $renderer->renderListEntry(
89
      $this->name,
90
      $this->sha,
91
      $this->mode,
92
      $this->resolveIcon(),
93
      $this->timestamp,
94
      $this->size
95
    );
96
  }
97
98
  public function emitRawHeaders(): void {
99
    header( "Content-Type: " . $this->mediaType );
100
    header( "Content-Length: " . $this->size );
101
    header( "Content-Disposition: attachment; filename=\"" .
102
      addslashes( basename( $this->name ) ) . "\"" );
103
  }
104
105
  public function renderMedia( FileRenderer $renderer, string $url ): bool {
106
    return $renderer->renderMedia( $this, $url, $this->mediaType );
107
  }
108
109
  public function renderSize( FileRenderer $renderer ): void {
110
    $renderer->renderSize( $this->size );
111
  }
112
113
  public function highlight(
114
    FileRenderer $renderer,
115
    string $content
116
  ): string {
117
    return $renderer->highlight( $this->name, $content, $this->mediaType );
118
  }
119
120
  public function isDir(): bool {
121
    return $this->isDir;
122
  }
123
124
  public function isImage(): bool {
125
    return $this->category === self::CAT_IMAGE;
126
  }
127
128
  public function isVideo(): bool {
129
    return $this->category === self::CAT_VIDEO;
130
  }
131
132
  public function isAudio(): bool {
133
    return $this->category === self::CAT_AUDIO;
134
  }
135
136
  public function isText(): bool {
137
    return $this->category === self::CAT_TEXT;
138
  }
139
140
  public function isBinary(): bool {
141
    return $this->binary;
142
  }
143
144
  public function isName( string $name ): bool {
145
    return $this->name === $name;
146
  }
147
148
  private function resolveIcon(): string {
149
    return $this->isDir
150
      ? self::ICON_FOLDER
151
      : (str_contains( $this->mediaType, self::MEDIA_PDF )
152
        ? self::ICON_PDF
153
        : match( $this->category ) {
154
          self::CAT_ARCHIVE => self::ICON_ARCHIVE,
155
          self::CAT_IMAGE   => self::ICON_IMAGE,
156
          self::CAT_AUDIO   => self::ICON_AUDIO,
157
          self::CAT_VIDEO   => self::ICON_VIDEO,
158
          self::CAT_TEXT    => self::ICON_CODE,
159
          default           => self::ICON_FILE,
160
        });
161
  }
162
163
  private static function fileinfo(): finfo {
164
    return self::$finfo ??= new finfo( FILEINFO_MIME_TYPE );
165
  }
166
167
  private function detectMediaType( string $buffer ): string {
168
    return $buffer === ''
169
      ? self::MEDIA_EMPTY
170
      : (self::fileinfo()->buffer( substr( $buffer, 0, 128 ) )
171
        ?: self::MEDIA_OCTET);
172
  }
173
174
  private function detectCategory( string $filename ): string {
175
    $main = explode( '/', $this->mediaType )[0];
176
    $main = $this->isArchive( $filename ) ||
177
            str_contains( $this->mediaType, 'compressed' )
178
      ? self::CAT_ARCHIVE
179
      : $main;
180
181
    $main = $main !== self::CAT_ARCHIVE &&
182
            $this->isMediaTypeText()
183
      ? 'text'
184
      : $main;
185
186
    return match( $main ) {
187
      'image'           => self::CAT_IMAGE,
188
      'video'           => self::CAT_VIDEO,
189
      'audio'           => self::CAT_AUDIO,
190
      'text'            => self::CAT_TEXT,
191
      self::CAT_ARCHIVE => self::CAT_ARCHIVE,
192
      default           => self::CAT_BINARY,
193
    };
194
  }
195
196
  private function detectBinary(): bool {
197
    return $this->mediaType !== self::MEDIA_EMPTY &&
198
           !$this->isMediaTypeText() &&
199
           !str_contains( $this->mediaType, self::MEDIA_SVG );
200
  }
201
202
  private function isMediaTypeText(): bool {
203
    return str_starts_with( $this->mediaType, self::MEDIA_TEXT ) ||
204
           in_array( $this->mediaType, self::MEDIA_APP_TEXT, true );
205
  }
206
207
  private function isArchive( string $filename ): bool {
208
    return in_array(
209
      strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ),
210
      self::ARCHIVE_EXT,
211
      true
212
    );
213
  }
214
}
1215
A model/RepositoryList.php
1
<?php
2
class RepositoryList {
3
  private const ORDER_FILE    = __DIR__ . '/../order.txt';
4
  private const GIT_EXT       = '.git';
5
  private const GLOB_PATTERN  = '/*';
6
  private const HIDDEN_PREFIX = '.';
7
  private const EXCLUDE_CHAR  = '-';
8
  private const SORT_MAX      = PHP_INT_MAX;
9
10
  private const KEY_SAFE_NAME = 'safe_name';
11
  private const KEY_EXCLUDE   = 'exclude';
12
  private const KEY_ORDER     = 'order';
13
  private const KEY_PATH      = 'path';
14
  private const KEY_NAME      = 'name';
15
16
  private string $reposPath;
17
18
  public function __construct( string $path ) {
19
    $this->reposPath = $path;
20
  }
21
22
  public function eachRepository( callable $callback ): void {
23
    $repos = $this->sortRepositories( $this->loadRepositories() );
24
25
    foreach( $repos as $repo ) {
26
      $callback( $repo );
27
    }
28
  }
29
30
  private function loadRepositories(): array {
31
    $repos = [];
32
    $path  = $this->reposPath . self::GLOB_PATTERN;
33
    $dirs  = glob( $path, GLOB_ONLYDIR );
34
35
    if( $dirs !== false ) {
36
      $repos = $this->processDirectories( $dirs );
37
    }
38
39
    return $repos;
40
  }
41
42
  private function processDirectories( array $dirs ): array {
43
    $repos = [];
44
45
    foreach( $dirs as $dir ) {
46
      $data = $this->createRepositoryData( $dir );
47
48
      if( $data !== [] ) {
49
        $repos[$data[self::KEY_NAME]] = $data;
50
      }
51
    }
52
53
    return $repos;
54
  }
55
56
  private function createRepositoryData( string $dir ): array {
57
    $data = [];
58
    $base = basename( $dir );
59
60
    if( $base[0] !== self::HIDDEN_PREFIX ) {
61
      $name = $this->extractName( $base );
62
      $data = [
63
        self::KEY_NAME      => $name,
64
        self::KEY_SAFE_NAME => $name,
65
        self::KEY_PATH      => $dir,
66
      ];
67
    }
68
69
    return $data;
70
  }
71
72
  private function extractName( string $base ): string {
73
    $name = $base;
74
75
    if( str_ends_with( $base, self::GIT_EXT ) ) {
76
      $len  = strlen( self::GIT_EXT );
77
      $name = substr( $base, 0, -$len );
78
    }
79
80
    return $name;
81
  }
82
83
  private function sortRepositories( array $repos ): array {
84
    $file = self::ORDER_FILE;
85
86
    if( file_exists( $file ) ) {
87
      $repos = $this->applyCustomOrder( $repos, $file );
88
    } else {
89
      ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE );
90
    }
91
92
    return $repos;
93
  }
94
95
  private function applyCustomOrder( array $repos, string $file ): array {
96
    $lines = file( $file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
97
98
    if( $lines !== false ) {
99
      $config = $this->parseOrderFile( $lines );
100
      $repos  = $this->filterExcluded( $repos, $config[self::KEY_EXCLUDE] );
101
      $repos  = $this->sortWithConfig( $repos, $config[self::KEY_ORDER] );
102
    }
103
104
    return $repos;
105
  }
106
107
  private function parseOrderFile( array $lines ): array {
108
    $order   = [];
109
    $exclude = [];
110
111
    foreach( $lines as $line ) {
112
      $trim = trim( $line );
113
114
      if( $trim !== '' ) {
115
        if( str_starts_with( $trim, self::EXCLUDE_CHAR ) ) {
116
          $exclude = $this->addExclusion( $exclude, $trim );
117
        } else {
118
          $order[$trim] = count( $order );
119
        }
120
      }
121
    }
122
123
    return [ self::KEY_ORDER => $order, self::KEY_EXCLUDE => $exclude ];
124
  }
125
126
  private function addExclusion( array $exclude, string $line ): array {
127
    $name           = substr( $line, 1 );
128
    $exclude[$name] = true;
129
130
    return $exclude;
131
  }
132
133
  private function filterExcluded( array $repos, array $exclude ): array {
134
    foreach( $repos as $key => $repo ) {
135
      if( isset( $exclude[$repo[self::KEY_SAFE_NAME]] ) ) {
136
        unset( $repos[$key] );
137
      }
138
    }
139
140
    return $repos;
141
  }
142
143
  private function sortWithConfig( array $repos, array $order ): array {
144
    uasort( $repos, function( array $repoA, array $repoB ) use( $order ): int {
145
      return $this->compareRepositories( $repoA, $repoB, $order );
146
    } );
147
148
    return $repos;
149
  }
150
151
  private function compareRepositories(
152
    array $repoA,
153
    array $repoB,
154
    array $order
155
  ): int {
156
    $safeA = $repoA[self::KEY_SAFE_NAME];
157
    $safeB = $repoB[self::KEY_SAFE_NAME];
158
    $posA  = $order[$safeA] ?? self::SORT_MAX;
159
    $posB  = $order[$safeB] ?? self::SORT_MAX;
160
161
    $result = $posA === $posB
162
      ? strcasecmp( $safeA, $safeB )
163
      : $posA <=> $posB;
164
165
    return $result;
166
  }
167
}
1168
A model/Router.php
1
<?php
2
require_once __DIR__ . '/RepositoryList.php';
3
require_once __DIR__ . '/../git/Git.php';
4
require_once __DIR__ . '/../pages/CommitsPage.php';
5
require_once __DIR__ . '/../pages/DiffPage.php';
6
require_once __DIR__ . '/../pages/HomePage.php';
7
require_once __DIR__ . '/../pages/FilePage.php';
8
require_once __DIR__ . '/../pages/RawPage.php';
9
require_once __DIR__ . '/../pages/TagsPage.php';
10
require_once __DIR__ . '/../pages/ClonePage.php';
11
require_once __DIR__ . '/../pages/ComparePage.php';
12
13
class Router {
14
  private const ACTION_TREE    = 'tree';
15
  private const ACTION_BLOB    = 'blob';
16
  private const ACTION_RAW     = 'raw';
17
  private const ACTION_COMMITS = 'commits';
18
  private const ACTION_COMMIT  = 'commit';
19
  private const ACTION_COMPARE = 'compare';
20
  private const ACTION_TAGS    = 'tags';
21
22
  private const GET_REPOSITORY = 'repo';
23
  private const GET_ACTION     = 'action';
24
  private const GET_HASH       = 'hash';
25
  private const GET_NAME       = 'name';
26
27
  private const REFERENCE_HEAD = 'HEAD';
28
  private const ROUTE_REPO     = 'repo';
29
  private const EXTENSION_GIT  = '.git';
30
31
  private array $repos = [];
32
  private Git $git;
33
34
  private string $repoName   = '';
35
  private array $repoData    = [];
36
  private string $action     = '';
37
  private string $commitHash = '';
38
  private string $filePath   = '';
39
  private string $baseHash   = '';
40
41
  public function __construct( string $reposPath ) {
42
    $this->git = new Git( $reposPath );
43
    $list      = new RepositoryList( $reposPath );
44
45
    $list->eachRepository( function( $repo ) {
46
      $this->repos[$repo['safe_name']] = $repo;
47
    });
48
  }
49
50
  public function route(): Page {
51
    $this->normalizeQueryString();
52
    $uriParts = $this->parseUriParts();
53
    $repoName = !empty( $uriParts ) ? array_shift( $uriParts ) : '';
54
    $page     = new HomePage( $this->repos, $this->git );
55
56
    if( $repoName !== '' ) {
57
      if( str_ends_with( $repoName, self::EXTENSION_GIT ) ) {
58
        $page = $this->handleCloneRoute( $repoName, $uriParts );
59
      } elseif( isset( $this->repos[$repoName] ) ) {
60
        $page = $this->resolveActionRoute( $repoName, $uriParts );
61
      }
62
    }
63
64
    return $page;
65
  }
66
67
  private function handleCloneRoute(
68
    string $repoName,
69
    array $uriParts
70
  ): Page {
71
    $realName = substr( $repoName, 0, -4 );
72
    $path     = '';
73
74
    if( isset( $this->repos[$realName]['path'] ) ) {
75
      $path = $this->repos[$realName]['path'];
76
    } elseif( isset( $this->repos[$repoName]['path'] ) ) {
77
      $path = $this->repos[$repoName]['path'];
78
    }
79
80
    if( $path === '' ) {
81
      http_response_code( 404 );
82
      exit( "Repository not found" );
83
    }
84
85
    $this->git->setRepository( $path );
86
87
    return new ClonePage( $this->git, implode( '/', $uriParts ) );
88
  }
89
90
  private function resolveActionRoute(
91
    string $repoName,
92
    array $uriParts
93
  ): Page {
94
    $this->repoData = $this->repos[$repoName];
95
    $this->repoName = $repoName;
96
97
    $this->git->setRepository( $this->repoData['path'] );
98
99
    $act = array_shift( $uriParts );
100
    $this->action = $act ?: self::ACTION_TREE;
101
102
    $this->commitHash = self::REFERENCE_HEAD;
103
    $this->filePath   = '';
104
    $this->baseHash   = '';
105
106
    $hasHash = [
107
      self::ACTION_TREE, self::ACTION_BLOB, self::ACTION_RAW,
108
      self::ACTION_COMMITS
109
    ];
110
111
    if( in_array( $this->action, $hasHash ) ) {
112
      $hash = array_shift( $uriParts );
113
      $this->commitHash = $hash ?: self::REFERENCE_HEAD;
114
      $this->filePath   = implode( '/', $uriParts );
115
    } elseif( $this->action === self::ACTION_COMMIT ) {
116
      $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD;
117
    } elseif( $this->action === self::ACTION_COMPARE ) {
118
      $this->commitHash = array_shift( $uriParts ) ?? self::REFERENCE_HEAD;
119
      $this->baseHash   = array_shift( $uriParts ) ?? '';
120
    }
121
122
    $this->populateGet();
123
124
    return $this->createPage();
125
  }
126
127
  private function createPage(): Page {
128
    return match( $this->action ) {
129
      self::ACTION_TREE,
130
      self::ACTION_BLOB    => new FilePage(
131
        $this->repos, $this->repoData, $this->git, $this->commitHash,
132
        $this->filePath
133
      ),
134
      self::ACTION_RAW     => new RawPage(
135
        $this->git, $this->commitHash
136
      ),
137
      self::ACTION_COMMITS => new CommitsPage(
138
        $this->repos, $this->repoData, $this->git, $this->commitHash
139
      ),
140
      self::ACTION_COMMIT  => new DiffPage(
141
        $this->repos, $this->repoData, $this->git, $this->commitHash
142
      ),
143
      self::ACTION_TAGS    => new TagsPage(
144
        $this->repos, $this->repoData, $this->git
145
      ),
146
      self::ACTION_COMPARE => new ComparePage(
147
        $this->repos, $this->repoData, $this->git, $this->commitHash,
148
        $this->baseHash
149
      ),
150
      default              => new FilePage(
151
        $this->repos, $this->repoData, $this->git, self::REFERENCE_HEAD, ''
152
      )
153
    };
154
  }
155
156
  private function normalizeQueryString(): void {
157
    if( empty( $_GET ) && !empty( $_SERVER['QUERY_STRING'] ) ) {
158
      parse_str( $_SERVER['QUERY_STRING'], $_GET );
159
    }
160
  }
161
162
  private function parseUriParts(): array {
163
    $requestUri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
164
    $scriptName = dirname( $_SERVER['SCRIPT_NAME'] );
165
166
    if( $scriptName !== '/' && strpos( $requestUri, $scriptName ) === 0 ) {
167
      $requestUri = substr( $requestUri, strlen( $scriptName ) );
168
    }
169
170
    $requestUri = trim( $requestUri, '/' );
171
    $uriParts   = explode( '/', $requestUri );
172
173
    if( !empty( $uriParts ) && $uriParts[0] === self::ROUTE_REPO ) {
174
      array_shift( $uriParts );
175
    }
176
177
    return $uriParts;
178
  }
179
180
  private function populateGet(): void {
181
    $_GET[self::GET_REPOSITORY] = $this->repoName;
182
    $_GET[self::GET_ACTION]     = $this->action;
183
    $_GET[self::GET_HASH]       = $this->commitHash;
184
    $_GET[self::GET_NAME]       = $this->filePath;
185
  }
186
}
1187
A model/Tag.php
1
<?php
2
require_once __DIR__ . '/../render/TagRenderer.php';
3
4
class Tag {
5
  private string $name;
6
  private string $sha;
7
  private string $targetSha;
8
  private int $timestamp;
9
  private string $message;
10
  private string $author;
11
12
  public function __construct(
13
    string $name,
14
    string $sha,
15
    string $targetSha,
16
    int $timestamp,
17
    string $message,
18
    string $author
19
  ) {
20
    $this->name      = $name;
21
    $this->sha       = $sha;
22
    $this->targetSha = $targetSha;
23
    $this->timestamp = $timestamp;
24
    $this->message   = $message;
25
    $this->author    = $author;
26
  }
27
28
  public function compare( Tag $other ): int {
29
    return $other->timestamp <=> $this->timestamp;
30
  }
31
32
  public function render( TagRenderer $renderer, ?Tag $prevTag = null ): void {
33
    $renderer->renderTagItem(
34
      $this->name,
35
      $this->sha,
36
      $this->targetSha,
37
      $prevTag ? $prevTag->targetSha : null,
38
      $this->timestamp,
39
      $this->message,
40
      $this->author
41
    );
42
  }
43
}
144
A model/UrlBuilder.php
1
<?php
2
class UrlBuilder {
3
  private const REPO_PREFIX = '/repo/';
4
  private const HEAD_REF    = '/HEAD';
5
  private const ACT_TREE    = 'tree';
6
7
  private $repo;
8
  private $action;
9
  private $hash;
10
  private $name;
11
  private $switcher;
12
13
  public function withRepo( $repo ) {
14
    $this->repo = $repo;
15
16
    return $this;
17
  }
18
19
  public function withAction( $action ) {
20
    $this->action = $action;
21
22
    return $this;
23
  }
24
25
  public function withHash( $hash ) {
26
    $this->hash = $hash;
27
28
    return $this;
29
  }
30
31
  public function withName( $name ) {
32
    $this->name = $name;
33
34
    return $this;
35
  }
36
37
  public function withSwitcher( $jsValue ) {
38
    $this->switcher = $jsValue;
39
40
    return $this;
41
  }
42
43
  public function build() {
44
    return $this->switcher
45
      ? "window.location.href='" . self::REPO_PREFIX . "' + " . $this->switcher
46
      : ($this->repo ? $this->assembleUrl() : '/');
47
  }
48
49
  private function assembleUrl() {
50
    $url = self::REPO_PREFIX . $this->repo;
51
    $act = !$this->action && $this->name ? self::ACT_TREE : $this->action;
52
53
    if( $act ) {
54
      $url .= '/' . $act . $this->resolveHashSegment( $act );
55
    }
56
57
    if( $this->name ) {
58
      $url .= '/' . ltrim( $this->name, '/' );
59
    }
60
61
    return $url;
62
  }
63
64
  private function resolveHashSegment( $act ) {
65
    return $this->hash
66
      ? '/' . $this->hash
67
      : (in_array( $act, ['tree', 'blob', 'raw', 'commits'] )
68
        ? self::HEAD_REF
69
        : '');
70
  }
71
}
172
M pages/BasePage.php
11
<?php
2
require_once __DIR__ . '/../File.php';
3
require_once __DIR__ . '/../UrlBuilder.php';
42
require_once __DIR__ . '/Page.php';
3
require_once __DIR__ . '/../model/UrlBuilder.php';
54
65
abstract class BasePage implements Page {
...
1817
  ) {
1918
    $siteTitle = Config::SITE_TITLE;
20
    $pageTitle = $this->title
21
      ? ' - ' . htmlspecialchars( $this->title )
22
      : '';
19
    $pageTitle = $this->title === ''
20
      ? ''
21
      : ' - ' . htmlspecialchars( $this->title );
2322
2423
    ?>
M pages/ClonePage.php
1111
  }
1212
13
  public function render() {
14
    if( $this->subPath === '' ) {
13
  public function render(): void {
14
    $path = $this->subPath;
15
16
    if( $path === '' ) {
1517
      $this->redirectBrowser();
16
    } elseif( str_ends_with( $this->subPath, 'info/refs' ) ) {
18
    } elseif( \str_ends_with( $path, 'info/refs' ) ) {
1719
      $this->renderInfoRefs();
18
    } elseif( str_ends_with( $this->subPath, 'git-upload-pack' ) ) {
20
    } elseif( \str_ends_with( $path, 'git-upload-pack' ) ) {
1921
      $this->handleUploadPack();
20
    } elseif( str_ends_with( $this->subPath, 'git-receive-pack' ) ) {
21
      http_response_code( 403 );
22
    } elseif( \str_ends_with( $path, 'git-receive-pack' ) ) {
23
      \http_response_code( 403 );
2224
      echo "Read-only repository.";
23
    } elseif( $this->subPath === 'HEAD' ) {
25
    } elseif( $path === 'HEAD' ) {
2426
      $this->serve( 'HEAD', 'text/plain' );
25
    } elseif( strpos( $this->subPath, 'objects/' ) === 0 ) {
26
      $this->serve( $this->subPath, 'application/x-git-object' );
27
    } elseif( \strpos( $path, 'objects/' ) === 0 ) {
28
      $this->serve( $path, 'application/x-git-object' );
2729
    } else {
28
      http_response_code( 404 );
30
      \http_response_code( 404 );
2931
      echo "Not Found";
3032
    }
3133
3234
    exit;
3335
  }
3436
3537
  private function redirectBrowser(): void {
36
    $url = str_replace( '.git', '', $_SERVER['REQUEST_URI'] );
37
38
    header( "Location: $url" );
38
    \header(
39
      "Location: " . \str_replace( '.git', '', $_SERVER['REQUEST_URI'] )
40
    );
3941
  }
4042
4143
  private function renderInfoRefs(): void {
42
    $service = $_GET['service'] ?? '';
43
44
    if( $service === 'git-upload-pack' ) {
45
      header( 'Content-Type: application/x-git-upload-pack-advertisement' );
46
      header( 'Cache-Control: no-cache' );
44
    if( ( $_GET['service'] ?? '' ) === 'git-upload-pack' ) {
45
      \header( 'Content-Type: application/x-git-upload-pack-advertisement' );
46
      \header( 'Cache-Control: no-cache' );
4747
4848
      $this->packetWrite( "# service=git-upload-pack\n" );
4949
      $this->packetFlush();
5050
5151
      $refs = [];
52
5253
      $this->git->eachRef( function( $ref, $sha ) use ( &$refs ) {
53
        $refs[] = ['ref' => $ref, 'sha' => $sha];
54
        $refs[] = [ 'ref' => $ref, 'sha' => $sha ];
5455
      } );
5556
...
6768
        );
6869
69
        for( $i = 1; $i < count( $refs ); $i++ ) {
70
        for( $i = 1; $i < \count( $refs ); $i++ ) {
7071
          $this->packetWrite(
7172
            $refs[$i]['sha'] . " " . $refs[$i]['ref'] . "\n"
7273
          );
7374
        }
7475
      }
7576
7677
      $this->packetFlush();
7778
    } else {
78
      header( 'Content-Type: text/plain' );
79
      \header( 'Content-Type: text/plain' );
7980
8081
      if( !$this->git->streamRaw( 'info/refs' ) ) {
...
8788
8889
  private function handleUploadPack(): void {
89
    set_time_limit( 0 );
90
91
    header( 'Content-Type: application/x-git-upload-pack-result' );
92
    header( 'Cache-Control: no-cache' );
90
    \set_time_limit( 0 );
91
    \header( 'Content-Type: application/x-git-upload-pack-result' );
92
    \header( 'Cache-Control: no-cache' );
9393
9494
    $wants  = [];
9595
    $haves  = [];
96
    $handle = fopen( 'php://input', 'rb' );
96
    $handle = \fopen( 'php://input', 'rb' );
9797
9898
    if( $handle ) {
99
      // If the input is gzipped, we wrap the stream
10099
      if( isset( $_SERVER['HTTP_CONTENT_ENCODING'] ) &&
101100
          $_SERVER['HTTP_CONTENT_ENCODING'] === 'gzip' ) {
102
        stream_filter_append( $handle, 'zlib.inflate', STREAM_FILTER_READ, [
103
          'window' => 31
104
        ] );
101
        \stream_filter_append(
102
          $handle,
103
          'zlib.inflate',
104
          \STREAM_FILTER_READ,
105
          [ 'window' => 31 ]
106
        );
105107
      }
106
107
      while( !feof( $handle ) ) {
108
        $lenHex = fread( $handle, 4 );
109
110
        if( strlen( $lenHex ) < 4 ) {
111
          break;
112
        }
113108
114
        $len = hexdec( $lenHex );
109
      while( !\feof( $handle ) ) {
110
        $lenHex = \fread( $handle, 4 );
111
        $len    = \strlen( $lenHex ) === 4 ? \hexdec( $lenHex ) : 0;
115112
116
        if( $len === 0 ) { // Flush packet
113
        if( $len === 0 ) {
117114
          break;
118
        }
119
120
        if( $len <= 4 ) {
121
          continue;
122115
        }
123
124
        $line = fread( $handle, $len - 4 );
125
        $trim = trim( $line );
126116
127
        if( strpos( $trim, 'want ' ) === 0 ) {
128
          $wants[] = explode( ' ', $trim )[1];
129
        } elseif( strpos( $trim, 'have ' ) === 0 ) {
130
          $haves[] = explode( ' ', $trim )[1];
131
        }
117
        if( $len > 4 ) {
118
          $trim = \trim( \fread( $handle, $len - 4 ) );
132119
133
        if( $trim === 'done' ) {
134
          break;
120
          if( \strpos( $trim, 'want ' ) === 0 ) {
121
            $wants[] = \explode( ' ', $trim )[1];
122
          } elseif( \strpos( $trim, 'have ' ) === 0 ) {
123
            $haves[] = \explode( ' ', $trim )[1];
124
          } elseif( $trim === 'done' ) {
125
            break;
126
          }
135127
        }
136128
      }
137129
138
      fclose( $handle );
130
      \fclose( $handle );
139131
    }
140132
141133
    if( !empty( $wants ) ) {
142134
      $this->packetWrite( "NAK\n" );
143135
144
      $objects       = $this->git->collectObjects( $wants, $haves );
145
      $lastHeartbeat = time();
136
      $objects = $this->git->collectObjects( $wants, $haves );
137
      $lastHb  = \time();
146138
147139
      foreach( $this->git->generatePackfile( $objects ) as $chunk ) {
148140
        if( $chunk !== '' ) {
149141
          $this->sendSidebandData( 1, $chunk );
150142
        }
151143
152
        $now = time();
153
        if( $now - $lastHeartbeat >= 5 ) {
144
        $now = \time();
145
146
        if( $now - $lastHb >= 5 ) {
154147
          $this->sendSidebandData( 2, "\r" );
155
          $lastHeartbeat = $now;
148
          $lastHb = $now;
156149
        }
157150
      }
158151
    }
159152
160153
    $this->packetFlush();
161154
  }
162155
163156
  private function sendSidebandData( int $band, string $data ): void {
164157
    $chunkSize = 65515;
165
    $len       = strlen( $data );
158
    $len       = \strlen( $data );
166159
167160
    for( $offset = 0; $offset < $len; $offset += $chunkSize ) {
168
      $chunk = substr( $data, $offset, $chunkSize );
169
170
      $this->packetWrite( chr( $band ) . $chunk );
171
    }
172
  }
173
174
  private function readPacketLine( string $input, int $offset ): array {
175
    $line = '';
176
    $next = $offset;
177
178
    if( $offset + 4 <= strlen( $input ) ) {
179
      $lenHex = substr( $input, $offset, 4 );
180
181
      if( ctype_xdigit( $lenHex ) ) {
182
        $len = hexdec( $lenHex );
183
184
        if( $len === 0 ) {
185
          $next = $offset + 4;
186
        } elseif( $len >= 4 ) {
187
          if( $offset + $len <= strlen( $input ) ) {
188
            $line = substr( $input, $offset + 4, $len - 4 );
189
            $next = $offset + $len;
190
          }
191
        }
192
      }
161
      $this->packetWrite(
162
        \chr( $band ) . \substr( $data, $offset, $chunkSize )
163
      );
193164
    }
194
195
    return [$line, $next];
196165
  }
197166
198167
  private function serve( string $path, string $contentType ): void {
199
    header( 'Content-Type: ' . $contentType );
168
    \header( 'Content-Type: ' . $contentType );
200169
201170
    if( !$this->git->streamRaw( $path ) ) {
202
      http_response_code( 404 );
171
      \http_response_code( 404 );
203172
      echo "Missing: $path";
204173
    }
205174
  }
206175
207176
  private function packetWrite( string $data ): void {
208
    printf( "%04x%s", strlen( $data ) + 4, $data );
177
    \printf( "%04x%s", \strlen( $data ) + 4, $data );
209178
  }
210179
M pages/CommitsPage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../UrlBuilder.php';
3
require_once __DIR__ . '/../model/UrlBuilder.php';
4
require_once __DIR__ . '/../model/Commit.php';
5
require_once __DIR__ . '/../render/HtmlCommitRenderer.php';
46
57
class CommitsPage extends BasePage {
68
  private const PER_PAGE = 100;
79
8
  private $currentRepo;
9
  private $git;
10
  private $hash;
10
  private array  $currentRepo;
11
  private Git    $git;
12
  private string $hash;
1113
1214
  public function __construct(
...
2325
  }
2426
25
  public function render() {
27
  public function render(): void {
2628
    $this->renderLayout( function() {
27
      $main  = $this->git->getMainBranch();
28
      $start = '';
29
      $count = 0;
29
      $main = $this->git->getMainBranch();
3030
3131
      if( !$main ) {
...
3838
             htmlspecialchars( $main['name'] ) . '</span></h2>';
3939
        echo '<div class="commit-list">';
40
41
        $start = $this->hash !== '' ? $this->hash : $main['hash'];
4240
41
        $start   = $this->hash !== '' ? $this->hash : $main['hash'];
4342
        $commits = [];
4443
4544
        $this->git->history(
4645
          $start,
4746
          self::PER_PAGE,
48
          function( $commit ) use ( &$commits ) {
47
          function( Commit $commit ) use( &$commits ) {
4948
            $commits[] = $commit;
5049
          }
5150
        );
5251
53
        $count = count( $commits );
54
        $nav   = $this->buildPagination( $main['hash'], $count );
52
        $nav = $this->buildPagination( $main['hash'], count( $commits ) );
5553
5654
        $this->renderPagination( $nav );
55
56
        $renderer = new HtmlCommitRenderer(
57
          $this->currentRepo['safe_name']
58
        );
5759
5860
        foreach( $commits as $commit ) {
59
          $this->renderCommitRow( $commit );
61
          $commit->render( $renderer );
6062
        }
6163
6264
        echo '</div>';
63
6465
        $this->renderPagination( $nav );
6566
      }
6667
    }, $this->currentRepo );
67
  }
68
69
  private function renderCommitRow( object $commit ) {
70
    $msg = htmlspecialchars( explode( "\n", $commit->message )[0] );
71
    $url = $this->buildCommitUrl( $commit->sha );
72
73
    echo '<div class="commit-row">';
74
    echo '<a href="' . $url . '" class="sha">' .
75
         substr( $commit->sha, 0, 7 ) . '</a>';
76
    echo '<span class="message">' . $msg . '</span>';
77
    echo '<span class="meta">' . htmlspecialchars( $commit->author ) .
78
         ' &bull; ' . date( 'Y-m-d', $commit->date ) . '</span>';
79
    echo '</div>';
8068
  }
8169
82
  private function renderPagination( array $nav ) {
70
  private function renderPagination( array $nav ): void {
8371
    $pages   = $nav['pages'];
8472
    $current = $nav['current'];
8573
    $hasNext = $nav['hasNext'];
86
    $hasAll  = $nav['hasAll'];
8774
    $hasPrev = $current > 1;
8875
    $total   = count( $pages );
8976
9077
    if( $hasPrev || $hasNext ) {
9178
      echo '<div class="pagination">';
9279
9380
      if( $hasPrev ) {
94
        $firstUrl = $this->buildPageUrl( $pages[0] );
95
96
        echo '<a href="' . $firstUrl . '" class="page-link page-nav" ' .
97
             'aria-label="first">' . $this->svgArrow( 'first' ) .
98
             '</a>';
99
100
        $prevUrl = $this->buildPageUrl( $pages[$current - 2] );
81
        echo '<a href="' . $this->pageUrl( $pages[0] ) .
82
             '" class="page-link page-nav" aria-label="first">' .
83
             $this->svgArrow( 'first' ) . '</a>';
10184
102
        echo '<a href="' . $prevUrl . '" class="page-link page-nav" ' .
103
             'aria-label="back">' . $this->svgArrow( 'back' ) .
104
             '</a>';
85
        echo '<a href="' . $this->pageUrl( $pages[$current - 2] ) .
86
             '" class="page-link page-nav" aria-label="back">' .
87
             $this->svgArrow( 'back' ) . '</a>';
10588
      } else {
10689
        echo '<span class="page-link page-nav page-nav-hidden" ' .
...
11699
117100
      if( $hasNext ) {
118
        $nextUrl = $this->buildPageUrl( $pages[$current] );
119
        $lastUrl = $this->buildPageUrl( $pages[$total - 1] );
120
121
        echo '<a href="' . $nextUrl . '" class="page-link page-nav" ' .
122
             'aria-label="next">' . $this->svgArrow( 'next' ) .
123
             '</a>';
101
        echo '<a href="' . $this->pageUrl( $pages[$current] ) .
102
             '" class="page-link page-nav" aria-label="next">' .
103
             $this->svgArrow( 'next' ) . '</a>';
124104
125
        echo '<a href="' . $lastUrl . '" class="page-link page-nav" ' .
126
             'aria-label="last">' . $this->svgArrow( 'last' ) .
127
             '</a>';
105
        echo '<a href="' . $this->pageUrl( $pages[$total - 1] ) .
106
             '" class="page-link page-nav" aria-label="last">' .
107
             $this->svgArrow( 'last' ) . '</a>';
128108
      } else {
129109
        echo '<span class="page-link page-nav page-nav-hidden" ' .
...
149129
150130
    $inner = $icons[$type] ?? '';
151
    $svg   = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ' .
152
             'fill="none" stroke="currentColor" stroke-width="2" ' .
153
             'stroke-linecap="round" stroke-linejoin="round" ' .
154
             'aria-label="' . $type . '" role="img">' .
155
             '<title>' . $type . '</title>' . $inner . '</svg>';
156131
157
    return $svg;
132
    return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ' .
133
           'fill="none" stroke="currentColor" stroke-width="2" ' .
134
           'stroke-linecap="round" stroke-linejoin="round" ' .
135
           'aria-label="' . $type . '" role="img">' .
136
           '<title>' . $type . '</title>' . $inner . '</svg>';
158137
  }
159138
160
  private function renderPageNumbers( array $pages, int $current ) {
139
  private function renderPageNumbers( array $pages, int $current ): void {
161140
    $total  = count( $pages );
162
    $start  = $current - 4;
141
    $start  = max( 1, $current - 4 );
142
    $end    = min( $total, $start + 9 );
163143
    $actual = 1;
164
    $end    = 0;
165
    $sha    = '';
166
    $url    = '';
167
168
    if( $start < 1 ) {
169
      $start = 1;
170
    }
171
172
    $end = $start + 9;
173
174
    if( $end > $total ) {
175
      $end   = $total;
176
      $start = $end - 9;
177144
178
      if( $start < 1 ) {
179
        $start = 1;
180
      }
145
    if( $end === $total ) {
146
      $start = max( 1, $end - 9 );
181147
    }
182148
183149
    while( $actual <= $total ) {
184150
      if( $actual >= $start && $actual <= $end ) {
185151
        if( $actual === $current ) {
186152
          echo '<span class="page-badge">' . $actual . '</span>';
187153
        } else {
188
          $sha = $pages[$actual - 1];
189
          $url = $this->buildPageUrl( $sha );
190
191
          echo '<a href="' . $url . '" class="page-link">' . $actual . '</a>';
154
          echo '<a href="' . $this->pageUrl( $pages[$actual - 1] ) .
155
               '" class="page-link">' . $actual . '</a>';
192156
        }
193157
      }
194158
195159
      $actual++;
196160
    }
197161
  }
198162
199163
  private function buildPagination( string $mainHash, int $count ): array {
200164
    $target      = $this->hash !== '' ? $this->hash : $mainHash;
201
    $pageHashes  = [];
165
    $pageCommits = [];
202166
    $commits     = 0;
203167
    $currentPage = 1;
204168
    $found       = false;
205169
    $hitLimit    = false;
206
    $result      = [];
207170
208171
    $this->git->history(
209172
      $mainHash,
210173
      PHP_INT_MAX,
211
      function( $commit ) use (
174
      function( Commit $commit ) use(
212175
        $target,
213
        &$pageHashes,
176
        &$pageCommits,
214177
        &$commits,
215178
        &$currentPage,
216179
        &$found,
217180
        &$hitLimit
218181
      ) {
219182
        $continue = true;
220183
221184
        if( $commits % self::PER_PAGE === 0 ) {
222
          $pageHashes[] = $commit->sha;
185
          $pageCommits[] = $commit;
223186
        }
224187
225
        if( $commit->sha === $target ) {
226
          $currentPage = count( $pageHashes );
188
        if( $commit->isSha( $target ) ) {
189
          $currentPage = count( $pageCommits );
227190
          $found       = true;
228191
        }
229192
230
        if( $found && count( $pageHashes ) > $currentPage + 10 ) {
193
        if( $found && count( $pageCommits ) > $currentPage + 10 ) {
231194
          $hitLimit = true;
232195
          $continue = false;
...
240203
      }
241204
    );
242
243
    $result['pages']   = $pageHashes;
244
    $result['current'] = $currentPage;
245
    $result['hasAll']  = !$hitLimit;
246
    $result['hasNext'] = $count === self::PER_PAGE &&
247
                         isset( $pageHashes[$currentPage] );
248
249
    return $result;
250
  }
251
252
  private function buildCommitUrl( string $targetHash ): string {
253
    $builder = new UrlBuilder();
254
    $result  = '';
255
256
    $builder->withRepo( $this->currentRepo['safe_name'] );
257
    $builder->withAction( 'commit' );
258
    $builder->withHash( $targetHash );
259
260
    $result = $builder->build();
261205
262
    return $result;
206
    return [
207
      'pages'   => $pageCommits,
208
      'current' => $currentPage,
209
      'hasAll'  => !$hitLimit,
210
      'hasNext' => $count === self::PER_PAGE &&
211
                   isset( $pageCommits[$currentPage] )
212
    ];
263213
  }
264
265
  private function buildPageUrl( string $targetHash ): string {
266
    $builder = new UrlBuilder();
267
    $result  = '';
268
269
    $builder->withRepo( $this->currentRepo['safe_name'] );
270
    $builder->withAction( 'commits' );
271
    $builder->withHash( $targetHash );
272
273
    $result = $builder->build();
274214
275
    return $result;
215
  private function pageUrl( Commit $commit ): string {
216
    return $commit->toUrl(
217
      (new UrlBuilder())
218
        ->withRepo( $this->currentRepo['safe_name'] )
219
        ->withAction( 'commits' )
220
    );
276221
  }
277222
}
M pages/ComparePage.php
8383
8484
  private function renderDiffLine( array $line ) {
85
    if( isset( $line['t'] ) && $line['t'] === 'gap' ) {
85
    if( $line['t'] === 'gap' ) {
8686
      echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
8787
    } else {
8888
      $class = match( $line['t'] ) {
8989
        '+'     => 'diff-add',
9090
        '-'     => 'diff-del',
9191
        default => ''
9292
      };
9393
9494
      echo '<tr class="' . $class . '">';
95
      echo '<td class="diff-line-num">' . ($line['no'] ?? '') . '</td>';
96
      echo '<td class="diff-line-num">' . ($line['nn'] ?? '') . '</td>';
95
      echo '<td class="diff-line-num">' . $line['no'] . '</td>';
96
      echo '<td class="diff-line-num">' . $line['nn'] . '</td>';
9797
      echo '<td class="diff-code"><pre>' .
98
           htmlspecialchars( $line['l'] ?? '' ) . '</pre></td>';
98
           htmlspecialchars( $line['l'] ) . '</pre></td>';
9999
      echo '</tr>';
100100
    }
M pages/DiffPage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../UrlBuilder.php';
43
require_once __DIR__ . '/../git/GitDiff.php';
4
require_once __DIR__ . '/../model/UrlBuilder.php';
55
66
class DiffPage extends BasePage {
...
142142
                  $pluralize( $deleted ) . ' removed';
143143
144
      if( $diffNet === 0 ) {
145
        $deltaMsg .= ', 0 lines changed';
146
      } elseif( $added > 0 && $deleted > 0 ) {
147
        if( $diffNet > 0 ) {
148
          $deltaMsg .= ', ' . $diffNet . '-line increase';
149
        } else {
150
          $deltaMsg .= ', ' . abs( $diffNet ) . '-line decrease';
151
        }
144
      if( $diffNet !== 0 ) {
145
        $direction = $diffNet > 0 ? 'increase' : 'decrease';
146
        $deltaMsg .= ', ' . abs( $diffNet ) . "-line $direction";
152147
      }
153148
M pages/FilePage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../UrlBuilder.php';
3
require_once __DIR__ . '/../model/UrlBuilder.php';
44
require_once __DIR__ . '/../render/HtmlFileRenderer.php';
55
M pages/HomePage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../UrlBuilder.php';
3
require_once __DIR__ . '/../model/UrlBuilder.php';
4
require_once __DIR__ . '/../model/Commit.php';
5
require_once __DIR__ . '/../render/HtmlCommitRenderer.php';
46
57
class HomePage extends BasePage {
6
  private $repositories;
7
  private $git;
8
  private array $repositories;
9
  private Git   $git;
810
911
  public function __construct( array $repositories, Git $git ) {
1012
    parent::__construct( $repositories );
13
1114
    $this->repositories = $repositories;
1215
    $this->git          = $git;
1316
  }
1417
15
  public function render() {
18
  public function render(): void {
1619
    $this->renderLayout( function() {
1720
      echo '<h2>Repositories</h2>';
...
3134
  }
3235
33
  private function renderRepoCard( $repo ) {
36
  private function renderRepoCard( array $repo ): void {
3437
    $this->git->setRepository( $repo['path'] );
3538
36
    $main  = $this->git->getMainBranch();
37
    $stats = [
38
      'branches' => 0,
39
      'tags'     => 0
40
    ];
39
    $stats = [ 'branches' => 0, 'tags' => 0 ];
4140
42
    $this->git->eachBranch( function() use ( &$stats ) {
41
    $this->git->eachBranch( function() use( &$stats ) {
4342
      $stats['branches']++;
4443
    } );
4544
46
    $this->git->eachTag( function() use ( &$stats ) {
45
    $this->git->eachTag( function() use( &$stats ) {
4746
      $stats['tags']++;
4847
    } );
4948
5049
    $url = (new UrlBuilder())->withRepo( $repo['safe_name'] )->build();
50
5151
    echo '<a href="' . $url . '" class="repo-card">';
5252
    echo '<h3>' . htmlspecialchars( $repo['name'] ) . '</h3>';
5353
    echo '<p class="repo-meta">';
54
55
    $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches';
56
    $tagLabel    = $stats['tags'] === 1 ? 'tag' : 'tags';
57
58
    echo $stats['branches'] . ' ' . $branchLabel . ', ' .
59
         $stats['tags'] . ' ' . $tagLabel;
54
    echo $stats['branches'] .
55
         ($stats['branches'] === 1 ? ' branch, ' : ' branches, ') .
56
         $stats['tags'] .
57
         ($stats['tags'] === 1 ? ' tag' : ' tags');
6058
61
    if( $main ) {
59
    if( $this->git->getMainBranch() ) {
6260
      echo ', ';
6361
64
      $this->git->history( 'HEAD', 1, function( $c ) use ( $repo ) {
65
        $renderer = new HtmlFileRenderer( $repo['safe_name'] );
66
        $renderer->renderTime( $c->date );
62
      $this->git->history( 'HEAD', 1, function( Commit $c ) use( $repo ) {
63
        $renderer = new HtmlCommitRenderer( $repo['safe_name'] );
64
65
        $c->renderTime( $renderer );
6766
      } );
6867
    }
M pages/RawPage.php
33
44
class RawPage implements Page {
5
  private $git;
6
  private $hash;
5
  private Git    $git;
6
  private string $hash;
77
8
  public function __construct( $git, $hash ) {
8
  public function __construct( Git $git, string $hash ) {
99
    $this->git  = $git;
1010
    $this->hash = $hash;
1111
  }
1212
13
  public function render() {
13
  public function render(): void {
1414
    $name = $_GET['name'] ?? '';
1515
    $file = $this->git->readFile( $this->hash, $name );
1616
17
    while( ob_get_level() ) {
18
      ob_end_clean();
17
    while( ob_get_level() > 0 ) {
18
      if( !ob_end_clean() ) {
19
        break;
20
      }
1921
    }
2022
2123
    $file->emitRawHeaders();
22
    $this->git->stream( $this->hash, function( $d ) {
23
      echo $d;
24
    }, $name );
24
25
    $this->git->stream(
26
      $this->hash,
27
      function( string $data ): void {
28
        echo $data;
29
      },
30
      $name
31
    );
2532
2633
    exit;
M pages/TagsPage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../model/Tag.php';
34
require_once __DIR__ . '/../render/HtmlTagRenderer.php';
45
A render/CommitRenderer.php
1
<?php
2
interface CommitRenderer {
3
  public function render(
4
    string $sha,
5
    string $message,
6
    string $author,
7
    int    $date
8
  ): void;
9
10
  public function renderTime( int $timestamp ): void;
11
}
112
M render/FileRenderer.php
11
<?php
2
require_once __DIR__ . '/../File.php';
2
require_once __DIR__ . '/../model/File.php';
33
44
interface FileRenderer {
A render/HtmlCommitRenderer.php
1
<?php
2
require_once __DIR__ . '/CommitRenderer.php';
3
require_once __DIR__ . '/../model/UrlBuilder.php';
4
5
class HtmlCommitRenderer implements CommitRenderer {
6
  private string $repoSafeName;
7
8
  public function __construct( string $repoSafeName ) {
9
    $this->repoSafeName = $repoSafeName;
10
  }
11
12
  public function render(
13
    string $sha,
14
    string $message,
15
    string $author,
16
    int    $date
17
  ): void {
18
    $msg = htmlspecialchars( explode( "\n", $message )[0] );
19
    $url = (new UrlBuilder())
20
      ->withRepo( $this->repoSafeName )
21
      ->withAction( 'commit' )
22
      ->withHash( $sha )
23
      ->build();
24
25
    echo '<div class="commit-row">';
26
    echo '<a href="' . $url . '" class="sha">' .
27
         substr( $sha, 0, 7 ) . '</a>';
28
    echo '<span class="message">' . $msg . '</span>';
29
    echo '<span class="meta">' . htmlspecialchars( $author ) .
30
         ' &bull; ' . date( 'Y-m-d', $date ) . '</span>';
31
    echo '</div>';
32
  }
33
34
  public function renderTime( int $timestamp ): void {
35
    $tokens = [
36
      31536000 => 'year', 2592000 => 'month', 604800 => 'week',
37
      86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second'
38
    ];
39
    $diff = $timestamp ? time() - $timestamp : null;
40
    $result = 'never';
41
42
    if( $diff && $diff >= 5 ) {
43
      foreach( $tokens as $unit => $text ) {
44
        if( $diff < $unit ) continue;
45
        $num = floor( $diff / $unit );
46
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
47
        break;
48
      }
49
    } elseif( $diff ) {
50
      $result = 'just now';
51
    }
52
53
    echo $result;
54
  }
55
}
156
M render/HtmlFileRenderer.php
22
require_once __DIR__ . '/FileRenderer.php';
33
require_once __DIR__ . '/Highlighter.php';
4
require_once __DIR__ . '/../UrlBuilder.php';
4
require_once __DIR__ . '/../model/UrlBuilder.php';
55
66
class HtmlFileRenderer implements FileRenderer {
M render/HtmlTagRenderer.php
11
<?php
22
require_once __DIR__ . '/TagRenderer.php';
3
require_once __DIR__ . '/../model/UrlBuilder.php';
34
45
class HtmlTagRenderer implements TagRenderer {