Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M Config.php
44
55
  private static function getHomeDirectory() {
6
    if (!empty($_SERVER['HOME'])) {
6
    if( !empty( $_SERVER['HOME'] ) ) {
77
      return $_SERVER['HOME'];
88
    }
9
    if (!empty(getenv('HOME'))) {
10
      return getenv('HOME');
9
10
    if( !empty( getenv( 'HOME' ) ) ) {
11
      return getenv( 'HOME' );
1112
    }
12
    if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) {
13
      $userInfo = posix_getpwuid(posix_getuid());
14
      if (!empty($userInfo['dir'])) {
13
14
    if( function_exists( 'posix_getpwuid' ) &&
15
        function_exists( 'posix_getuid' ) ) {
16
      $userInfo = posix_getpwuid( posix_getuid() );
17
18
      if( !empty( $userInfo['dir'] ) ) {
1519
        return $userInfo['dir'];
1620
      }
1721
    }
22
1823
    return '';
1924
  }
2025
2126
  public static function getReposPath() {
2227
    return self::getHomeDirectory() . '/repos';
2328
  }
2429
2530
  public static function init() {
26
    ini_set('display_errors', 0);
27
    ini_set('log_errors', 1);
28
    ini_set('error_log', __DIR__ . '/error.log');
31
    ini_set( 'display_errors', 0 );
32
    ini_set( 'log_errors', 1 );
33
    ini_set( 'error_log', __DIR__ . '/error.log' );
2934
  }
3035
}
M File.php
2222
  private int $size;
2323
  private bool $isDir;
24
  private string $icon;
2524
2625
  private string $mediaType;
...
3635
    string $contents = ''
3736
  ) {
38
    $this->name        = $name;
39
    $this->sha         = $sha;
40
    $this->mode        = $mode;
41
    $this->timestamp   = $timestamp;
42
    $this->size        = $size;
43
    $this->isDir       = $mode === '40000' || $mode === '040000';
37
    $this->name = $name;
38
    $this->sha = $sha;
39
    $this->mode = $mode;
40
    $this->timestamp = $timestamp;
41
    $this->size = $size;
42
    $this->isDir = $mode === '40000' || $mode === '040000';
4443
4544
    $buffer = $this->isDir ? '' : $contents;
4645
47
    $this->mediaType = $this->detectMediaType($buffer);
48
    $this->category  = $this->detectCategory($name);
49
    $this->binary    = $this->detectBinary();
50
    $this->icon      = $this->resolveIcon();
46
    $this->mediaType = $this->detectMediaType( $buffer );
47
    $this->category = $this->detectCategory( $name );
48
    $this->binary = $this->detectBinary();
5149
  }
5250
53
  public function compare(File $other): int {
51
  public function compare( File $other ): int {
5452
    return $this->isDir !== $other->isDir
5553
      ? ($this->isDir ? -1 : 1)
56
      : strcasecmp($this->name, $other->name);
54
      : strcasecmp( $this->name, $other->name );
5755
  }
5856
59
  public function render(FileRenderer $renderer): void {
60
    $renderer->renderFile(
57
  public function renderListEntry( FileRenderer $renderer ): void {
58
    $renderer->renderListEntry(
6159
      $this->name,
6260
      $this->sha,
6361
      $this->mode,
64
      $this->icon,
62
      $this->resolveIcon(),
6563
      $this->timestamp,
6664
      $this->size
6765
    );
6866
  }
6967
70
  public function renderSize(FileRenderer $renderer): void {
71
    $renderer->renderSize($this->size);
68
  public function renderMedia( FileRenderer $renderer, string $url ): bool {
69
    return $renderer->renderMedia( $this, $url, $this->mediaType );
7270
  }
73
74
  public function renderMedia(string $url): bool {
75
    $rendered = false;
76
77
    if ($this->isImage()) {
78
      echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>';
79
      $rendered = true;
80
    } elseif ($this->isVideo()) {
81
      echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $this->mediaType . '"></video></div>';
82
      $rendered = true;
83
    } elseif ($this->isAudio()) {
84
      echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $this->mediaType . '"></audio></div>';
85
      $rendered = true;
86
    }
8771
88
    return $rendered;
72
  public function renderSize( FileRenderer $renderer ): void {
73
    $renderer->renderSize( $this->size );
8974
  }
9075
91
  public function emitRawHeaders(): void {
92
    header("Content-Type: " . $this->mediaType);
93
    header("Content-Length: " . $this->size);
94
    header("Content-Disposition: attachment; filename=\"" . addslashes(basename($this->name)) . "\"");
76
  public function highlight( FileRenderer $renderer, string $content ): string {
77
    return $renderer->highlight( $this->name, $content, $this->mediaType );
9578
  }
9679
...
11396
  public function isBinary(): bool {
11497
    return $this->binary;
98
  }
99
100
  public function emitRawHeaders(): void {
101
    header( "Content-Type: " . $this->mediaType );
102
    header( "Content-Length: " . $this->size );
103
    header( "Content-Disposition: attachment; filename=\"" .
104
      addslashes( basename( $this->name ) ) . "\"" );
115105
  }
116106
117107
  private function resolveIcon(): string {
118108
    return $this->isDir
119109
      ? 'fa-folder'
120
      : (str_contains($this->mediaType, 'application/pdf')
110
      : (str_contains( $this->mediaType, 'application/pdf' )
121111
        ? 'fa-file-pdf'
122
        : match ($this->category) {
112
        : match( $this->category ) {
123113
          self::CAT_ARCHIVE => 'fa-file-archive',
124
          self::CAT_IMAGE   => 'fa-file-image',
125
          self::CAT_AUDIO   => 'fa-file-audio',
126
          self::CAT_VIDEO   => 'fa-file-video',
127
          self::CAT_TEXT    => 'fa-file-code',
128
          default           => 'fa-file',
114
          self::CAT_IMAGE => 'fa-file-image',
115
          self::CAT_AUDIO => 'fa-file-audio',
116
          self::CAT_VIDEO => 'fa-file-video',
117
          self::CAT_TEXT => 'fa-file-code',
118
          default => 'fa-file',
129119
        });
130120
  }
131121
132
  private function detectMediaType(string $buffer): string {
133
    $finfo = new finfo(FILEINFO_MIME_TYPE);
134
    $mediaType = $finfo->buffer($buffer);
122
  private function detectMediaType( string $buffer ): string {
123
    if( $buffer === '' ) return 'application/x-empty';
124
125
    $finfo = new finfo( FILEINFO_MIME_TYPE );
126
    $mediaType = $finfo->buffer( $buffer );
127
135128
    return $mediaType ?: 'application/octet-stream';
136129
  }
137130
138
  private function detectCategory(string $filename = ''): string {
139
    $parts = explode('/', $this->mediaType);
131
  private function detectCategory( string $filename = '' ): string {
132
    $parts = explode( '/', $this->mediaType );
140133
141
    return match(true) {
134
    return match( true ) {
142135
      $parts[0] === 'image' => self::CAT_IMAGE,
143136
      $parts[0] === 'video' => self::CAT_VIDEO,
144137
      $parts[0] === 'audio' => self::CAT_AUDIO,
145138
      $parts[0] === 'text' => self::CAT_TEXT,
146
      $this->isArchiveFile($filename) => self::CAT_ARCHIVE,
147
      str_contains($this->mediaType, 'compressed') => self::CAT_ARCHIVE,
139
      $this->isArchiveFile( $filename ) => self::CAT_ARCHIVE,
140
      str_contains( $this->mediaType, 'compressed' ) => self::CAT_ARCHIVE,
148141
      default => self::CAT_BINARY,
149142
    };
150143
  }
151144
152145
  private function detectBinary(): bool {
153
    return !str_starts_with($this->mediaType, 'text/');
146
    return !str_starts_with( $this->mediaType, 'text/' );
154147
  }
155148
156
  private function isArchiveFile(string $filename): bool {
149
  private function isArchiveFile( string $filename ): bool {
157150
    return in_array(
158
      strtolower(pathinfo($filename, PATHINFO_EXTENSION)),
151
      strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) ),
159152
      self::ARCHIVE_EXTENSIONS,
160153
      true
M RepositoryList.php
33
  private $reposPath;
44
5
  public function __construct($path) {
5
  public function __construct( $path ) {
66
    $this->reposPath = $path;
77
  }
88
9
  public function eachRepository(callable $callback) {
9
  public function eachRepository( callable $callback ) {
1010
    $repos = [];
11
    $dirs = glob($this->reposPath . '/*', GLOB_ONLYDIR);
11
    $dirs = glob( $this->reposPath . '/*', GLOB_ONLYDIR );
1212
13
    if ($dirs === false) return;
13
    if( $dirs === false ) {
14
      return;
15
    }
1416
15
    foreach ($dirs as $dir) {
16
      $basename = basename($dir);
17
      if ($basename[0] === '.') continue;
17
    foreach( $dirs as $dir ) {
18
      $basename = basename( $dir );
19
20
      if( $basename[0] === '.' ) {
21
        continue;
22
      }
1823
1924
      $repos[$basename] = [
2025
        'name' => $basename,
2126
        'safe_name' => $basename,
2227
        'path' => $dir
2328
      ];
2429
    }
2530
26
    $this->sortRepositories($repos);
31
    $this->sortRepositories( $repos );
2732
28
    foreach ($repos as $repo) {
29
      $callback($repo);
33
    foreach( $repos as $repo ) {
34
      $callback( $repo );
3035
    }
3136
  }
3237
33
  private function sortRepositories(array &$repos) {
38
  private function sortRepositories( array &$repos ) {
3439
    $orderFile = __DIR__ . '/order.txt';
3540
36
    if (!file_exists($orderFile)) {
37
      ksort($repos, SORT_NATURAL | SORT_FLAG_CASE);
41
    if( !file_exists( $orderFile ) ) {
42
      ksort( $repos, SORT_NATURAL | SORT_FLAG_CASE );
3843
      return;
3944
    }
4045
41
    $lines = file($orderFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
46
    $lines = file( $orderFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
4247
    $order = [];
4348
    $exclude = [];
4449
45
    foreach ($lines as $line) {
46
      $line = trim($line);
47
      if ($line === '') continue;
50
    foreach( $lines as $line ) {
51
      $line = trim( $line );
4852
49
      if ($line[0] === '-') {
50
        $exclude[substr($line, 1)] = true;
53
      if( $line === '' ) {
54
        continue;
55
      }
56
57
      if( $line[0] === '-' ) {
58
        $exclude[substr( $line, 1 )] = true;
5159
      } else {
52
        $order[$line] = count($order);
60
        $order[$line] = count( $order );
5361
      }
5462
    }
5563
56
    foreach ($repos as $key => $repo) {
57
      if (isset($exclude[$repo['safe_name']])) {
58
        unset($repos[$key]);
64
    foreach( $repos as $key => $repo ) {
65
      if( isset( $exclude[$repo['safe_name']] ) ) {
66
        unset( $repos[$key] );
5967
      }
6068
    }
6169
62
    uasort($repos, function($a, $b) use ($order) {
70
    uasort( $repos, function( $a, $b ) use ( $order ) {
6371
      $nameA = $a['safe_name'];
6472
      $nameB = $b['safe_name'];
65
6673
      $posA = $order[$nameA] ?? PHP_INT_MAX;
6774
      $posB = $order[$nameB] ?? PHP_INT_MAX;
6875
69
      if ($posA === $posB) {
70
        return strcasecmp($nameA, $nameB);
76
      if( $posA === $posB ) {
77
        return strcasecmp( $nameA, $nameB );
7178
      }
7279
7380
      return $posA <=> $posB;
74
    });
81
    } );
7582
  }
7683
}
M Router.php
22
require_once __DIR__ . '/RepositoryList.php';
33
require_once __DIR__ . '/git/Git.php';
4
54
require_once __DIR__ . '/pages/CommitsPage.php';
65
require_once __DIR__ . '/pages/DiffPage.php';
...
1716
  public function __construct( string $reposPath ) {
1817
    $this->git = new Git( $reposPath );
19
2018
    $list = new RepositoryList( $reposPath );
2119
2220
    $list->eachRepository( function( $repo ) {
2321
      $this->repos[] = $repo;
2422
    } );
2523
  }
2624
2725
  public function route(): Page {
2826
    $reqRepo = $_GET['repo'] ?? '';
29
    $action  = $_GET['action'] ?? 'file';
30
    $hash    = $this->sanitize( $_GET['hash'] ?? '' );
27
    $action = $_GET['action'] ?? 'file';
28
    $hash = $this->sanitize( $_GET['hash'] ?? '' );
3129
    $subPath = '';
32
3330
    $uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
3431
    $scriptName = $_SERVER['SCRIPT_NAME'];
3532
36
    if ( strpos( $uri, $scriptName ) === 0 ) {
33
    if( strpos( $uri, $scriptName ) === 0 ) {
3734
      $uri = substr( $uri, strlen( $scriptName ) );
3835
    }
3936
4037
    if( preg_match( '#^/([^/]+)\.git(?:/(.*))?$#', $uri, $matches ) ) {
4138
      $reqRepo = urldecode( $matches[1] );
4239
      $subPath = isset( $matches[2] ) ? ltrim( $matches[2], '/' ) : '';
43
      $action  = 'clone';
40
      $action = 'clone';
4441
    }
4542
4643
    $currRepo = null;
47
    $decoded  = urldecode( $reqRepo );
44
    $decoded = urldecode( $reqRepo );
4845
4946
    foreach( $this->repos as $repo ) {
5047
      if( $repo['safe_name'] === $reqRepo || $repo['name'] === $decoded ) {
5148
        $currRepo = $repo;
49
5250
        break;
5351
      }
5452
5553
      $prefix = $repo['safe_name'] . '/';
5654
5755
      if( strpos( $reqRepo, $prefix ) === 0 ) {
5856
        $currRepo = $repo;
59
        $subPath  = substr( $reqRepo, strlen( $prefix ) );
60
        $action   = 'clone';
57
        $subPath = substr( $reqRepo, strlen( $prefix ) );
58
        $action = 'clone';
59
6160
        break;
6261
      }
M Tag.php
1818
    string $author
1919
  ) {
20
    $this->name      = $name;
21
    $this->sha       = $sha;
20
    $this->name = $name;
21
    $this->sha = $sha;
2222
    $this->targetSha = $targetSha;
2323
    $this->timestamp = $timestamp;
24
    $this->message   = $message;
25
    $this->author    = $author;
24
    $this->message = $message;
25
    $this->author = $author;
2626
  }
2727
28
  public function compare(Tag $other): int {
28
  public function compare( Tag $other ): int {
2929
    return $other->timestamp <=> $this->timestamp;
3030
  }
3131
32
  public function render(TagRenderer $renderer): void {
32
  public function render( TagRenderer $renderer ): void {
3333
    $renderer->renderTagItem(
3434
      $this->name,
M git/Git.php
66
77
class Git {
8
  private const CHUNK_SIZE    = 128;
9
  private const MAX_READ_SIZE = 1048576;
10
11
  private string $repoPath;
12
  private string $objectsPath;
13
14
  private GitRefs $refs;
15
  private GitPacks $packs;
16
17
  public function __construct( string $repoPath ) {
18
    $this->setRepository( $repoPath );
19
  }
20
21
  public function setRepository( string $repoPath ): void {
22
    $this->repoPath    = rtrim( $repoPath, '/' );
23
    $this->objectsPath = $this->repoPath . '/objects';
24
25
    $this->refs  = new GitRefs( $this->repoPath );
26
    $this->packs = new GitPacks( $this->objectsPath );
27
  }
28
29
  public function resolve( string $reference ): string {
30
    return $this->refs->resolve( $reference );
31
  }
32
33
  public function getMainBranch(): array {
34
    return $this->refs->getMainBranch();
35
  }
36
37
  public function eachBranch( callable $callback ): void {
38
    $this->refs->scanRefs( 'refs/heads', $callback );
39
  }
40
41
  public function eachTag( callable $callback ): void {
42
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) {
43
      $data = $this->read( $sha );
44
45
      $targetSha = $sha;
46
      $timestamp = 0;
47
      $message   = '';
48
      $author    = '';
49
50
      if( strncmp( $data, 'object ', 7 ) === 0 ) {
51
        if( preg_match( '/^object ([0-9a-f]{40})$/m', $data, $m ) ) {
52
          $targetSha = $m[1];
53
        }
54
        if( preg_match( '/^tagger (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) {
55
          $author    = trim( $m[1] );
56
          $timestamp = (int)$m[2];
57
        }
58
59
        $pos = strpos( $data, "\n\n" );
60
        if( $pos !== false ) {
61
          $message = trim( substr( $data, $pos + 2 ) );
62
        }
63
      } else {
64
        if( preg_match( '/^author (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) {
65
          $author    = trim( $m[1] );
66
          $timestamp = (int)$m[2];
67
        }
68
69
        $pos = strpos( $data, "\n\n" );
70
        if( $pos !== false ) {
71
          $message = trim( substr( $data, $pos + 2 ) );
72
        }
73
      }
74
75
      $callback( new Tag(
76
        $name,
77
        $sha,
78
        $targetSha,
79
        $timestamp,
80
        $message,
81
        $author
82
      ) );
83
    } );
84
  }
85
86
  public function getObjectSize( string $sha ): int {
87
    $size = $this->packs->getSize( $sha );
88
89
    if( $size !== null ) {
90
      return $size;
91
    }
92
93
    return $this->getLooseObjectSize( $sha );
94
  }
95
96
  public function peek( string $sha, int $length = 255 ): string {
97
    $size = $this->packs->getSize( $sha );
98
99
    if( $size === null ) {
100
      return $this->peekLooseObject( $sha, $length );
101
    }
102
103
    return $this->packs->peek( $sha, $length ) ?? '';
104
  }
105
106
  public function read( string $sha ): string {
107
    $size = $this->getObjectSize( $sha );
108
109
    if( $size > self::MAX_READ_SIZE ) {
110
      return '';
111
    }
112
113
    $content = '';
114
115
    $this->slurp( $sha, function( $chunk ) use ( &$content ) {
116
      $content .= $chunk;
117
    } );
118
119
    return $content;
120
  }
121
122
  public function readFile( string $hash, string $name ) {
123
    return new File(
124
      $name,
125
      $hash,
126
      '100644',
127
      0,
128
      $this->getObjectSize( $hash ),
129
      $this->peek( $hash )
130
    );
131
  }
132
133
  public function stream( string $sha, callable $callback ): void {
134
    $this->slurp( $sha, $callback );
135
  }
136
137
  private function slurp( string $sha, callable $callback ): void {
138
    $loosePath = $this->getLoosePath( $sha );
139
140
    if( is_file( $loosePath ) ) {
141
      $fileHandle = @fopen( $loosePath, 'rb' );
142
143
      if( !$fileHandle ) return;
144
145
      $inflator    = inflate_init( ZLIB_ENCODING_DEFLATE );
146
      $buffer      = '';
147
      $headerFound = false;
148
149
      while( !feof( $fileHandle ) ) {
150
        $chunk         = fread( $fileHandle, 16384 );
151
        $inflatedChunk = @inflate_add( $inflator, $chunk );
152
153
        if( $inflatedChunk === false ) break;
154
155
        if( !$headerFound ) {
156
          $buffer .= $inflatedChunk;
157
          $nullPos = strpos( $buffer, "\0" );
158
159
          if( $nullPos !== false ) {
160
            $body = substr( $buffer, $nullPos + 1 );
161
162
            if( $body !== '' ) {
163
              $callback( $body );
164
            }
165
166
            $headerFound = true;
167
            $buffer      = '';
168
          }
169
        } else {
170
          $callback( $inflatedChunk );
171
        }
172
      }
173
174
      fclose( $fileHandle );
175
      return;
176
    }
177
178
    if( method_exists( $this->packs, 'stream' ) ) {
179
      $streamed = $this->packs->stream( $sha, $callback );
180
181
      if( $streamed ) {
182
        return;
183
      }
184
    }
185
186
    $data = $this->packs->read( $sha );
187
188
    if( $data !== null && $data !== '' ) {
189
      $callback( $data );
190
    }
191
  }
192
193
  private function peekLooseObject( string $sha, int $length ): string {
194
    $path = $this->getLoosePath( $sha );
195
196
    if( !is_file( $path ) ) {
197
      return '';
198
    }
199
200
    $fileHandle = @fopen( $path, 'rb' );
201
202
    if( !$fileHandle ) {
203
      return '';
204
    }
205
206
    $inflator    = inflate_init( ZLIB_ENCODING_DEFLATE );
207
    $headerFound = false;
208
    $buffer      = '';
209
210
    while( !feof( $fileHandle ) && strlen( $buffer ) < $length ) {
211
      $chunk    = fread( $fileHandle, 128 );
212
      $inflated = @inflate_add( $inflator, $chunk );
213
214
      if( !$headerFound ) {
215
        $raw     = $inflated;
216
        $nullPos = strpos( $raw, "\0" );
217
218
        if( $nullPos !== false ) {
219
          $headerFound = true;
220
          $buffer .= substr( $raw, $nullPos + 1 );
221
        }
222
      } else {
223
        $buffer .= $inflated;
224
      }
225
    }
226
227
    fclose( $fileHandle );
228
229
    return substr( $buffer, 0, $length );
230
  }
231
232
  public function history( string $ref, int $limit, callable $callback ): void {
233
    $currentSha = $this->resolve( $ref );
234
    $count      = 0;
235
236
    while( $currentSha !== '' && $count < $limit ) {
237
      $data = $this->read( $currentSha );
238
239
      if( $data === '' ) {
240
        break;
241
      }
242
243
      $position = strpos( $data, "\n\n" );
244
      $message  = $position !== false ? substr( $data, $position + 2 ) : '';
245
      preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $matches );
246
247
      $callback( (object)[
248
        'sha'     => $currentSha,
249
        'message' => trim( $message ),
250
        'author'  => $matches[1] ?? 'Unknown',
251
        'email'   => $matches[2] ?? '',
252
        'date'    => (int)( $matches[3] ?? 0 )
253
      ] );
254
255
      $currentSha = preg_match(
256
        '/^parent ([0-9a-f]{40})$/m',
257
        $data,
258
        $parentMatches
259
      ) ? $parentMatches[1] : '';
260
261
      $count++;
262
    }
263
  }
264
265
  public function walk( string $refOrSha, callable $callback ): void {
266
    $sha  = $this->resolve( $refOrSha );
267
    $data = $sha !== '' ? $this->read( $sha ) : '';
268
269
    if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) {
270
      $data = $this->read( $matches[1] );
271
    }
272
273
    if( $this->isTreeData( $data ) ) {
274
      $this->processTree( $data, $callback );
275
    }
276
  }
277
278
  private function processTree( string $data, callable $callback ): void {
279
    $position = 0;
280
    $length   = strlen( $data );
281
282
    while( $position < $length ) {
283
      $spacePos = strpos( $data, ' ', $position );
284
      $nullPos  = strpos( $data, "\0", $spacePos );
285
286
      if( $spacePos === false || $nullPos === false ) {
287
        break;
288
      }
289
290
      $mode = substr( $data, $position, $spacePos - $position );
291
      $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
292
      $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );
293
294
      $isDirectory = $mode === '40000' || $mode === '040000';
295
      $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
296
      $contents    = $isDirectory ? '' : $this->peek( $sha );
297
298
      $callback( new File( $name, $sha, $mode, 0, $size, $contents ) );
299
300
      $position = $nullPos + 21;
301
    }
302
  }
303
304
  private function isTreeData( string $data ): bool {
305
    $pattern = '/^(40000|100644|100755|120000|160000) /';
306
307
    if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) {
308
      $nullPos = strpos( $data, "\0" );
309
310
      return $nullPos !== false && ( $nullPos + 21 <= strlen( $data ) );
311
    }
312
313
    return false;
314
  }
315
316
  private function getLoosePath( string $sha ): string {
317
    return "{$this->objectsPath}/" . substr( $sha, 0, 2 ) . "/" .
318
           substr( $sha, 2 );
319
  }
320
321
  private function getLooseObjectSize( string $sha ): int {
322
    $path = $this->getLoosePath( $sha );
323
324
    if( !is_file( $path ) ) {
325
      return 0;
326
    }
327
328
    $fileHandle = @fopen( $path, 'rb' );
329
330
    if( !$fileHandle ) {
331
      return 0;
332
    }
333
334
    $data     = '';
335
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
336
337
    while( !feof( $fileHandle ) ) {
338
      $chunk  = fread( $fileHandle, self::CHUNK_SIZE );
339
      $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH );
340
341
      if( $output === false ) {
342
        break;
343
      }
344
345
      $data .= $output;
346
347
      if( strpos( $data, "\0" ) !== false ) {
348
        break;
349
      }
350
    }
351
352
    fclose( $fileHandle );
353
354
    $header = explode( "\0", $data, 2 )[0];
355
    $parts  = explode( ' ', $header );
356
357
    return isset( $parts[1] ) ? (int)$parts[1] : 0;
358
  }
359
360
  public function streamRaw( string $subPath ): bool {
361
    if( strpos( $subPath, '..' ) !== false ) {
362
      return false;
363
    }
364
365
    $fullPath = "{$this->repoPath}/$subPath";
366
367
    if( !file_exists( $fullPath ) ) {
368
      return false;
369
    }
370
371
    $realPath = realpath( $fullPath );
372
    $repoReal = realpath( $this->repoPath );
373
374
    if( !$realPath || strpos( $realPath, $repoReal ) !== 0 ) {
375
      return false;
376
    }
377
378
    readfile( $fullPath );
379
    return true;
380
  }
381
382
  public function eachRef( callable $callback ): void {
383
    $head = $this->resolve( 'HEAD' );
384
385
    if( $head !== '' ) {
386
      $callback( 'HEAD', $head );
387
    }
388
389
    $this->refs->scanRefs( 'refs/heads', function( $name, $sha ) use ( $callback ) {
390
      $callback( "refs/heads/$name", $sha );
391
    } );
392
393
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) {
394
      $callback( "refs/tags/$name", $sha );
395
    } );
8
  private const MAX_READ_SIZE = 1048576;
9
10
  private string $repoPath;
11
  private string $objectsPath;
12
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->objectsPath = $this->repoPath . '/objects';
23
24
    $this->refs  = new GitRefs( $this->repoPath );
25
    $this->packs = new GitPacks( $this->objectsPath );
26
  }
27
28
  public function resolve( string $reference ): string {
29
    return $this->refs->resolve( $reference );
30
  }
31
32
  public function getMainBranch(): array {
33
    return $this->refs->getMainBranch();
34
  }
35
36
  public function eachBranch( callable $callback ): void {
37
    $this->refs->scanRefs( 'refs/heads', $callback );
38
  }
39
40
  public function eachTag( callable $callback ): void {
41
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
42
      $callback
43
    ) {
44
      $data = $this->read( $sha );
45
      $tag  = $this->parseTagData( $name, $sha, $data );
46
47
      $callback( $tag );
48
    } );
49
  }
50
51
  private function parseTagData(
52
    string $name,
53
    string $sha,
54
    string $data
55
  ): Tag {
56
    $isAnnotated = strncmp( $data, 'object ', 7 ) === 0;
57
58
    $targetSha = $isAnnotated
59
      ? $this->extractPattern(
60
          $data,
61
          '/^object ([0-9a-f]{40})$/m',
62
          1,
63
          $sha
64
        )
65
      : $sha;
66
67
    $pattern = $isAnnotated
68
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
69
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
70
71
    $identity = $this->parseIdentity( $data, $pattern );
72
    $message  = $this->extractMessage( $data );
73
74
    return new Tag(
75
      $name,
76
      $sha,
77
      $targetSha,
78
      $identity['timestamp'],
79
      $message,
80
      $identity['name']
81
    );
82
  }
83
84
  private function extractPattern(
85
    string $data,
86
    string $pattern,
87
    int $group,
88
    string $default = ''
89
  ): string {
90
    $matches = [];
91
92
    $result = preg_match( $pattern, $data, $matches )
93
      ? $matches[$group]
94
      : $default;
95
96
    return $result;
97
  }
98
99
  private function parseIdentity( string $data, string $pattern ): array {
100
    $matches = [];
101
    $found   = preg_match( $pattern, $data, $matches );
102
103
    return [
104
      'name'      => $found ? trim( $matches[1] ) : 'Unknown',
105
      'email'     => $found ? $matches[2] : '',
106
      'timestamp' => $found ? (int)$matches[3] : 0
107
    ];
108
  }
109
110
  private function extractMessage( string $data ): string {
111
    $pos = strpos( $data, "\n\n" );
112
113
    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
114
  }
115
116
  public function getObjectSize( string $sha ): int {
117
    $size = $this->packs->getSize( $sha );
118
119
    return $size !== null ? $size : $this->getLooseObjectSize( $sha );
120
  }
121
122
  public function peek( string $sha, int $length = 255 ): string {
123
    $size = $this->packs->getSize( $sha );
124
125
    return $size === null
126
      ? $this->peekLooseObject( $sha, $length )
127
      : $this->packs->peek( $sha, $length ) ?? '';
128
  }
129
130
  public function read( string $sha ): string {
131
    $size = $this->getObjectSize( $sha );
132
133
    if( $size > self::MAX_READ_SIZE ) {
134
      return '';
135
    }
136
137
    $content = '';
138
139
    $this->slurp( $sha, function( $chunk ) use ( &$content ) {
140
      $content .= $chunk;
141
    } );
142
143
    return $content;
144
  }
145
146
  public function readFile( string $hash, string $name ) {
147
    return new File(
148
      $name,
149
      $hash,
150
      '100644',
151
      0,
152
      $this->getObjectSize( $hash ),
153
      $this->peek( $hash )
154
    );
155
  }
156
157
  public function stream( string $sha, callable $callback ): void {
158
    $this->slurp( $sha, $callback );
159
  }
160
161
  private function slurp( string $sha, callable $callback ): void {
162
    $loosePath = $this->getLoosePath( $sha );
163
164
    if( is_file( $loosePath ) ) {
165
      $this->slurpLooseObject( $loosePath, $callback );
166
    } else {
167
      $this->slurpPackedObject( $sha, $callback );
168
    }
169
  }
170
171
  private function iterateInflated( string $path, callable $processor ): void {
172
    $this->withInflatedFile(
173
      $path,
174
      function( $fileHandle, $inflator ) use ( $processor ) {
175
        $headerFound = false;
176
        $buffer      = '';
177
178
        while( !feof( $fileHandle ) ) {
179
          $chunk    = fread( $fileHandle, 16384 );
180
          $inflated = inflate_add( $inflator, $chunk );
181
182
          if( $inflated === false ) {
183
            break;
184
          }
185
186
          if( !$headerFound ) {
187
            $buffer .= $inflated;
188
            $nullPos = strpos( $buffer, "\0" );
189
190
            if( $nullPos !== false ) {
191
              $headerFound = true;
192
              $header      = substr( $buffer, 0, $nullPos );
193
              $body        = substr( $buffer, $nullPos + 1 );
194
195
              if( $processor( $body, $header ) === false ) {
196
                return;
197
              }
198
            }
199
          } else {
200
            if( $processor( $inflated, null ) === false ) {
201
              return;
202
            }
203
          }
204
        }
205
      }
206
    );
207
  }
208
209
  private function slurpLooseObject(
210
    string $path,
211
    callable $callback
212
  ): void {
213
    $this->iterateInflated(
214
      $path,
215
      function( $chunk ) use ( $callback ) {
216
        if( $chunk !== '' ) {
217
          $callback( $chunk );
218
        }
219
        return true;
220
      }
221
    );
222
  }
223
224
  private function withInflatedFile( string $path, callable $callback ): void {
225
    $fileHandle = fopen( $path, 'rb' );
226
    $inflator   = $fileHandle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
227
228
    if( $fileHandle && $inflator ) {
229
      $callback( $fileHandle, $inflator );
230
      fclose( $fileHandle );
231
    }
232
  }
233
234
  private function slurpPackedObject(
235
    string $sha,
236
    callable $callback
237
  ): void {
238
    $streamed = $this->packs->stream( $sha, $callback );
239
240
    if( !$streamed ) {
241
      $data = $this->packs->read( $sha );
242
243
      if( $data !== null && $data !== '' ) {
244
        $callback( $data );
245
      }
246
    }
247
  }
248
249
  private function peekLooseObject( string $sha, int $length ): string {
250
    $path = $this->getLoosePath( $sha );
251
252
    return is_file( $path )
253
      ? $this->inflateLooseObjectPrefix( $path, $length )
254
      : '';
255
  }
256
257
  private function inflateLooseObjectPrefix(
258
    string $path,
259
    int $length
260
  ): string {
261
    $buffer = '';
262
263
    $this->iterateInflated(
264
      $path,
265
      function( $chunk ) use ( $length, &$buffer ) {
266
        $buffer .= $chunk;
267
        return strlen( $buffer ) < $length;
268
      }
269
    );
270
271
    return substr( $buffer, 0, $length );
272
  }
273
274
  public function history( string $ref, int $limit, callable $callback ): void {
275
    $currentSha = $this->resolve( $ref );
276
    $count      = 0;
277
278
    while( $currentSha !== '' && $count < $limit ) {
279
      $commit = $this->parseCommit( $currentSha );
280
281
      if( $commit === null ) {
282
        break;
283
      }
284
285
      $callback( $commit );
286
      $currentSha = $commit->parentSha;
287
      $count++;
288
    }
289
  }
290
291
  private function parseCommit( string $sha ): ?object {
292
    $data = $this->read( $sha );
293
294
    return $data === '' ? null : $this->buildCommitObject( $sha, $data );
295
  }
296
297
  private function buildCommitObject( string $sha, string $data ): object {
298
    $identity  = $this->parseIdentity( $data, '/^author (.*) <(.*)> (\d+)/m' );
299
    $message   = $this->extractMessage( $data );
300
    $parentSha = $this->extractPattern(
301
      $data,
302
      '/^parent ([0-9a-f]{40})$/m',
303
      1
304
    );
305
306
    return (object)[
307
      'sha'       => $sha,
308
      'message'   => $message,
309
      'author'    => $identity['name'],
310
      'email'     => $identity['email'],
311
      'date'      => $identity['timestamp'],
312
      'parentSha' => $parentSha
313
    ];
314
  }
315
316
  public function walk( string $refOrSha, callable $callback ): void {
317
    $sha = $this->resolve( $refOrSha );
318
319
    if( $sha !== '' ) {
320
      $this->walkTree( $sha, $callback );
321
    }
322
  }
323
324
  private function walkTree( string $sha, callable $callback ): void {
325
    $data     = $this->read( $sha );
326
    $treeData = $data !== '' && preg_match(
327
      '/^tree ([0-9a-f]{40})$/m',
328
      $data,
329
      $matches
330
    ) ? $this->read( $matches[1] ) : $data;
331
332
    if( $treeData !== '' && $this->isTreeData( $treeData ) ) {
333
      $this->processTree( $treeData, $callback );
334
    }
335
  }
336
337
  private function processTree( string $data, callable $callback ): void {
338
    $position = 0;
339
    $length   = strlen( $data );
340
341
    while( $position < $length ) {
342
      $result = $this->parseTreeEntry( $data, $position, $length );
343
344
      if( $result === null ) {
345
        break;
346
      }
347
348
      $callback( $result['file'] );
349
      $position = $result['nextPosition'];
350
    }
351
  }
352
353
  private function parseTreeEntry(
354
    string $data,
355
    int $position,
356
    int $length
357
  ): ?array {
358
    $spacePos = strpos( $data, ' ', $position );
359
    $nullPos  = strpos( $data, "\0", $spacePos );
360
361
    $hasValidPositions =
362
      $spacePos !== false &&
363
      $nullPos !== false &&
364
      $nullPos + 21 <= $length;
365
366
    return $hasValidPositions
367
      ? $this->buildTreeEntryResult( $data, $position, $spacePos, $nullPos )
368
      : null;
369
  }
370
371
  private function buildTreeEntryResult(
372
    string $data,
373
    int $position,
374
    int $spacePos,
375
    int $nullPos
376
  ): array {
377
    $mode = substr( $data, $position, $spacePos - $position );
378
    $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
379
    $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );
380
381
    $isDirectory = $mode === '40000' || $mode === '040000';
382
    $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
383
    $contents    = $isDirectory ? '' : $this->peek( $sha );
384
385
    $file = new File( $name, $sha, $mode, 0, $size, $contents );
386
387
    return [
388
      'file'         => $file,
389
      'nextPosition' => $nullPos + 21
390
    ];
391
  }
392
393
  private function isTreeData( string $data ): bool {
394
    $pattern        = '/^(40000|100644|100755|120000|160000) /';
395
    $minLength      = strlen( $data ) >= 25;
396
    $matchesPattern = $minLength && preg_match( $pattern, $data );
397
    $nullPos        = $matchesPattern ? strpos( $data, "\0" ) : false;
398
399
    return $matchesPattern &&
400
      $nullPos !== false &&
401
      $nullPos + 21 <= strlen( $data );
402
  }
403
404
  private function getLoosePath( string $sha ): string {
405
    return "{$this->objectsPath}/" .
406
      substr( $sha, 0, 2 ) . "/" .
407
      substr( $sha, 2 );
408
  }
409
410
  private function getLooseObjectSize( string $sha ): int {
411
    $path = $this->getLoosePath( $sha );
412
413
    return is_file( $path ) ? $this->readLooseObjectHeader( $path ) : 0;
414
  }
415
416
  private function readLooseObjectHeader( string $path ): int {
417
    $size = 0;
418
419
    $this->iterateInflated(
420
      $path,
421
      function( $chunk, $header ) use ( &$size ) {
422
        if( $header !== null ) {
423
          $parts = explode( ' ', $header );
424
          $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
425
        }
426
        return false;
427
      }
428
    );
429
430
    return $size;
431
  }
432
433
  public function streamRaw( string $subPath ): bool {
434
    return strpos( $subPath, '..' ) === false
435
      ? $this->streamRawFile( $subPath )
436
      : false;
437
  }
438
439
  private function streamRawFile( string $subPath ): bool {
440
    $fullPath = "{$this->repoPath}/$subPath";
441
442
    return file_exists( $fullPath )
443
      ? $this->streamIfPathValid( $fullPath )
444
      : false;
445
  }
446
447
  private function streamIfPathValid( string $fullPath ): bool {
448
    $realPath = realpath( $fullPath );
449
    $repoReal = realpath( $this->repoPath );
450
    $isValid  = $realPath && strpos( $realPath, $repoReal ) === 0;
451
452
    return $isValid ? readfile( $fullPath ) !== false : false;
453
  }
454
455
  public function eachRef( callable $callback ): void {
456
    $head = $this->resolve( 'HEAD' );
457
458
    if( $head !== '' ) {
459
      $callback( 'HEAD', $head );
460
    }
461
462
    $this->refs->scanRefs(
463
      'refs/heads',
464
      function( $name, $sha ) use ( $callback ) {
465
        $callback( "refs/heads/$name", $sha );
466
      }
467
    );
468
469
    $this->refs->scanRefs(
470
      'refs/tags',
471
      function( $name, $sha ) use ( $callback ) {
472
        $callback( "refs/tags/$name", $sha );
473
      }
474
    );
396475
  }
397476
}
M git/GitDiff.php
66
  private const MAX_DIFF_SIZE = 1048576;
77
8
  public function __construct(Git $git) {
8
  public function __construct( Git $git ) {
99
    $this->git = $git;
1010
  }
1111
12
  public function compare(string $commitHash) {
13
    $commitData = $this->git->read($commitHash);
14
    $parentHash = preg_match('/^parent ([0-9a-f]{40})/m', $commitData, $matches) ? $matches[1] : '';
12
  public function compare( string $commitHash ) {
13
    $commitData = $this->git->read( $commitHash );
1514
16
    $newTree = $this->getTreeHash($commitHash);
17
    $oldTree = $parentHash ? $this->getTreeHash($parentHash) : null;
15
    $parentHash = preg_match(
16
      '/^parent ([0-9a-f]{40})/m',
17
      $commitData,
18
      $matches
19
    ) ? $matches[1] : '';
1820
19
    return $this->diffTrees($oldTree, $newTree);
21
    $newTree = $this->getTreeHash( $commitHash );
22
    $oldTree = $parentHash ? $this->getTreeHash( $parentHash ) : null;
23
24
    return $this->diffTrees( $oldTree, $newTree );
2025
  }
2126
22
  private function getTreeHash($commitSha) {
23
    $data = $this->git->read($commitSha);
24
    return preg_match('/^tree ([0-9a-f]{40})/m', $data, $matches) ? $matches[1] : null;
27
  private function getTreeHash( $commitSha ) {
28
    $data = $this->git->read( $commitSha );
29
30
    return preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches )
31
      ? $matches[1]
32
      : null;
2533
  }
2634
27
  private function diffTrees($oldTreeSha, $newTreeSha, $path = '') {
35
  private function diffTrees( $oldTreeSha, $newTreeSha, $path = '' ) {
2836
    $changes = [];
2937
30
    if ($oldTreeSha !== $newTreeSha) {
31
      $oldEntries = $oldTreeSha ? $this->parseTree($oldTreeSha) : [];
32
      $newEntries = $newTreeSha ? $this->parseTree($newTreeSha) : [];
38
    if( $oldTreeSha !== $newTreeSha ) {
39
      $oldEntries = $oldTreeSha ? $this->parseTree( $oldTreeSha ) : [];
40
      $newEntries = $newTreeSha ? $this->parseTree( $newTreeSha ) : [];
3341
34
      $allNames = array_unique(array_merge(array_keys($oldEntries), array_keys($newEntries)));
35
      sort($allNames);
42
      $allNames = array_unique(
43
        array_merge( array_keys( $oldEntries ), array_keys( $newEntries ) )
44
      );
3645
37
      foreach ($allNames as $name) {
46
      sort( $allNames );
47
48
      foreach( $allNames as $name ) {
3849
        $old         = $oldEntries[$name] ?? null;
3950
        $new         = $newEntries[$name] ?? null;
4051
        $currentPath = $path ? "$path/$name" : $name;
4152
42
        if (!$old) {
53
        if( !$old ) {
4354
          $changes = $new['is_dir']
44
            ? array_merge($changes, $this->diffTrees(null, $new['sha'], $currentPath))
45
            : array_merge($changes, [$this->createChange('A', $currentPath, null, $new['sha'])]);
46
        } elseif (!$new) {
55
            ? array_merge(
56
                $changes,
57
                $this->diffTrees( null, $new['sha'], $currentPath )
58
              )
59
            : array_merge(
60
                $changes,
61
                [$this->createChange( 'A', $currentPath, null, $new['sha'] )]
62
              );
63
        } elseif( !$new ) {
4764
          $changes = $old['is_dir']
48
            ? array_merge($changes, $this->diffTrees($old['sha'], null, $currentPath))
49
            : array_merge($changes, [$this->createChange('D', $currentPath, $old['sha'], null)]);
50
        } elseif ($old['sha'] !== $new['sha']) {
65
            ? array_merge(
66
                $changes,
67
                $this->diffTrees( $old['sha'], null, $currentPath )
68
              )
69
            : array_merge(
70
                $changes,
71
                [$this->createChange( 'D', $currentPath, $old['sha'], null )]
72
              );
73
        } elseif( $old['sha'] !== $new['sha'] ) {
5174
          $changes = ($old['is_dir'] && $new['is_dir'])
52
            ? array_merge($changes, $this->diffTrees($old['sha'], $new['sha'], $currentPath))
75
            ? array_merge(
76
                $changes,
77
                $this->diffTrees( $old['sha'], $new['sha'], $currentPath )
78
              )
5379
            : (($old['is_dir'] || $new['is_dir'])
5480
              ? $changes
55
              : array_merge($changes, [$this->createChange('M', $currentPath, $old['sha'], $new['sha'])]));
81
              : array_merge(
82
                  $changes,
83
                  [$this->createChange(
84
                    'M',
85
                    $currentPath,
86
                    $old['sha'],
87
                    $new['sha']
88
                  )]
89
                ));
5690
        }
5791
      }
5892
    }
5993
6094
    return $changes;
6195
  }
6296
63
  private function parseTree($sha) {
64
    $data    = $this->git->read($sha);
97
  private function parseTree( $sha ) {
98
    $data    = $this->git->read( $sha );
6599
    $entries = [];
66
    $len     = strlen($data);
100
    $len     = strlen( $data );
67101
    $pos     = 0;
68102
69
    while ($pos < $len) {
70
      $space = strpos($data, ' ', $pos);
71
      $null  = strpos($data, "\0", $space);
103
    while( $pos < $len ) {
104
      $space = strpos( $data, ' ', $pos );
105
      $null  = strpos( $data, "\0", $space );
72106
73
      if ($space === false || $null === false) break;
107
      if( $space === false || $null === false ) {
108
        break;
109
      }
74110
75
      $mode = substr($data, $pos, $space - $pos);
76
      $name = substr($data, $space + 1, $null - $space - 1);
77
      $hash = bin2hex(substr($data, $null + 1, 20));
111
      $mode = substr( $data, $pos, $space - $pos );
112
      $name = substr( $data, $space + 1, $null - $space - 1 );
113
      $hash = bin2hex( substr( $data, $null + 1, 20 ) );
78114
79115
      $entries[$name] = [
80116
        'mode'   => $mode,
81117
        'sha'    => $hash,
82118
        'is_dir' => $mode === '40000' || $mode === '040000'
83119
      ];
84120
85121
      $pos = $null + 21;
86122
    }
123
87124
    return $entries;
88125
  }
89126
90
  private function createChange($type, $path, $oldSha, $newSha) {
91
    $oldSize = $oldSha ? $this->git->getObjectSize($oldSha) : 0;
92
    $newSize = $newSha ? $this->git->getObjectSize($newSha) : 0;
127
  private function createChange( $type, $path, $oldSha, $newSha ) {
128
    $oldSize = $oldSha ? $this->git->getObjectSize( $oldSha ) : 0;
129
    $newSize = $newSha ? $this->git->getObjectSize( $newSha ) : 0;
93130
    $result  = [];
94131
95
    if ($oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE) {
132
    if( $oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE ) {
96133
      $result = [
97134
        'type'      => $type,
98135
        'path'      => $path,
99136
        'is_binary' => true,
100137
        'hunks'     => []
101138
      ];
102139
    } else {
103
      $oldContent = $oldSha ? $this->git->read($oldSha) : '';
104
      $newContent = $newSha ? $this->git->read($newSha) : '';
140
      $oldContent = $oldSha ? $this->git->read( $oldSha ) : '';
141
      $newContent = $newSha ? $this->git->read( $newSha ) : '';
105142
106
      $isBinary = ($newSha && (new VirtualDiffFile($path, $newContent))->isBinary()) ||
107
                  (!$newSha && $oldSha && (new VirtualDiffFile($path, $oldContent))->isBinary());
143
      $isBinary =
144
        ($newSha && (new VirtualDiffFile( $path, $newContent ))->isBinary()) ||
145
        (!$newSha && $oldSha &&
146
          (new VirtualDiffFile( $path, $oldContent ))->isBinary());
108147
109148
      $result = [
110149
        'type'      => $type,
111150
        'path'      => $path,
112151
        'is_binary' => $isBinary,
113
        'hunks'     => $isBinary ? null : $this->calculateDiff($oldContent, $newContent)
152
        'hunks'     => $isBinary
153
          ? null
154
          : $this->calculateDiff( $oldContent, $newContent )
114155
      ];
115156
    }
116157
117158
    return $result;
118159
  }
119160
120
  private function calculateDiff($old, $new) {
121
    $old = str_replace("\r\n", "\n", $old);
122
    $new = str_replace("\r\n", "\n", $new);
161
  private function calculateDiff( $old, $new ) {
162
    $old = str_replace( "\r\n", "\n", $old );
163
    $new = str_replace( "\r\n", "\n", $new );
123164
124
    $oldLines = explode("\n", $old);
125
    $newLines = explode("\n", $new);
165
    $oldLines = explode( "\n", $old );
166
    $newLines = explode( "\n", $new );
126167
127
    $m = count($oldLines);
128
    $n = count($newLines);
168
    $m = count( $oldLines );
169
    $n = count( $newLines );
129170
130171
    $start = 0;
131
    while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) {
172
173
    while(
174
      $start < $m &&
175
      $start < $n &&
176
      $oldLines[$start] === $newLines[$start]
177
    ) {
132178
      $start++;
133179
    }
134180
135181
    $end = 0;
136
    while ($m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]) {
182
183
    while(
184
      $m - $end > $start &&
185
      $n - $end > $start &&
186
      $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]
187
    ) {
137188
      $end++;
138189
    }
139190
140
    $oldSlice = array_slice($oldLines, $start, $m - $start - $end);
141
    $newSlice = array_slice($newLines, $start, $n - $start - $end);
191
    $oldSlice = array_slice( $oldLines, $start, $m - $start - $end );
192
    $newSlice = array_slice( $newLines, $start, $n - $start - $end );
142193
143194
    $result = null;
144195
145
    if ((count($oldSlice) * count($newSlice)) > 500000) {
196
    if( (count( $oldSlice ) * count( $newSlice )) > 500000 ) {
146197
      $result = [['t' => 'gap']];
147198
    } else {
148
      $ops = $this->computeLCS($oldSlice, $newSlice);
199
      $ops = $this->computeLCS( $oldSlice, $newSlice );
149200
150201
      $groupedOps = [];
151202
      $bufferDel  = [];
152203
      $bufferAdd  = [];
153204
154
      foreach ($ops as $op) {
155
        if ($op['t'] === ' ') {
156
          foreach ($bufferDel as $o) $groupedOps[] = $o;
157
          foreach ($bufferAdd as $o) $groupedOps[] = $o;
205
      foreach( $ops as $op ) {
206
        if( $op['t'] === ' ' ) {
207
          foreach( $bufferDel as $o ) { $groupedOps[] = $o; }
208
          foreach( $bufferAdd as $o ) { $groupedOps[] = $o; }
209
158210
          $bufferDel    = [];
159211
          $bufferAdd    = [];
160212
          $groupedOps[] = $op;
161
        } elseif ($op['t'] === '-') {
213
        } elseif( $op['t'] === '-' ) {
162214
          $bufferDel[] = $op;
163
        } elseif ($op['t'] === '+') {
215
        } elseif( $op['t'] === '+' ) {
164216
          $bufferAdd[] = $op;
165217
        }
166218
      }
167
      foreach ($bufferDel as $o) $groupedOps[] = $o;
168
      foreach ($bufferAdd as $o) $groupedOps[] = $o;
169
      $ops = $groupedOps;
219
220
      foreach( $bufferDel as $o ) { $groupedOps[] = $o; }
221
      foreach( $bufferAdd as $o ) { $groupedOps[] = $o; }
170222
223
      $ops    = $groupedOps;
171224
      $stream = [];
172225
173
      for ($i = 0; $i < $start; $i++) {
174
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1];
226
      for( $i = 0; $i < $start; $i++ ) {
227
        $stream[] = [
228
          't'  => ' ',
229
          'l'  => $oldLines[$i],
230
          'no' => $i + 1,
231
          'nn' => $i + 1
232
        ];
175233
      }
176234
177235
      $currO = $start + 1;
178236
      $currN = $start + 1;
179237
180
      foreach ($ops as $op) {
181
        if ($op['t'] === ' ') {
182
          $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++];
183
        } elseif ($op['t'] === '-') {
184
          $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null];
185
        } elseif ($op['t'] === '+') {
186
          $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++];
238
      foreach( $ops as $op ) {
239
        if( $op['t'] === ' ' ) {
240
          $stream[] = [
241
            't'  => ' ',
242
            'l'  => $op['l'],
243
            'no' => $currO++,
244
            'nn' => $currN++
245
          ];
246
        } elseif( $op['t'] === '-' ) {
247
          $stream[] = [
248
            't'  => '-',
249
            'l'  => $op['l'],
250
            'no' => $currO++,
251
            'nn' => null
252
          ];
253
        } elseif( $op['t'] === '+' ) {
254
          $stream[] = [
255
            't'  => '+',
256
            'l'  => $op['l'],
257
            'no' => null,
258
            'nn' => $currN++
259
          ];
187260
        }
188261
      }
189262
190
      for ($i = $m - $end; $i < $m; $i++) {
191
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++];
263
      for( $i = $m - $end; $i < $m; $i++ ) {
264
        $stream[] = [
265
          't'  => ' ',
266
          'l'  => $oldLines[$i],
267
          'no' => $currO++,
268
          'nn' => $currN++
269
        ];
192270
      }
193271
194272
      $finalLines       = [];
195273
      $lastVisibleIndex = -1;
196
      $streamLen        = count($stream);
274
      $streamLen        = count( $stream );
197275
      $contextLines     = 3;
198276
199
      for ($i = 0; $i < $streamLen; $i++) {
277
      for( $i = 0; $i < $streamLen; $i++ ) {
200278
        $show = false;
201279
202
        if ($stream[$i]['t'] !== ' ') {
280
        if( $stream[$i]['t'] !== ' ' ) {
203281
          $show = true;
204282
        } else {
205
          for ($j = 1; $j <= $contextLines; $j++) {
206
            if (($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ') {
283
          for( $j = 1; $j <= $contextLines; $j++ ) {
284
            if( ($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ' ) {
207285
              $show = true;
208286
              break;
209287
            }
210288
          }
211
          if (!$show) {
212
            for ($j = 1; $j <= $contextLines; $j++) {
213
              if (($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ') {
289
290
          if( !$show ) {
291
            for( $j = 1; $j <= $contextLines; $j++ ) {
292
              if( ($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ' ) {
214293
                $show = true;
215294
                break;
216295
              }
217296
            }
218297
          }
219298
        }
220299
221
        if ($show) {
222
          if ($lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1) {
300
        if( $show ) {
301
          if( $lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1 ) {
223302
            $finalLines[] = ['t' => 'gap'];
224303
          }
304
225305
          $finalLines[]     = $stream[$i];
226306
          $lastVisibleIndex = $i;
227307
        }
228308
      }
309
229310
      $result = $finalLines;
230311
    }
231312
232313
    return $result;
233314
  }
234315
235
  private function computeLCS($old, $new) {
236
    $m = count($old);
237
    $n = count($new);
238
    $c = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
316
  private function computeLCS( $old, $new ) {
317
    $m = count( $old );
318
    $n = count( $new );
319
    $c = array_fill( 0, $m + 1, array_fill( 0, $n + 1, 0 ) );
239320
240
    for ($i = 1; $i <= $m; $i++) {
241
      for ($j = 1; $j <= $n; $j++) {
321
    for( $i = 1; $i <= $m; $i++ ) {
322
      for( $j = 1; $j <= $n; $j++ ) {
242323
        $c[$i][$j] = ($old[$i - 1] === $new[$j - 1])
243324
          ? $c[$i - 1][$j - 1] + 1
244
          : max($c[$i][$j - 1], $c[$i - 1][$j]);
325
          : max( $c[$i][$j - 1], $c[$i - 1][$j] );
245326
      }
246327
    }
247328
248329
    $diff = [];
249330
    $i    = $m;
250331
    $j    = $n;
251332
252
    while ($i > 0 || $j > 0) {
253
      if ($i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1]) {
254
        array_unshift($diff, ['t' => ' ', 'l' => $old[$i - 1]]);
333
    while( $i > 0 || $j > 0 ) {
334
      if( $i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1] ) {
335
        array_unshift( $diff, ['t' => ' ', 'l' => $old[$i - 1]] );
255336
        $i--;
256337
        $j--;
257
      } elseif ($j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j])) {
258
        array_unshift($diff, ['t' => '+', 'l' => $new[$j - 1]]);
338
      } elseif( $j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j]) ) {
339
        array_unshift( $diff, ['t' => '+', 'l' => $new[$j - 1]] );
259340
        $j--;
260
      } elseif ($i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j])) {
261
        array_unshift($diff, ['t' => '-', 'l' => $old[$i - 1]]);
341
      } elseif( $i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j]) ) {
342
        array_unshift( $diff, ['t' => '-', 'l' => $old[$i - 1]] );
262343
        $i--;
263344
      }
264345
    }
346
265347
    return $diff;
266348
  }
267349
}
268350
269351
class VirtualDiffFile extends File {
270
  public function __construct(string $name, string $content) {
271
    parent::__construct($name, '', '100644', 0, strlen($content), $content);
352
  public function __construct( string $name, string $content ) {
353
    parent::__construct(
354
      $name,
355
      '',
356
      '100644',
357
      0,
358
      strlen( $content ),
359
      $content
360
    );
272361
  }
273362
}
M git/GitPacks.php
7676
    }
7777
78
    return $this->streamPackEntry( $handle, $info['offset'], $size, $callback );
79
  }
80
81
  public function getSize( string $sha ): ?int {
82
    $info = $this->findPackInfo( $sha );
83
84
    if( $info['offset'] === -1 ) {
85
      return null;
86
    }
87
88
    return $this->extractPackedSize( $info['file'], $info['offset'] );
89
  }
90
91
  private function findPackInfo( string $sha ): array {
92
    if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) {
93
      return ['offset' => -1];
94
    }
95
96
    $binarySha = hex2bin( $sha );
97
98
    if( $this->lastPack ) {
99
      $offset = $this->findInIdx( $this->lastPack, $binarySha );
100
101
      if( $offset !== -1 ) {
102
        return $this->makeResult( $this->lastPack, $offset );
103
      }
104
    }
105
106
    foreach( $this->packFiles as $indexFile ) {
107
      if( $indexFile === $this->lastPack ) {
108
        continue;
109
      }
110
111
      $offset = $this->findInIdx( $indexFile, $binarySha );
112
113
      if( $offset !== -1 ) {
114
        $this->lastPack = $indexFile;
115
116
        return $this->makeResult( $indexFile, $offset );
117
      }
118
    }
119
120
    return ['offset' => -1];
121
  }
122
123
  private function makeResult( string $indexPath, int $offset ): array {
124
    return [
125
      'file'   => str_replace( '.idx', '.pack', $indexPath ),
126
      'offset' => $offset
127
    ];
128
  }
129
130
  private function findInIdx( string $indexFile, string $binarySha ): int {
131
    $fileHandle = $this->getHandle( $indexFile );
132
133
    if( !$fileHandle ) {
134
      return -1;
135
    }
136
137
    if( !isset( $this->fanoutCache[$indexFile] ) ) {
138
      fseek( $fileHandle, 0 );
139
140
      if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) {
141
        $this->fanoutCache[$indexFile] = array_values(
142
          unpack( 'N*', fread( $fileHandle, 1024 ) )
143
        );
144
      } else {
145
        return -1;
146
      }
147
    }
148
149
    $fanout = $this->fanoutCache[$indexFile];
150
151
    $firstByte = ord( $binarySha[0] );
152
    $start     = $firstByte === 0 ? 0 : $fanout[$firstByte - 1];
153
    $end       = $fanout[$firstByte];
154
155
    if( $end <= $start ) {
156
      return -1;
157
    }
158
159
    $cacheKey = "$indexFile:$firstByte";
160
161
    if( !isset( $this->shaBucketCache[$cacheKey] ) ) {
162
      $count = $end - $start;
163
      fseek( $fileHandle, 1032 + ($start * 20) );
164
      $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 );
165
166
      fseek(
167
        $fileHandle,
168
        1032 + ($fanout[255] * 24) + ($start * 4)
169
      );
170
      $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 );
171
    }
172
173
    $shaBlock = $this->shaBucketCache[$cacheKey];
174
    $count    = strlen( $shaBlock ) / 20;
175
    $low      = 0;
176
    $high     = $count - 1;
177
    $foundIdx = -1;
178
179
    while( $low <= $high ) {
180
      $mid     = ($low + $high) >> 1;
181
      $compare = substr( $shaBlock, $mid * 20, 20 );
182
183
      if( $compare < $binarySha ) {
184
        $low = $mid + 1;
185
      } elseif( $compare > $binarySha ) {
186
        $high = $mid - 1;
187
      } else {
188
        $foundIdx = $mid;
189
        break;
190
      }
191
    }
192
193
    if( $foundIdx === -1 ) {
194
      return -1;
195
    }
196
197
    $offsetData = substr(
198
      $this->offsetBucketCache[$cacheKey],
199
      $foundIdx * 4,
200
      4
201
    );
202
    $offset = unpack( 'N', $offsetData )[1];
203
204
    if( $offset & 0x80000000 ) {
205
      $packTotal = $fanout[255];
206
      $pos64     = 1032 + ($packTotal * 28) +
207
                   (($offset & 0x7FFFFFFF) * 8);
208
      fseek( $fileHandle, $pos64 );
209
      $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1];
210
    }
211
212
    return (int)$offset;
213
  }
214
215
  private function readPackEntry( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string {
216
    fseek( $fileHandle, $offset );
217
218
    $header = $this->readVarInt( $fileHandle );
219
    $type   = ($header['byte'] >> 4) & 7;
220
221
    if( $type === 6 ) {
222
      return $this->handleOfsDelta( $fileHandle, $offset, $expectedSize, $cap );
223
    }
224
225
    if( $type === 7 ) {
226
      return $this->handleRefDelta( $fileHandle, $expectedSize, $cap );
227
    }
228
229
    return $this->decompressToString( $fileHandle, $expectedSize, $cap );
230
  }
231
232
  private function streamPackEntry( $fileHandle, int $offset, int $expectedSize, callable $callback ): bool {
233
    fseek( $fileHandle, $offset );
234
235
    $header = $this->readVarInt( $fileHandle );
236
    $type   = ($header['byte'] >> 4) & 7;
237
238
    if( $type === 6 || $type === 7 ) {
239
      return $this->streamDeltaObject( $fileHandle, $offset, $type, $expectedSize, $callback );
240
    }
241
242
    return $this->streamDecompression( $fileHandle, $callback );
243
  }
244
245
  private function streamDeltaObject( $fileHandle, int $offset, int $type, int $expectedSize, callable $callback ): bool {
246
    fseek( $fileHandle, $offset );
247
    $this->readVarInt( $fileHandle );
248
249
    if( $type === 6 ) {
250
      $byte     = ord( fread( $fileHandle, 1 ) );
251
      $negative = $byte & 127;
252
253
      while( $byte & 128 ) {
254
        $byte     = ord( fread( $fileHandle, 1 ) );
255
        $negative = (($negative + 1) << 7) | ($byte & 127);
256
      }
257
258
      $deltaPos   = ftell( $fileHandle );
259
      $baseOffset = $offset - $negative;
260
261
      $base = '';
262
      $this->streamPackEntry( $fileHandle, $baseOffset, 0, function( $chunk ) use ( &$base ) {
263
        $base .= $chunk;
264
      } );
265
266
      fseek( $fileHandle, $deltaPos );
267
    } else {
268
      $baseSha = bin2hex( fread( $fileHandle, 20 ) );
269
270
      $base = '';
271
      $streamed = $this->stream( $baseSha, function( $chunk ) use ( &$base ) {
272
        $base .= $chunk;
273
      } );
274
275
      if( !$streamed ) {
276
        return false;
277
      }
278
    }
279
280
    $compressed = fread( $fileHandle, self::MAX_READ );
281
    $delta      = @gzuncompress( $compressed ) ?: '';
282
283
    $result = $this->applyDelta( $base, $delta );
284
285
    $chunkSize = 8192;
286
    $length    = strlen( $result );
287
    for( $i = 0; $i < $length; $i += $chunkSize ) {
288
      $callback( substr( $result, $i, $chunkSize ) );
289
    }
290
291
    return true;
292
  }
293
294
  private function streamDecompression( $fileHandle, callable $callback ): bool {
295
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
296
    if( $inflator === false ) {
297
      return false;
298
    }
299
300
    while( !feof( $fileHandle ) ) {
301
      $chunk = fread( $fileHandle, 8192 );
302
303
      if( $chunk === false || $chunk === '' ) {
304
        break;
305
      }
306
307
      $data = @inflate_add( $inflator, $chunk );
308
309
      if( $data !== false && $data !== '' ) {
310
        $callback( $data );
311
      }
312
313
      if( $data === false ||
314
          inflate_get_status( $inflator ) === ZLIB_STREAM_END ) {
315
        break;
316
      }
317
    }
318
319
    return true;
320
  }
321
322
  private function decompressToString( $fileHandle, int $maxSize, int $cap = 0 ): string {
323
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
324
    if( $inflator === false ) {
325
      return '';
326
    }
327
328
    $result = '';
329
330
    while( !feof( $fileHandle ) ) {
331
      $chunk = fread( $fileHandle, 8192 );
332
333
      if( $chunk === false || $chunk === '' ) {
334
        break;
335
      }
336
337
      $data = @inflate_add( $inflator, $chunk );
338
339
      if( $data !== false ) {
340
        $result .= $data;
341
      }
342
343
      if( $cap > 0 && strlen( $result ) >= $cap ) {
344
        return substr( $result, 0, $cap );
345
      }
346
347
      if( $data === false ||
348
          inflate_get_status( $inflator ) === ZLIB_STREAM_END ) {
349
        break;
350
      }
351
    }
352
353
    return $result;
354
  }
355
356
  private function extractPackedSize( string $packPath, int $offset ): int {
357
    $fileHandle = $this->getHandle( $packPath );
358
359
    if( !$fileHandle ) {
360
      return 0;
361
    }
362
363
    fseek( $fileHandle, $offset );
364
365
    $header = $this->readVarInt( $fileHandle );
366
    $size   = $header['value'];
367
    $type   = ($header['byte'] >> 4) & 7;
368
369
    if( $type === 6 || $type === 7 ) {
370
      return $this->readDeltaTargetSize( $fileHandle, $type );
371
    }
372
373
    return $size;
374
  }
375
376
  private function handleOfsDelta( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string {
377
    $byte     = ord( fread( $fileHandle, 1 ) );
378
    $negative = $byte & 127;
379
380
    while( $byte & 128 ) {
381
      $byte     = ord( fread( $fileHandle, 1 ) );
382
      $negative = (($negative + 1) << 7) | ($byte & 127);
383
    }
384
385
    $currentPos = ftell( $fileHandle );
386
    $baseOffset = $offset - $negative;
387
388
    fseek( $fileHandle, $baseOffset );
389
    $baseHeader = $this->readVarInt( $fileHandle );
390
    $baseSize   = $baseHeader['value'];
391
392
    fseek( $fileHandle, $baseOffset );
393
    $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize, $cap );
394
395
    fseek( $fileHandle, $currentPos );
396
397
    $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) );
398
    $compressed     = fread( $fileHandle, $remainingBytes );
399
    $delta          = @gzuncompress( $compressed ) ?: '';
400
401
    return $this->applyDelta( $base, $delta, $cap );
402
  }
403
404
  private function handleRefDelta( $fileHandle, int $expectedSize, int $cap = 0 ): string {
405
    $baseSha = bin2hex( fread( $fileHandle, 20 ) );
406
407
    if ( $cap > 0 ) {
408
      $base = $this->peek( $baseSha, $cap ) ?? '';
409
    } else {
410
      $base = $this->read( $baseSha ) ?? '';
411
    }
412
413
    $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) );
414
    $compressed     = fread( $fileHandle, $remainingBytes );
415
    $delta          = @gzuncompress( $compressed ) ?: '';
416
417
    return $this->applyDelta( $base, $delta, $cap );
418
  }
419
420
  private function applyDelta( string $base, string $delta, int $cap = 0 ): string {
421
    $position = 0;
422
    $this->skipSize( $delta, $position );
423
    $this->skipSize( $delta, $position );
424
425
    $output      = '';
426
    $deltaLength = strlen( $delta );
427
428
    while( $position < $deltaLength ) {
429
      if( $cap > 0 && strlen( $output ) >= $cap ) {
430
        break;
431
      }
432
433
      $opcode = ord( $delta[$position++] );
434
435
      if( $opcode & 128 ) {
436
        $offset = 0;
437
        $length = 0;
438
439
        if( $opcode & 0x01 ) {
440
          $offset |= ord( $delta[$position++] );
441
        }
442
        if( $opcode & 0x02 ) {
443
          $offset |= ord( $delta[$position++] ) << 8;
444
        }
445
        if( $opcode & 0x04 ) {
446
          $offset |= ord( $delta[$position++] ) << 16;
447
        }
448
        if( $opcode & 0x08 ) {
449
          $offset |= ord( $delta[$position++] ) << 24;
450
        }
451
452
        if( $opcode & 0x10 ) {
453
          $length |= ord( $delta[$position++] );
454
        }
455
        if( $opcode & 0x20 ) {
456
          $length |= ord( $delta[$position++] ) << 8;
457
        }
458
        if( $opcode & 0x40 ) {
459
          $length |= ord( $delta[$position++] ) << 16;
460
        }
461
462
        if( $length === 0 ) {
463
          $length = 0x10000;
464
        }
465
466
        $output .= substr( $base, $offset, $length );
467
      } else {
468
        $length = $opcode & 127;
469
        $output .= substr( $delta, $position, $length );
470
        $position += $length;
471
      }
472
    }
473
474
    return $output;
475
  }
476
477
  private function readVarInt( $fileHandle ): array {
478
    $byte  = ord( fread( $fileHandle, 1 ) );
479
    $value = $byte & 15;
480
    $shift = 4;
481
    $first = $byte;
482
483
    while( $byte & 128 ) {
484
      $byte  = ord( fread( $fileHandle, 1 ) );
485
      $value |= (($byte & 127) << $shift);
486
      $shift += 7;
487
    }
488
489
    return ['value' => $value, 'byte' => $first];
490
  }
491
492
  private function readDeltaTargetSize( $fileHandle, int $type ): int {
493
    if( $type === 6 ) {
494
      $byte = ord( fread( $fileHandle, 1 ) );
495
496
      while( $byte & 128 ) {
497
        $byte = ord( fread( $fileHandle, 1 ) );
498
      }
499
    } else {
500
      fseek( $fileHandle, 20, SEEK_CUR );
501
    }
502
503
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
504
    if( $inflator === false ) {
505
      return 0;
506
    }
507
508
    $header      = '';
509
    $attempts    = 0;
510
    $maxAttempts = 64;
511
512
    while( !feof( $fileHandle ) && strlen( $header ) < 32 && $attempts < $maxAttempts ) {
78
    return $this->streamPackEntry(
79
      $handle,
80
      $info['offset'],
81
      $size,
82
      $callback
83
    );
84
  }
85
86
  public function getSize( string $sha ): ?int {
87
    $info = $this->findPackInfo( $sha );
88
89
    if( $info['offset'] === -1 ) {
90
      return null;
91
    }
92
93
    return $this->extractPackedSize( $info['file'], $info['offset'] );
94
  }
95
96
  private function findPackInfo( string $sha ): array {
97
    if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) {
98
      return ['offset' => -1];
99
    }
100
101
    $binarySha = hex2bin( $sha );
102
103
    if( $this->lastPack ) {
104
      $offset = $this->findInIdx( $this->lastPack, $binarySha );
105
106
      if( $offset !== -1 ) {
107
        return $this->makeResult( $this->lastPack, $offset );
108
      }
109
    }
110
111
    foreach( $this->packFiles as $indexFile ) {
112
      if( $indexFile === $this->lastPack ) {
113
        continue;
114
      }
115
116
      $offset = $this->findInIdx( $indexFile, $binarySha );
117
118
      if( $offset !== -1 ) {
119
        $this->lastPack = $indexFile;
120
121
        return $this->makeResult( $indexFile, $offset );
122
      }
123
    }
124
125
    return ['offset' => -1];
126
  }
127
128
  private function makeResult( string $indexPath, int $offset ): array {
129
    return [
130
      'file'   => str_replace( '.idx', '.pack', $indexPath ),
131
      'offset' => $offset
132
    ];
133
  }
134
135
  private function findInIdx( string $indexFile, string $binarySha ): int {
136
    $fileHandle = $this->getHandle( $indexFile );
137
138
    if( !$fileHandle ) {
139
      return -1;
140
    }
141
142
    if( !isset( $this->fanoutCache[$indexFile] ) ) {
143
      fseek( $fileHandle, 0 );
144
145
      if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) {
146
        $this->fanoutCache[$indexFile] = array_values(
147
          unpack( 'N*', fread( $fileHandle, 1024 ) )
148
        );
149
      } else {
150
        return -1;
151
      }
152
    }
153
154
    $fanout = $this->fanoutCache[$indexFile];
155
156
    $firstByte = ord( $binarySha[0] );
157
    $start     = $firstByte === 0 ? 0 : $fanout[$firstByte - 1];
158
    $end       = $fanout[$firstByte];
159
160
    if( $end <= $start ) {
161
      return -1;
162
    }
163
164
    $cacheKey = "$indexFile:$firstByte";
165
166
    if( !isset( $this->shaBucketCache[$cacheKey] ) ) {
167
      $count = $end - $start;
168
169
      fseek( $fileHandle, 1032 + ($start * 20) );
170
171
      $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 );
172
173
      fseek(
174
        $fileHandle,
175
        1032 + ($fanout[255] * 24) + ($start * 4)
176
      );
177
178
      $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 );
179
    }
180
181
    $shaBlock = $this->shaBucketCache[$cacheKey];
182
    $count    = strlen( $shaBlock ) / 20;
183
    $low      = 0;
184
    $high     = $count - 1;
185
    $foundIdx = -1;
186
187
    while( $low <= $high ) {
188
      $mid     = ($low + $high) >> 1;
189
      $compare = substr( $shaBlock, $mid * 20, 20 );
190
191
      if( $compare < $binarySha ) {
192
        $low = $mid + 1;
193
      } elseif( $compare > $binarySha ) {
194
        $high = $mid - 1;
195
      } else {
196
        $foundIdx = $mid;
197
        break;
198
      }
199
    }
200
201
    if( $foundIdx === -1 ) {
202
      return -1;
203
    }
204
205
    $offsetData = substr(
206
      $this->offsetBucketCache[$cacheKey],
207
      $foundIdx * 4,
208
      4
209
    );
210
211
    $offset = unpack( 'N', $offsetData )[1];
212
213
    if( $offset & 0x80000000 ) {
214
      $packTotal = $fanout[255];
215
      $pos64     = 1032 + ($packTotal * 28) +
216
                   (($offset & 0x7FFFFFFF) * 8);
217
218
      fseek( $fileHandle, $pos64 );
219
220
      $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1];
221
    }
222
223
    return (int)$offset;
224
  }
225
226
  private function readPackEntry(
227
    $fileHandle,
228
    int $offset,
229
    int $expectedSize,
230
    int $cap = 0
231
  ): string {
232
    fseek( $fileHandle, $offset );
233
234
    $header = $this->readVarInt( $fileHandle );
235
    $type   = ($header['byte'] >> 4) & 7;
236
237
    if( $type === 6 ) {
238
      return $this->handleOfsDelta(
239
        $fileHandle,
240
        $offset,
241
        $expectedSize,
242
        $cap
243
      );
244
    }
245
246
    if( $type === 7 ) {
247
      return $this->handleRefDelta( $fileHandle, $expectedSize, $cap );
248
    }
249
250
    return $this->decompressToString( $fileHandle, $expectedSize, $cap );
251
  }
252
253
  private function streamPackEntry(
254
    $fileHandle,
255
    int $offset,
256
    int $expectedSize,
257
    callable $callback
258
  ): bool {
259
    fseek( $fileHandle, $offset );
260
261
    $header = $this->readVarInt( $fileHandle );
262
    $type   = ($header['byte'] >> 4) & 7;
263
264
    if( $type === 6 || $type === 7 ) {
265
      return $this->streamDeltaObject(
266
        $fileHandle,
267
        $offset,
268
        $type,
269
        $expectedSize,
270
        $callback
271
      );
272
    }
273
274
    return $this->streamDecompression( $fileHandle, $callback );
275
  }
276
277
  private function streamDeltaObject(
278
    $fileHandle,
279
    int $offset,
280
    int $type,
281
    int $expectedSize,
282
    callable $callback
283
  ): bool {
284
    fseek( $fileHandle, $offset );
285
    $this->readVarInt( $fileHandle );
286
287
    if( $type === 6 ) {
288
      $byte     = ord( fread( $fileHandle, 1 ) );
289
      $negative = $byte & 127;
290
291
      while( $byte & 128 ) {
292
        $byte     = ord( fread( $fileHandle, 1 ) );
293
        $negative = (($negative + 1) << 7) | ($byte & 127);
294
      }
295
296
      $deltaPos   = ftell( $fileHandle );
297
      $baseOffset = $offset - $negative;
298
299
      $base = '';
300
301
      $this->streamPackEntry(
302
        $fileHandle,
303
        $baseOffset,
304
        0,
305
        function( $chunk ) use ( &$base ) { $base .= $chunk; }
306
      );
307
308
      fseek( $fileHandle, $deltaPos );
309
    } else {
310
      $baseSha = bin2hex( fread( $fileHandle, 20 ) );
311
312
      $base     = '';
313
      $streamed = $this->stream(
314
        $baseSha,
315
        function( $chunk ) use ( &$base ) { $base .= $chunk; }
316
      );
317
318
      if( !$streamed ) {
319
        return false;
320
      }
321
    }
322
323
    $compressed = fread( $fileHandle, self::MAX_READ );
324
    $delta      = @gzuncompress( $compressed ) ?: '';
325
326
    $result = $this->applyDelta( $base, $delta );
327
328
    $chunkSize = 8192;
329
    $length    = strlen( $result );
330
331
    for( $i = 0; $i < $length; $i += $chunkSize ) {
332
      $callback( substr( $result, $i, $chunkSize ) );
333
    }
334
335
    return true;
336
  }
337
338
  private function streamDecompression( $fileHandle, callable $callback ): bool {
339
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
340
341
    if( $inflator === false ) {
342
      return false;
343
    }
344
345
    while( !feof( $fileHandle ) ) {
346
      $chunk = fread( $fileHandle, 8192 );
347
348
      if( $chunk === false || $chunk === '' ) {
349
        break;
350
      }
351
352
      $data = @inflate_add( $inflator, $chunk );
353
354
      if( $data !== false && $data !== '' ) {
355
        $callback( $data );
356
      }
357
358
      if(
359
        $data === false ||
360
        inflate_get_status( $inflator ) === ZLIB_STREAM_END
361
      ) {
362
        break;
363
      }
364
    }
365
366
    return true;
367
  }
368
369
  private function decompressToString(
370
    $fileHandle,
371
    int $maxSize,
372
    int $cap = 0
373
  ): string {
374
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
375
376
    if( $inflator === false ) {
377
      return '';
378
    }
379
380
    $result = '';
381
382
    while( !feof( $fileHandle ) ) {
383
      $chunk = fread( $fileHandle, 8192 );
384
385
      if( $chunk === false || $chunk === '' ) {
386
        break;
387
      }
388
389
      $data = @inflate_add( $inflator, $chunk );
390
391
      if( $data !== false ) {
392
        $result .= $data;
393
      }
394
395
      if( $cap > 0 && strlen( $result ) >= $cap ) {
396
        return substr( $result, 0, $cap );
397
      }
398
399
      if(
400
        $data === false ||
401
        inflate_get_status( $inflator ) === ZLIB_STREAM_END
402
      ) {
403
        break;
404
      }
405
    }
406
407
    return $result;
408
  }
409
410
  private function extractPackedSize( string $packPath, int $offset ): int {
411
    $fileHandle = $this->getHandle( $packPath );
412
413
    if( !$fileHandle ) {
414
      return 0;
415
    }
416
417
    fseek( $fileHandle, $offset );
418
419
    $header = $this->readVarInt( $fileHandle );
420
    $size   = $header['value'];
421
    $type   = ($header['byte'] >> 4) & 7;
422
423
    if( $type === 6 || $type === 7 ) {
424
      return $this->readDeltaTargetSize( $fileHandle, $type );
425
    }
426
427
    return $size;
428
  }
429
430
  private function handleOfsDelta(
431
    $fileHandle,
432
    int $offset,
433
    int $expectedSize,
434
    int $cap = 0
435
  ): string {
436
    $byte     = ord( fread( $fileHandle, 1 ) );
437
    $negative = $byte & 127;
438
439
    while( $byte & 128 ) {
440
      $byte     = ord( fread( $fileHandle, 1 ) );
441
      $negative = (($negative + 1) << 7) | ($byte & 127);
442
    }
443
444
    $currentPos = ftell( $fileHandle );
445
    $baseOffset = $offset - $negative;
446
447
    fseek( $fileHandle, $baseOffset );
448
449
    $baseHeader = $this->readVarInt( $fileHandle );
450
    $baseSize   = $baseHeader['value'];
451
452
    fseek( $fileHandle, $baseOffset );
453
454
    $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize, $cap );
455
456
    fseek( $fileHandle, $currentPos );
457
458
    $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) );
459
    $compressed     = fread( $fileHandle, $remainingBytes );
460
    $delta          = @gzuncompress( $compressed ) ?: '';
461
462
    return $this->applyDelta( $base, $delta, $cap );
463
  }
464
465
  private function handleRefDelta(
466
    $fileHandle,
467
    int $expectedSize,
468
    int $cap = 0
469
  ): string {
470
    $baseSha = bin2hex( fread( $fileHandle, 20 ) );
471
472
    if( $cap > 0 ) {
473
      $base = $this->peek( $baseSha, $cap ) ?? '';
474
    } else {
475
      $base = $this->read( $baseSha ) ?? '';
476
    }
477
478
    $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) );
479
    $compressed     = fread( $fileHandle, $remainingBytes );
480
    $delta          = @gzuncompress( $compressed ) ?: '';
481
482
    return $this->applyDelta( $base, $delta, $cap );
483
  }
484
485
  private function applyDelta( string $base, string $delta, int $cap = 0 ): string {
486
    $position = 0;
487
488
    $this->skipSize( $delta, $position );
489
    $this->skipSize( $delta, $position );
490
491
    $output      = '';
492
    $deltaLength = strlen( $delta );
493
494
    while( $position < $deltaLength ) {
495
      if( $cap > 0 && strlen( $output ) >= $cap ) {
496
        break;
497
      }
498
499
      $opcode = ord( $delta[$position++] );
500
501
      if( $opcode & 128 ) {
502
        $offset = 0;
503
        $length = 0;
504
505
        if( $opcode & 0x01 ) { $offset |= ord( $delta[$position++] ); }
506
        if( $opcode & 0x02 ) { $offset |= ord( $delta[$position++] ) << 8; }
507
        if( $opcode & 0x04 ) { $offset |= ord( $delta[$position++] ) << 16; }
508
        if( $opcode & 0x08 ) { $offset |= ord( $delta[$position++] ) << 24; }
509
510
        if( $opcode & 0x10 ) { $length |= ord( $delta[$position++] ); }
511
        if( $opcode & 0x20 ) { $length |= ord( $delta[$position++] ) << 8; }
512
        if( $opcode & 0x40 ) { $length |= ord( $delta[$position++] ) << 16; }
513
514
        if( $length === 0 ) { $length = 0x10000; }
515
516
        $output .= substr( $base, $offset, $length );
517
      } else {
518
        $length = $opcode & 127;
519
        $output .= substr( $delta, $position, $length );
520
        $position += $length;
521
      }
522
    }
523
524
    return $output;
525
  }
526
527
  private function readVarInt( $fileHandle ): array {
528
    $byte  = ord( fread( $fileHandle, 1 ) );
529
    $value = $byte & 15;
530
    $shift = 4;
531
    $first = $byte;
532
533
    while( $byte & 128 ) {
534
      $byte  = ord( fread( $fileHandle, 1 ) );
535
      $value |= (($byte & 127) << $shift);
536
      $shift += 7;
537
    }
538
539
    return ['value' => $value, 'byte' => $first];
540
  }
541
542
  private function readDeltaTargetSize( $fileHandle, int $type ): int {
543
    if( $type === 6 ) {
544
      $byte = ord( fread( $fileHandle, 1 ) );
545
546
      while( $byte & 128 ) {
547
        $byte = ord( fread( $fileHandle, 1 ) );
548
      }
549
    } else {
550
      fseek( $fileHandle, 20, SEEK_CUR );
551
    }
552
553
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
554
555
    if( $inflator === false ) {
556
      return 0;
557
    }
558
559
    $header      = '';
560
    $attempts    = 0;
561
    $maxAttempts = 64;
562
563
    while(
564
      !feof( $fileHandle ) &&
565
      strlen( $header ) < 32 &&
566
      $attempts < $maxAttempts
567
    ) {
513568
      $chunk = fread( $fileHandle, 512 );
514569
M index.php
55
Config::init();
66
7
$router = new Router(Config::getReposPath());
7
$router = new Router( Config::getReposPath() );
88
$page = $router->route();
9
910
$page->render();
1011
M pages/BasePage.php
77
  protected $title;
88
9
  public function __construct(array $repositories) {
9
  public function __construct( array $repositories ) {
1010
    $this->repositories = $repositories;
1111
  }
1212
13
  protected function renderLayout($contentCallback, $currentRepo = null) {
13
  protected function renderLayout( $contentCallback, $currentRepo = null ) {
1414
    ?>
1515
    <!DOCTYPE html>
1616
    <html lang="en">
1717
    <head>
1818
      <meta charset="UTF-8">
1919
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
20
      <title><?php echo Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title>
21
      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
20
      <title><?php
21
        echo Config::SITE_TITLE .
22
          ($this->title ? ' - ' . htmlspecialchars( $this->title ) : '');
23
      ?></title>
24
      <link rel="stylesheet"
25
            href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
2226
      <link rel="stylesheet" href="repo.css">
2327
    </head>
2428
    <body>
2529
    <div class="container">
2630
      <header>
2731
        <h1><?php echo Config::SITE_TITLE; ?></h1>
2832
        <nav class="nav">
2933
          <a href="?">Home</a>
30
          <?php if ($currentRepo):
31
            $safeName = urlencode($currentRepo['safe_name']); ?>
34
          <?php if( $currentRepo ):
35
            $safeName = urlencode( $currentRepo['safe_name'] ); ?>
3236
            <a href="?repo=<?php echo $safeName; ?>">Files</a>
3337
            <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a>
3438
            <a href="?action=refs&repo=<?php echo $safeName; ?>">Branches</a>
3539
            <a href="?action=tags&repo=<?php echo $safeName; ?>">Tags</a>
3640
          <?php endif; ?>
3741
38
          <?php if ($currentRepo): ?>
42
          <?php if( $currentRepo ): ?>
3943
          <div class="repo-selector">
4044
            <label>Repository:</label>
4145
            <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)">
42
              <?php foreach ($this->repositories as $r): ?>
43
              <option value="<?php echo htmlspecialchars($r['safe_name']); ?>"
46
              <?php foreach( $this->repositories as $r ): ?>
47
              <option value="<?php echo htmlspecialchars( $r['safe_name'] ); ?>"
4448
                <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>>
45
                <?php echo htmlspecialchars($r['name']); ?>
49
                <?php echo htmlspecialchars( $r['name'] ); ?>
4650
              </option>
4751
              <?php endforeach; ?>
4852
            </select>
4953
          </div>
5054
          <?php endif; ?>
5155
        </nav>
5256
53
        <?php if ($currentRepo): ?>
57
        <?php if( $currentRepo ): ?>
5458
          <div class="repo-info-banner">
55
            <span class="current-repo">Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong></span>
59
            <span class="current-repo">
60
              Current: <strong><?php
61
                echo htmlspecialchars( $currentRepo['name'] );
62
              ?></strong>
63
            </span>
5664
          </div>
5765
        <?php endif; ?>
5866
      </header>
5967
60
      <?php call_user_func($contentCallback); ?>
68
      <?php call_user_func( $contentCallback ); ?>
6169
6270
    </div>
M pages/ClonePage.php
77
88
  public function __construct( Git $git, string $subPath ) {
9
    $this->git     = $git;
9
    $this->git = $git;
1010
    $this->subPath = $subPath;
1111
  }
M pages/CommitsPage.php
77
  private $hash;
88
9
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) {
10
    parent::__construct($repositories);
9
  public function __construct(
10
    array $repositories,
11
    array $currentRepo,
12
    Git $git,
13
    string $hash
14
  ) {
15
    parent::__construct( $repositories );
1116
    $this->currentRepo = $currentRepo;
1217
    $this->git = $git;
1318
    $this->hash = $hash;
1419
    $this->title = $currentRepo['name'];
1520
  }
1621
1722
  public function render() {
18
    $this->renderLayout(function() {
19
      // Use local private $git
23
    $this->renderLayout( function() {
2024
      $main = $this->git->getMainBranch();
2125
22
      if (!$main) {
23
        echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>';
26
      if( !$main ) {
27
        echo '<div class="empty-state"><h3>No branches</h3>' .
28
             '<p>Empty repository.</p></div>';
2429
        return;
2530
      }
2631
2732
      $this->renderBreadcrumbs();
28
      echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>';
33
34
      echo '<h2>Commit History <span class="branch-badge">' .
35
           htmlspecialchars( $main['name'] ) . '</span></h2>';
2936
      echo '<div class="commit-list">';
3037
3138
      $start = $this->hash ?: $main['hash'];
32
      $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']);
39
      $repoParam = '&repo=' . urlencode( $this->currentRepo['safe_name'] );
3340
34
      $this->git->history($start, 100, function($commit) use ($repoParam) {
35
        $msg = htmlspecialchars(explode("\n", $commit->message)[0]);
41
      $this->git->history( $start, 100, function( $commit ) use ( $repoParam ) {
42
        $msg = htmlspecialchars( explode( "\n", $commit->message )[0] );
43
3644
        echo '<div class="commit-row">';
37
        echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>';
45
        echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam .
46
             '" class="sha">' . substr( $commit->sha, 0, 7 ) . '</a>';
3847
        echo '<span class="message">' . $msg . '</span>';
39
        echo '<span class="meta">' . htmlspecialchars($commit->author) . ' &bull; ' . date('Y-m-d', $commit->date) . '</span>';
48
        echo '<span class="meta">' . htmlspecialchars( $commit->author ) .
49
             ' &bull; ' . date( 'Y-m-d', $commit->date ) . '</span>';
4050
        echo '</div>';
41
      });
51
      } );
52
4253
      echo '</div>';
43
    }, $this->currentRepo);
54
    }, $this->currentRepo );
4455
  }
4556
4657
  private function renderBreadcrumbs() {
4758
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
48
4959
    $crumbs = [
5060
      '<a href="?">Repositories</a>',
51
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
61
      '<a href="' . $repoUrl . '">' .
62
        htmlspecialchars( $this->currentRepo['name'] ) . '</a>',
5263
      'Commits'
5364
    ];
5465
55
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
66
    echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>';
5667
  }
5768
}
M pages/DiffPage.php
88
  private $hash;
99
10
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) {
11
    parent::__construct($repositories);
10
  public function __construct(
11
    array $repositories,
12
    array $currentRepo,
13
    Git $git,
14
    string $hash
15
  ) {
16
    parent::__construct( $repositories );
1217
    $this->currentRepo = $currentRepo;
1318
    $this->git = $git;
1419
    $this->hash = $hash;
15
    $this->title = substr($hash, 0, 7);
20
    $this->title = substr( $hash, 0, 7 );
1621
  }
1722
1823
  public function render() {
19
    $this->renderLayout(function() {
20
      $commitData = $this->git->read($this->hash);
21
      $diffEngine = new GitDiff($this->git);
22
23
      $lines = explode("\n", $commitData);
24
    $this->renderLayout( function() {
25
      $commitData = $this->git->read( $this->hash );
26
      $diffEngine = new GitDiff( $this->git );
27
      $lines = explode( "\n", $commitData );
2428
      $msg = '';
2529
      $isMsg = false;
2630
      $headers = [];
27
      foreach ($lines as $line) {
28
        if ($line === '') { $isMsg = true; continue; }
29
        if ($isMsg) { $msg .= $line . "\n"; }
30
        else {
31
            if (preg_match('/^(\w+) (.*)$/', $line, $m)) $headers[$m[1]] = $m[2];
31
32
      foreach( $lines as $line ) {
33
        if( $line === '' ) {
34
          $isMsg = true;
35
          continue;
36
        }
37
38
        if( $isMsg ) {
39
          $msg .= $line . "\n";
40
        } else {
41
          if( preg_match( '/^(\w+) (.*)$/', $line, $m ) ) {
42
            $headers[$m[1]] = $m[2];
43
          }
3244
        }
3345
      }
3446
35
      $changes = $diffEngine->compare($this->hash);
47
      $changes = $diffEngine->compare( $this->hash );
3648
3749
      $this->renderBreadcrumbs();
3850
39
      // Fix 1: Redact email address
4051
      $author = $headers['author'] ?? 'Unknown';
41
      $author = preg_replace('/<[^>]+>/', '<email>', $author);
52
      $author = preg_replace( '/<[^>]+>/', '<email>', $author );
4253
4354
      echo '<div class="commit-details">';
4455
      echo '<div class="commit-header">';
45
      echo '<h1 class="commit-title">' . htmlspecialchars(trim($msg)) . '</h1>';
56
      echo '<h1 class="commit-title">' . htmlspecialchars( trim( $msg ) ) . '</h1>';
4657
      echo '<div class="commit-info">';
47
      echo '<div class="commit-info-row"><span class="commit-info-label">Author</span><span class="commit-author">' . htmlspecialchars($author) . '</span></div>';
48
      echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span><span class="commit-info-value">' . $this->hash . '</span></div>';
58
      echo '<div class="commit-info-row"><span class="commit-info-label">Author</span>' .
59
           '<span class="commit-author">' . htmlspecialchars( $author ) . '</span></div>';
60
      echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span>' .
61
           '<span class="commit-info-value">' . $this->hash . '</span></div>';
4962
50
      if (isset($headers['parent'])) {
51
          // Fix 2: Use '&' instead of '?' because parameters (action & hash) already exist
52
          $repoUrl = '&repo=' . urlencode($this->currentRepo['safe_name']);
53
          echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span><span class="commit-info-value">';
54
          echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl . '" class="parent-link">' . substr($headers['parent'], 0, 7) . '</a>';
55
          echo '</span></div>';
63
      if( isset( $headers['parent'] ) ) {
64
        $repoUrl = '&repo=' . urlencode( $this->currentRepo['safe_name'] );
65
66
        echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span>' .
67
             '<span class="commit-info-value">';
68
        echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl .
69
             '" class="parent-link">' . substr( $headers['parent'], 0, 7 ) . '</a>';
70
        echo '</span></div>';
5671
      }
72
5773
      echo '</div></div></div>';
5874
5975
      echo '<div class="diff-container">';
60
      foreach ($changes as $change) {
61
        $this->renderFileDiff($change);
76
77
      foreach( $changes as $change ) {
78
        $this->renderFileDiff( $change );
6279
      }
63
      if (empty($changes)) {
64
          echo '<div class="empty-state"><p>No changes detected.</p></div>';
80
81
      if( empty( $changes ) ) {
82
        echo '<div class="empty-state"><p>No changes detected.</p></div>';
6583
      }
66
      echo '</div>';
6784
68
    }, $this->currentRepo);
85
      echo '</div>';
86
    }, $this->currentRepo );
6987
  }
7088
71
  private function renderFileDiff($change) {
89
  private function renderFileDiff( $change ) {
7290
    $statusIcon = 'fa-file';
7391
    $statusClass = '';
7492
75
    if ($change['type'] === 'A') { $statusIcon = 'fa-plus-circle'; $statusClass = 'status-add'; }
76
    if ($change['type'] === 'D') { $statusIcon = 'fa-minus-circle'; $statusClass = 'status-del'; }
77
    if ($change['type'] === 'M') { $statusIcon = 'fa-pencil-alt'; $statusClass = 'status-mod'; }
93
    if( $change['type'] === 'A' ) {
94
      $statusIcon = 'fa-plus-circle';
95
      $statusClass = 'status-add';
96
    }
97
98
    if( $change['type'] === 'D' ) {
99
      $statusIcon = 'fa-minus-circle';
100
      $statusClass = 'status-del';
101
    }
102
103
    if( $change['type'] === 'M' ) {
104
      $statusIcon = 'fa-pencil-alt';
105
      $statusClass = 'status-mod';
106
    }
78107
79108
    echo '<div class="diff-file">';
80109
    echo '<div class="diff-header">';
81
    echo '<span class="diff-status ' . $statusClass . '"><i class="fa ' . $statusIcon . '"></i></span>';
82
    echo '<span class="diff-path">' . htmlspecialchars($change['path']) . '</span>';
110
    echo '<span class="diff-status ' . $statusClass . '">' .
111
         '<i class="fa ' . $statusIcon . '"></i></span>';
112
    echo '<span class="diff-path">' . htmlspecialchars( $change['path'] ) . '</span>';
83113
    echo '</div>';
84114
85
    if ($change['is_binary']) {
86
        echo '<div class="diff-binary">Binary files differ</div>';
115
    if( $change['is_binary'] ) {
116
      echo '<div class="diff-binary">Binary files differ</div>';
87117
    } else {
88118
      echo '<div class="diff-content">';
89119
      echo '<table><tbody>';
90120
91
      foreach ($change['hunks'] as $line) {
92
          if (isset($line['t']) && $line['t'] === 'gap') {
93
              echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
94
              continue;
95
          }
121
      foreach( $change['hunks'] as $line ) {
122
        if( isset( $line['t'] ) && $line['t'] === 'gap' ) {
123
          echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
124
          continue;
125
        }
96126
97
          $class = 'diff-ctx';
98
          $char = ' ';
99
          if ($line['t'] === '+') { $class = 'diff-add'; $char = '+'; }
100
          if ($line['t'] === '-') { $class = 'diff-del'; $char = '-'; }
127
        $class = 'diff-ctx';
128
        $char = ' ';
101129
102
          echo '<tr class="' . $class . '">';
103
          echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
104
          echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
105
          echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . htmlspecialchars($line['l']) . '</td>';
106
          echo '</tr>';
130
        if( $line['t'] === '+' ) {
131
          $class = 'diff-add';
132
          $char = '+';
133
        }
134
135
        if( $line['t'] === '-' ) {
136
          $class = 'diff-del';
137
          $char = '-';
138
        }
139
140
        echo '<tr class="' . $class . '">';
141
        echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
142
        echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
143
        echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' .
144
             htmlspecialchars( $line['l'] ) . '</td>';
145
        echo '</tr>';
107146
      }
108147
109148
      echo '</tbody></table>';
110149
      echo '</div>';
111150
    }
151
112152
    echo '</div>';
113153
  }
114154
115155
  private function renderBreadcrumbs() {
116
    $safeName = urlencode($this->currentRepo['safe_name']);
117
156
    $safeName = urlencode( $this->currentRepo['safe_name'] );
118157
    $crumbs = [
119158
      '<a href="?">Repositories</a>',
120
      '<a href="?repo=' . $safeName . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
121
      // Fix 3: Use '&' separator for the repo parameter
159
      '<a href="?repo=' . $safeName . '">' .
160
        htmlspecialchars( $this->currentRepo['name'] ) . '</a>',
122161
      '<a href="?action=commits&repo=' . $safeName . '">Commits</a>',
123
      substr($this->hash, 0, 7)
162
      substr( $this->hash, 0, 7 )
124163
    ];
125
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
164
165
    echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>';
126166
  }
127167
}
M pages/FilePage.php
11
<?php
22
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../render/HtmlFileRenderer.php';
34
45
class FilePage extends BasePage {
56
  private $currentRepo;
67
  private $git;
78
  private $hash;
8
9
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash = '') {
10
    parent::__construct($repositories);
119
10
  public function __construct(
11
    array $repositories,
12
    array $currentRepo,
13
    Git $git,
14
    string $hash = ''
15
  ) {
16
    parent::__construct( $repositories );
1217
    $this->currentRepo = $currentRepo;
13
    $this->git         = $git;
14
    $this->hash        = $hash;
15
    $this->title       = $currentRepo['name'];
18
    $this->git = $git;
19
    $this->hash = $hash;
20
    $this->title = $currentRepo['name'];
1621
  }
1722
1823
  public function render() {
19
    $this->renderLayout(function() {
24
    $this->renderLayout( function() {
2025
      $main = $this->git->getMainBranch();
2126
22
      if (!$main) {
27
      if( !$main ) {
2328
        echo '<div class="empty-state"><h3>No branches</h3></div>';
2429
      } else {
25
        $target  = $this->hash ?: $main['hash'];
30
        $target = $this->hash ?: $main['hash'];
2631
        $entries = [];
2732
28
        $this->git->walk($target, function($file) use (&$entries) {
33
        $this->git->walk( $target, function( $file ) use ( &$entries ) {
2934
          $entries[] = $file;
30
        });
35
        } );
3136
32
        if (!empty($entries)) {
33
          $this->renderTree($main, $target, $entries);
37
        if( !empty( $entries ) ) {
38
          $this->renderTree( $main, $target, $entries );
3439
        } else {
35
          $this->renderBlob($target);
40
          $this->renderBlob( $target );
3641
        }
3742
      }
38
    }, $this->currentRepo);
43
    }, $this->currentRepo );
3944
  }
4045
41
  private function renderTree($main, $targetHash, $entries) {
46
  private function renderTree( $main, $targetHash, $entries ) {
4247
    $path = $_GET['name'] ?? '';
4348
44
    $this->renderBreadcrumbs($targetHash, 'Tree');
49
    $this->renderBreadcrumbs( $targetHash, 'Tree' );
4550
46
    echo '<h2>' . htmlspecialchars($this->currentRepo['name']) .
51
    echo '<h2>' . htmlspecialchars( $this->currentRepo['name'] ) .
4752
         ' <span class="branch-badge">' .
48
         htmlspecialchars($main['name']) . '</span></h2>';
53
         htmlspecialchars( $main['name'] ) . '</span></h2>';
4954
50
    usort($entries, function($a, $b) {
51
      return $a->compare($b);
52
    });
55
    usort( $entries, function( $a, $b ) {
56
      return $a->compare( $b );
57
    } );
5358
5459
    echo '<div class="file-list">';
55
    $renderer = new HtmlFileRenderer($this->currentRepo['safe_name'], $path);
60
    $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], $path );
5661
57
    foreach ($entries as $file) {
58
      $file->render($renderer);
62
    foreach( $entries as $file ) {
63
      $file->renderListEntry( $renderer );
5964
    }
6065
6166
    echo '</div>';
6267
  }
63
64
  private function renderBlob($targetHash) {
65
    $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']);
66
    $filename  = $_GET['name'] ?? '';
67
    $file      = $this->git->readFile($targetHash, $filename);
68
    $size      = $this->git->getObjectSize($targetHash);
6968
70
    $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']);
69
  private function renderBlob( $targetHash ) {
70
    $repoParam = '&repo=' . urlencode( $this->currentRepo['safe_name'] );
71
    $filename = $_GET['name'] ?? '';
72
    $file = $this->git->readFile( $targetHash, $filename );
73
    $size = $this->git->getObjectSize( $targetHash );
74
    $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'] );
7175
72
    $this->renderBreadcrumbs($targetHash, 'File');
76
    $this->renderBreadcrumbs( $targetHash, 'File' );
7377
74
    if ($size === 0) {
75
      $this->renderDownloadState($targetHash, "This file is empty.");
78
    if( $size === 0 ) {
79
      $this->renderDownloadState( $targetHash, "This file is empty." );
7680
    } else {
77
      $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename);
81
      $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam .
82
                '&name=' . urlencode( $filename );
7883
79
      if (!$file->renderMedia($rawUrl)) {
80
        if ($file->isText()) {
81
          if ($size > 524288) {
84
      if( !$file->renderMedia( $renderer, $rawUrl ) ) {
85
        if( $file->isText() ) {
86
          if( $size > 524288 ) {
8287
            ob_start();
83
            $file->renderSize($renderer);
88
            $file->renderSize( $renderer );
8489
            $sizeStr = ob_get_clean();
85
            $this->renderDownloadState($targetHash, "File is too large to display ($sizeStr).");
90
91
            $this->renderDownloadState(
92
              $targetHash,
93
              "File is too large to display ($sizeStr)."
94
            );
8695
          } else {
8796
            $content = '';
88
            $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; });
89
            echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>';
97
98
            $this->git->stream( $targetHash, function( $d ) use ( &$content ) {
99
              $content .= $d;
100
            } );
101
102
            echo '<div class="blob-content"><pre class="blob-code">' .
103
                 $file->highlight( $renderer, $content ) . '</pre></div>';
90104
          }
91105
        } else {
92
          $this->renderDownloadState($targetHash, "This is a binary file.");
106
          $this->renderDownloadState( $targetHash, "This is a binary file." );
93107
        }
94108
      }
95109
    }
96110
  }
97111
98
  private function renderDownloadState($hash, $reason) {
112
  private function renderDownloadState( $hash, $reason ) {
99113
    $filename = $_GET['name'] ?? '';
100
    $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']) . '&name=' . urlencode($filename);
114
    $url = '?action=raw&hash=' . $hash . '&repo=' .
115
           urlencode( $this->currentRepo['safe_name'] ) .
116
           '&name=' . urlencode( $filename );
101117
102118
    echo '<div class="empty-state download-state">';
103
    echo   '<p>' . htmlspecialchars($reason) . '</p>';
104
    echo   '<a href="' . $url . '" class="btn-download">Download Raw File</a>';
119
    echo '<p>' . htmlspecialchars( $reason ) . '</p>';
120
    echo '<a href="' . $url . '" class="btn-download">Download Raw File</a>';
105121
    echo '</div>';
106122
  }
107
108
  private function renderBreadcrumbs($hash, $type) {
109
    $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']);
110
    $path    = $_GET['name'] ?? '';
111123
124
  private function renderBreadcrumbs( $hash, $type ) {
125
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
126
    $path = $_GET['name'] ?? '';
112127
    $crumbs = [
113128
      '<a href="?">Repositories</a>',
114
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>'
129
      '<a href="' . $repoUrl . '">' .
130
        htmlspecialchars( $this->currentRepo['name'] ) . '</a>'
115131
    ];
116132
117
    if ($path) {
118
      $parts = explode('/', trim($path, '/'));
119
      $acc   = '';
120
      foreach ($parts as $idx => $part) {
133
    if( $path ) {
134
      $parts = explode( '/', trim( $path, '/' ) );
135
      $acc = '';
136
137
      foreach( $parts as $idx => $part ) {
121138
        $acc .= ($idx === 0 ? '' : '/') . $part;
122
        if ($idx === count($parts) - 1) {
123
          $crumbs[] = htmlspecialchars($part);
139
140
        if( $idx === count( $parts ) - 1 ) {
141
          $crumbs[] = htmlspecialchars( $part );
124142
        } else {
125
          $crumbs[] = '<a href="' . $repoUrl . '&name=' . urlencode($acc) . '">' .
126
                      htmlspecialchars($part) . '</a>';
143
          $crumbs[] = '<a href="' . $repoUrl . '&name=' . urlencode( $acc ) . '">' .
144
                      htmlspecialchars( $part ) . '</a>';
127145
        }
128146
      }
129
    } elseif ($this->hash) {
130
      $crumbs[] = $type . ' ' . substr($hash, 0, 7);
147
    } elseif( $this->hash ) {
148
      $crumbs[] = $type . ' ' . substr( $hash, 0, 7 );
131149
    }
132150
133
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
151
    echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>';
134152
  }
135153
}
M pages/HomePage.php
55
  private $git;
66
7
  public function __construct(array $repositories, Git $git) {
8
    parent::__construct($repositories);
7
  public function __construct( array $repositories, Git $git ) {
8
    parent::__construct( $repositories );
99
    $this->git = $git;
1010
  }
1111
1212
  public function render() {
13
    $this->renderLayout(function() {
13
    $this->renderLayout( function() {
1414
      echo '<h2>Repositories</h2>';
15
      if (empty($this->repositories)) {
15
16
      if( empty( $this->repositories ) ) {
1617
        echo '<div class="empty-state">No repositories found.</div>';
1718
        return;
1819
      }
20
1921
      echo '<div class="repo-grid">';
20
      foreach ($this->repositories as $repo) {
21
        $this->renderRepoCard($repo);
22
23
      foreach( $this->repositories as $repo ) {
24
        $this->renderRepoCard( $repo );
2225
      }
26
2327
      echo '</div>';
24
    });
28
    } );
2529
  }
2630
27
  private function renderRepoCard($repo) {
28
    $this->git->setRepository($repo['path']);
31
  private function renderRepoCard( $repo ) {
32
    $this->git->setRepository( $repo['path'] );
2933
3034
    $main = $this->git->getMainBranch();
35
    $stats = [
36
      'branches' => 0,
37
      'tags' => 0
38
    ];
3139
32
    $stats = ['branches' => 0, 'tags' => 0];
33
    $this->git->eachBranch(function() use (&$stats) { $stats['branches']++; });
34
    $this->git->eachTag(function() use (&$stats) { $stats['tags']++; });
40
    $this->git->eachBranch( function() use ( &$stats ) {
41
      $stats['branches']++;
42
    } );
3543
36
    echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">';
37
    echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>';
44
    $this->git->eachTag( function() use ( &$stats ) {
45
      $stats['tags']++;
46
    } );
3847
48
    echo '<a href="?repo=' . urlencode( $repo['safe_name'] ) . '" class="repo-card">';
49
    echo '<h3>' . htmlspecialchars( $repo['name'] ) . '</h3>';
3950
    echo '<p class="repo-meta">';
4051
4152
    $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches';
4253
    $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags';
4354
44
    echo $stats['branches'] . ' ' . $branchLabel . ', ' . $stats['tags'] . ' ' . $tagLabel;
55
    echo $stats['branches'] . ' ' . $branchLabel . ', ' .
56
         $stats['tags'] . ' ' . $tagLabel;
4557
46
    if ($main) {
58
    if( $main ) {
4759
      echo ', ';
48
      $this->git->history('HEAD', 1, function($c) use ($repo) {
49
        $renderer = new HtmlFileRenderer($repo['safe_name']);
50
        $renderer->renderTime($c->date);
51
      });
60
61
      $this->git->history( 'HEAD', 1, function( $c ) use ( $repo ) {
62
        $renderer = new HtmlFileRenderer( $repo['safe_name'] );
63
        $renderer->renderTime( $c->date );
64
      } );
5265
    }
66
5367
    echo '</p>';
5468
5569
    $descPath = $repo['path'] . '/description';
56
    if (file_exists($descPath)) {
57
      $description = trim(file_get_contents($descPath));
58
      if ($description !== '') {
59
        echo '<p style="margin-top: 1.5em;">' . htmlspecialchars($description) . '</p>';
70
71
    if( file_exists( $descPath ) ) {
72
      $description = trim( file_get_contents( $descPath ) );
73
74
      if( $description !== '' ) {
75
        echo '<p style="margin-top: 1.5em;">' .
76
             htmlspecialchars( $description ) . '</p>';
6077
      }
6178
    }
M pages/RawPage.php
66
  private $hash;
77
8
  public function __construct($git, $hash) {
8
  public function __construct( $git, $hash ) {
99
    $this->git = $git;
1010
    $this->hash = $hash;
1111
  }
1212
1313
  public function render() {
14
    $filename = basename($_GET['name'] ?? '') ?: 'file';
15
    $file = $this->git->readFile($this->hash, $filename);
14
    $filename = basename( $_GET['name'] ?? '' ) ?: 'file';
15
    $file = $this->git->readFile( $this->hash, $filename );
1616
17
    while (ob_get_level()) {
17
    while( ob_get_level() ) {
1818
      ob_end_clean();
1919
    }
2020
2121
    $file->emitRawHeaders();
22
23
    $this->git->stream($this->hash, function($d) {
22
    $this->git->stream( $this->hash, function( $d ) {
2423
      echo $d;
25
    });
24
    } );
2625
2726
    exit;
M pages/TagsPage.php
77
  private $git;
88
9
  public function __construct(array $repositories, array $currentRepo, Git $git) {
10
    parent::__construct($repositories);
9
  public function __construct(
10
    array $repositories,
11
    array $currentRepo,
12
    Git $git
13
  ) {
14
    parent::__construct( $repositories );
1115
    $this->currentRepo = $currentRepo;
1216
    $this->git = $git;
1317
    $this->title = $currentRepo['name'] . ' - Tags';
1418
  }
1519
1620
  public function render() {
17
    $this->renderLayout(function() {
21
    $this->renderLayout( function() {
1822
      $this->renderBreadcrumbs();
1923
...
3236
3337
      $tags = [];
34
      $this->git->eachTag(function(Tag $tag) use (&$tags) {
38
39
      $this->git->eachTag( function( Tag $tag ) use ( &$tags ) {
3540
        $tags[] = $tag;
36
      });
41
      } );
3742
38
      usort($tags, function(Tag $a, Tag $b) {
39
          return $a->compare($b);
40
      });
43
      usort( $tags, function( Tag $a, Tag $b ) {
44
        return $a->compare( $b );
45
      } );
4146
42
      $renderer = new HtmlTagRenderer($this->currentRepo['safe_name']);
47
      $renderer = new HtmlTagRenderer( $this->currentRepo['safe_name'] );
4348
44
      if (empty($tags)) {
45
        echo '<tr><td colspan="5"><div class="empty-state"><p>No tags found.</p></div></td></tr>';
49
      if( empty( $tags ) ) {
50
        echo '<tr><td colspan="5"><div class="empty-state">' .
51
             '<p>No tags found.</p></div></td></tr>';
4652
      } else {
47
        foreach ($tags as $tag) {
48
          $tag->render($renderer);
53
        foreach( $tags as $tag ) {
54
          $tag->render( $renderer );
4955
        }
5056
      }
5157
5258
      echo '</tbody>';
5359
      echo '</table>';
54
    }, $this->currentRepo);
60
    }, $this->currentRepo );
5561
  }
5662
5763
  private function renderBreadcrumbs() {
58
    $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']);
59
64
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
6065
    $crumbs = [
6166
      '<a href="?">Repositories</a>',
62
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
67
      '<a href="' . $repoUrl . '">' .
68
        htmlspecialchars( $this->currentRepo['name'] ) . '</a>',
6369
      'Tags'
6470
    ];
6571
66
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
72
    echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>';
6773
  }
6874
}
M render/FileRenderer.php
11
<?php
2
require_once __DIR__ . '/../File.php';
3
24
interface FileRenderer {
3
  public function renderFile(
5
  public function renderListEntry(
46
    string $name,
57
    string $sha,
68
    string $mode,
79
    string $iconClass,
810
    int $timestamp,
9
    int $size = 0
11
    int $size
1012
  ): void;
1113
12
  public function renderTime( int $timestamp ): void;
14
  public function renderMedia(
15
    File $file,
16
    string $url,
17
    string $mediaType
18
  ): bool;
1319
1420
  public function renderSize( int $bytes ): void;
15
}
16
17
class HtmlFileRenderer implements FileRenderer {
18
  private string $repoSafeName;
19
  private string $currentPath;
20
21
  public function __construct( string $repoSafeName, string $currentPath = '' ) {
22
    $this->repoSafeName = $repoSafeName;
23
    $this->currentPath  = trim( $currentPath, '/' );
24
  }
25
26
  public function renderFile(
27
    string $name,
28
    string $sha,
29
    string $mode,
30
    string $iconClass,
31
    int $timestamp,
32
    int $size = 0
33
  ): void {
34
    $fullPath = ($this->currentPath===''?'':$this->currentPath.'/') . $name;
35
    $url      = '?repo=' . urlencode( $this->repoSafeName ) . '&hash=' . $sha . '&name=' . urlencode( $fullPath );
36
37
    echo '<a href="' . $url . '" class="file-item">';
38
    echo   '<span class="file-mode">' . $mode . '</span>';
39
    echo   '<span class="file-name">';
40
    echo     '<i class="fas ' . $iconClass . ' file-icon-container"></i>';
41
    echo     htmlspecialchars( $name );
42
    echo   '</span>';
43
44
    if( $size > 0 ) {
45
      echo '<span class="file-size">' . $this->formatSize($size) . '</span>';
46
    }
47
48
    if( $timestamp > 0 ) {
49
      echo '<span class="file-date">';
50
      $this->renderTime( $timestamp );
51
      echo '</span>';
52
    }
53
54
    echo '</a>';
55
  }
56
57
  public function renderTime( int $timestamp ): void {
58
    $tokens = [
59
      31536000 => 'year',
60
      2592000  => 'month',
61
      604800   => 'week',
62
      86400    => 'day',
63
      3600     => 'hour',
64
      60       => 'minute',
65
      1        => 'second'
66
    ];
67
68
    $diff = $timestamp ? time() - $timestamp : null;
69
    $result = 'never';
70
71
    if( $diff && $diff >= 5 ) {
72
      foreach( $tokens as $unit => $text ) {
73
        if( $diff < $unit ) {
74
          continue;
75
        }
76
77
        $num = floor( $diff / $unit );
78
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
79
        break;
80
      }
81
    } elseif( $diff ) {
82
      $result = 'just now';
83
    }
84
85
    echo $result;
86
  }
87
88
  public function renderSize( int $bytes ): void {
89
    echo $this->formatSize($bytes);
90
  }
91
92
  private function formatSize(int $bytes): string {
93
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
94
    $i = 0;
9521
96
    while ($bytes >= 1024 && $i < count($units) - 1) {
97
      $bytes /= 1024;
98
      $i++;
99
    }
22
  public function highlight(
23
    string $filename,
24
    string $content,
25
    string $mediaType
26
  ): string;
10027
101
    return ($bytes === 0 ? 0 : round($bytes)) . ' ' . $units[$i];
102
  }
28
  public function renderTime( int $timestamp ): void;
10329
}
10430
A render/Highlighter.php
1
<?php
2
require_once __DIR__ . '/LanguageDefinitions.php';
3
4
class Highlighter {
5
  private string $content;
6
  private string $lang;
7
  private array $rules;
8
9
  public function __construct(string $filename, string $content, string $mediaType) {
10
    $this->content = $content;
11
12
    $this->lang = $this->detectLanguage($mediaType, $filename);
13
    $this->rules = LanguageDefinitions::get($this->lang) ?? [];
14
  }
15
16
  public function render(): string {
17
    if (empty($this->rules)) {
18
      return htmlspecialchars($this->content);
19
    }
20
21
    $patterns = [];
22
23
    foreach ($this->rules as $name => $pattern) {
24
      $delim = $pattern[0];
25
      $inner = substr($pattern, 1, strrpos($pattern, $delim) - 1);
26
      $inner = str_replace('~', '\~', $inner);
27
28
      $patterns[] = "(?P<{$name}>{$inner})";
29
    }
30
31
    if (!in_array($this->lang, ['markdown', 'rmd'])) {
32
      $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,])";
33
    }
34
35
    $patterns[] = "(?P<any>[\s\S])";
36
    $combined = '~' . implode('|', $patterns) . '~msu';
37
38
    return preg_replace_callback($combined, function ($matches) {
39
      foreach ($matches as $key => $value) {
40
        if (!is_numeric($key) && $value !== '') {
41
          if ($key === 'any') {
42
            return htmlspecialchars($value);
43
          }
44
45
          if ($key === 'string_interp') {
46
            return $this->renderInterpolatedString($value);
47
          }
48
49
          if ($key === 'math') {
50
            return $this->renderMath($value);
51
          }
52
53
          return '<span class="hl-' . $key . '">' . htmlspecialchars($value) . '</span>';
54
        }
55
      }
56
57
      return htmlspecialchars($matches[0]);
58
    }, $this->content);
59
  }
60
61
  private function renderInterpolatedString(string $content): string {
62
    $pattern = '/(\$\{[a-zA-Z0-9_]+\}|\$[a-zA-Z0-9_]+)/';
63
    $parts   = preg_split($pattern, $content, -1, PREG_SPLIT_DELIM_CAPTURE);
64
    $output  = '<span class="hl-string">';
65
66
    foreach ($parts as $part) {
67
      if ($part === '') continue;
68
69
      if (str_starts_with($part, '${') && str_ends_with($part, '}')) {
70
        $inner = substr($part, 2, -1);
71
        $output .= '<span class="hl-interp-punct">${</span>';
72
        $output .= '<span class="hl-variable">' . htmlspecialchars($inner) . '</span>';
73
        $output .= '<span class="hl-interp-punct">}</span>';
74
      } elseif (str_starts_with($part, '$') && strlen($part) > 1) {
75
         $output .= '<span class="hl-interp-punct">$</span>';
76
         $output .= '<span class="hl-variable">' . htmlspecialchars(substr($part, 1)) . '</span>';
77
      } else {
78
        $output .= htmlspecialchars($part);
79
      }
80
    }
81
82
    $output .= '</span>';
83
84
    return $output;
85
  }
86
87
  private function renderMath(string $content): string {
88
    $parts = preg_split('/(`[^`]+`)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE);
89
    $output = '';
90
91
    foreach ($parts as $part) {
92
      if ($part === '') continue;
93
94
      if (str_starts_with($part, '`') && str_ends_with($part, '`')) {
95
        $output .= '<span class="hl-function">' . htmlspecialchars($part) . '</span>';
96
      } else {
97
        $output .= '<span class="hl-math">' . htmlspecialchars($part) . '</span>';
98
      }
99
    }
100
101
    return $output;
102
  }
103
104
  private function detectLanguage(string $mediaType, string $filename): string {
105
    $lang = match( $mediaType ) {
106
      'text/x-php', 'application/x-php', 'application/x-httpd-php' => 'php',
107
      'text/html' => 'html',
108
      'text/css' => 'css',
109
      'application/javascript', 'text/javascript', 'text/x-javascript' => 'javascript',
110
      'application/json', 'text/json', 'application/x-json' => 'json',
111
      'application/xml', 'text/xml', 'image/svg+xml' => 'xml',
112
      'text/x-shellscript', 'application/x-sh' => 'bash',
113
      'text/x-c', 'text/x-csrc' => 'c',
114
      'text/x-c++src', 'text/x-c++', 'text/x-cpp' => 'cpp',
115
      'text/x-java', 'text/x-java-source', 'application/java-archive' => 'java',
116
      'text/x-python', 'application/x-python-code' => 'python',
117
      'text/x-ruby', 'application/x-ruby' => 'ruby',
118
      'text/x-go', 'text/go' => 'go',
119
      'text/rust', 'text/x-rust' => 'rust',
120
      'text/x-lua', 'text/lua' => 'lua',
121
      'text/markdown', 'text/x-markdown' => 'markdown',
122
      'text/x-r', 'text/x-r-source', 'application/R' => 'r',
123
      'application/sql', 'text/sql', 'text/x-sql' => 'sql',
124
      'text/yaml', 'text/x-yaml', 'application/yaml' => 'yaml',
125
      'application/typescript', 'text/typescript' => 'typescript',
126
      'text/x-gradle' => 'gradle',
127
      default => null
128
    };
129
130
    if( $lang !== null ) {
131
      return $lang;
132
    }
133
134
    $ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
135
136
    return match( $ext ) {
137
      'php', 'phtml', 'php8', 'php7' => 'php',
138
      'c', 'h' => 'c',
139
      'cpp', 'hpp', 'cc', 'cxx' => 'cpp',
140
      'java' => 'java',
141
      'js', 'jsx', 'mjs' => 'javascript',
142
      'ts', 'tsx' => 'typescript',
143
      'go' => 'go',
144
      'rs' => 'rust',
145
      'py', 'pyw' => 'python',
146
      'rb', 'erb' => 'ruby',
147
      'lua' => 'lua',
148
      'sh', 'bash', 'zsh' => 'bash',
149
      'bat', 'cmd' => 'batch',
150
      'md', 'markdown' => 'markdown',
151
      'rmd' => 'rmd',
152
      'r' => 'r',
153
      'xml', 'svg' => 'xml',
154
      'html', 'htm' => 'html',
155
      'css' => 'css',
156
      'json', 'lock' => 'json',
157
      'sql' => 'sql',
158
      'yaml', 'yml' => 'yaml',
159
      'gradle' => 'gradle',
160
      default => 'text'
161
    };
162
  }
163
}
1164
A render/HtmlFileRenderer.php
1
<?php
2
require_once __DIR__ . '/FileRenderer.php';
3
require_once __DIR__ . '/Highlighter.php';
4
5
class HtmlFileRenderer implements FileRenderer {
6
  private string $repoSafeName;
7
  private string $currentPath;
8
9
  public function __construct( string $repoSafeName, string $currentPath = '' ) {
10
    $this->repoSafeName = $repoSafeName;
11
    $this->currentPath = trim( $currentPath, '/' );
12
  }
13
14
  public function renderListEntry(
15
    string $name,
16
    string $sha,
17
    string $mode,
18
    string $iconClass,
19
    int $timestamp,
20
    int $size
21
  ): void {
22
    $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') .
23
      $name;
24
25
    $url = '?repo=' . urlencode( $this->repoSafeName ) .
26
           '&hash=' . $sha .
27
           '&name=' . urlencode( $fullPath );
28
29
    echo '<a href="' . $url . '" class="file-item">';
30
    echo '<span class="file-mode">' . $mode . '</span>';
31
    echo '<span class="file-name">';
32
    echo '<i class="fas ' . $iconClass . ' file-icon-container"></i>';
33
    echo htmlspecialchars( $name );
34
    echo '</span>';
35
36
    if( $size > 0 ) {
37
      echo '<span class="file-size">' . $this->formatSize( $size ) . '</span>';
38
    }
39
40
    if( $timestamp > 0 ) {
41
      echo '<span class="file-date">';
42
      $this->renderTime( $timestamp );
43
      echo '</span>';
44
    }
45
46
    echo '</a>';
47
  }
48
49
  public function renderMedia(
50
    File $file,
51
    string $url,
52
    string $mediaType
53
  ): bool {
54
    $rendered = false;
55
56
    if( $file->isImage() ) {
57
      echo '<div class="blob-content blob-content-image">' .
58
        '<img src="' . $url . '"></div>';
59
      $rendered = true;
60
    } elseif( $file->isVideo() ) {
61
      echo '<div class="blob-content blob-content-video">' .
62
        '<video controls><source src="' . $url . '" type="' .
63
        $mediaType . '"></video></div>';
64
      $rendered = true;
65
    } elseif( $file->isAudio() ) {
66
      echo '<div class="blob-content blob-content-audio">' .
67
        '<audio controls><source src="' . $url . '" type="' .
68
        $mediaType . '"></audio></div>';
69
      $rendered = true;
70
    }
71
72
    return $rendered;
73
  }
74
75
  public function renderSize( int $bytes ): void {
76
    echo $this->formatSize( $bytes );
77
  }
78
79
  public function highlight(
80
    string $filename,
81
    string $content,
82
    string $mediaType
83
  ): string {
84
    return (new Highlighter($filename, $content, $mediaType))->render();
85
  }
86
87
  public function renderTime( int $timestamp ): void {
88
    $tokens = [
89
      31536000 => 'year',
90
      2592000 => 'month',
91
      604800 => 'week',
92
      86400 => 'day',
93
      3600 => 'hour',
94
      60 => 'minute',
95
      1 => 'second'
96
    ];
97
98
    $diff = $timestamp ? time() - $timestamp : null;
99
    $result = 'never';
100
101
    if( $diff && $diff >= 5 ) {
102
      foreach( $tokens as $unit => $text ) {
103
        if( $diff < $unit ) {
104
          continue;
105
        }
106
107
        $num = floor( $diff / $unit );
108
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
109
        break;
110
      }
111
    } elseif( $diff ) {
112
      $result = 'just now';
113
    }
114
115
    echo $result;
116
  }
117
118
  private function formatSize( int $bytes ): string {
119
    $units = [ 'B', 'KB', 'MB', 'GB', 'TB' ];
120
    $i = 0;
121
122
    while( $bytes >= 1024 && $i < count( $units ) - 1 ) {
123
      $bytes /= 1024;
124
      $i++;
125
    }
126
127
    return ($bytes === 0 ? 0 : round( $bytes )) . ' ' . $units[$i];
128
  }
129
}
1130
A render/LanguageDefinitions.php
1
<?php
2
class LanguageDefinitions {
3
  public static function get(string $lang): array {
4
    $int   = '(-?\b\d+(\.\d+)?\b)';
5
    $str   = '(".*?"|\'.*?\')';
6
    $float = '(-?\d+(\.\d+)?([eE][+-]?\d+)?)';
7
8
    $rules = [
9
      'gradle' => [
10
        'comment'       => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
11
        'string_interp' => '/(".*?"|""".*?""")/',
12
        'string'        => '/(\'.*?\'|\'\'\'.*?\'\'\'|\/.*?\/)/',
13
        'keyword'       => '/\b(def|task|apply|plugin|sourceCompatibility|targetCompatibility|repositories|dependencies|test|group|version|plugins|buildscript|allprojects|subprojects|project|ext|implementation|api|compileOnly|runtimeOnly|testImplementation|testRuntimeOnly|mavenCentral|google|jcenter|classpath)\b/',
14
        'function'      => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\{)/',
15
        'variable'      => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/',
16
        'boolean'       => '/\b(true|false|null)\b/',
17
        'number'        => '/' . $int . '/',
18
      ],
19
      'php' => [
20
        'tag'           => '/(<\?php|<\?|=\?>|\?>)/',
21
        'string_interp' => '/(".*?")/',
22
        'string'        => '/(\'.*?\')/',
23
        'comment'       => '/(\/\/[^\r\n]*|#[^\r\n]*|\/\*.*?\*\/)/ms',
24
        'keyword'       => '/\b(class|abstract|and|array|as|break|callable|case|catch|clone|const|continue|declare|default|die|do|echo|else|elseif|empty|enddeclare|endfor|endforeach|endif|endswitch|endwhile|eval|exit|extends|final|finally|fn|for|foreach|function|global|goto|if|implements|include|include_once|instanceof|insteadof|interface|isset|list|match|namespace|new|or|print|private|protected|public|require|require_once|return|static|switch|throw|trait|try|unset|use|var|while|xor|yield)\b/',
25
        'function'      => '/\b([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)\s*(?=\()/',
26
        'variable'      => '/(\$[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)/',
27
        'number'        => '/' . $int . '/',
28
        'boolean'       => '/\b(true|false|null)\b/i',
29
      ],
30
      'bash' => [
31
        'string_interp' => '/(".*?")/',
32
        'string'        => '/(\'.*?\')/',
33
        'comment'       => '/(#[^\n]*)/',
34
        'keyword'       => '/(?<!-)\b(alias|bg|bind|break|builtin|case|cd|command|compgen|complete|continue|declare|dirs|disown|do|done|echo|elif|else|enable|esac|eval|exec|exit|export|fc|fg|fi|for|function|getopts|hash|help|history|if|jobs|kill|let|local|logout|popd|printf|pushd|pwd|read|readonly|return|set|shift|shopt|source|suspend|test|then|times|trap|type|typeset|ulimit|umask|unalias|unset|until|wait|while)\b/',
35
        'function'      => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
36
        'variable'      => '/(\$[a-zA-Z_][a-zA-Z0-9_]*|\$\{[^}]+\})/',
37
        'number'        => '/' . $int . '/',
38
      ],
39
      'batch' => [
40
        'comment'  => '/((?i:rem)\b[^\n]*|::[^\n]*)/',
41
        'string'   => '/("[^"]*")/',
42
        'keyword'  => '/(?i)\b(if|else|goto|for|in|do|call|exit|echo|pause|set|shift|start|cd|dir|copy|del|md|rd|cls|setlocal|endlocal|enabledelayedexpansion|defined|exist|not|errorlevel|setx|findstr|reg|nul|tokens|usebackq|equ|neq|lss|leq|gtr|geq)\b/',
43
        'variable' => '/(![\w-]+!|%[\w\(\)-]+%|%%[~a-zA-Z]+|%[~a-zA-Z0-9]+)/',
44
        'label'    => '/(^\s*:[a-zA-Z0-9_-]+)/m',
45
        'number'   => '/' . $int . '/',
46
      ],
47
      'c' => [
48
        'string'   => '/' . $str . '/',
49
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
50
        'keyword'  => '/\b(auto|break|case|const|continue|default|do|else|enum|extern|for|goto|if|register|return|signed|sizeof|static|struct|switch|typedef|union|unsigned|void|volatile|while)\b/',
51
        'type'     => '/\b(char|double|float|int|long|short|void)\b/',
52
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
53
        'number'   => '/' . $int . '/',
54
      ],
55
      'cpp' => [
56
        'string'   => '/' . $str . '/',
57
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
58
        'keyword'  => '/\b(alignas|alignof|and|and_eq|asm|auto|bitand|bitor|break|case|catch|class|compl|const|constexpr|const_cast|continue|decltype|default|delete|do|dynamic_cast|else|enum|explicit|export|extern|for|friend|goto|if|inline|mutable|namespace|new|noexcept|not|not_eq|nullptr|operator|or|or_eq|private|protected|public|register|reinterpret_cast|return|sizeof|static|static_assert|static_cast|struct|switch|template|this|thread_local|throw|try|typedef|typeid|typename|union|using|virtual|volatile|while|xor|xor_eq)\b/',
59
        'type'     => '/\b(bool|char|char16_t|char32_t|double|float|int|long|short|signed|unsigned|void|wchar_t)\b/',
60
        'boolean'  => '/\b(true|false)\b/',
61
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
62
        'number'   => '/' . $int . '/',
63
      ],
64
      'java' => [
65
        'string'   => '/' . $str . '/',
66
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
67
        'keyword'  => '/\b(abstract|assert|break|case|catch|class|const|continue|default|do|else|enum|extends|final|finally|for|goto|if|implements|import|instanceof|interface|native|new|package|private|protected|public|return|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|void|volatile|while)\b/',
68
        'type'     => '/\b(boolean|byte|char|double|float|int|long|short|void)\b/',
69
        'boolean'  => '/\b(true|false|null)\b/',
70
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
71
        'number'   => '/' . $int . '/',
72
      ],
73
      'go' => [
74
        'string'   => '/(".*?"|`.*?`)/s',
75
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
76
        'keyword'  => '/\b(break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var)\b/',
77
        'boolean'  => '/\b(true|false|nil|iota)\b/',
78
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
79
        'number'   => '/' . $int . '/',
80
      ],
81
      'rust' => [
82
        'string'   => '/(".*?"|\'.*?\')/',
83
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
84
        'keyword'  => '/\b(as|break|const|continue|crate|else|enum|extern|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|Self|static|struct|super|trait|type|unsafe|use|where|while|async|await|dyn)\b/',
85
        'boolean'  => '/\b(true|false)\b/',
86
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
87
        'number'   => '/' . $int . '/',
88
      ],
89
      'python' => [
90
        'string'   => '/(\'\'\'.*?\'\'\'|""".*?"""|".*?"|\'.*?\')/s',
91
        'comment'  => '/(#[^\r\n]*)/m',
92
        'keyword'  => '/\b(and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield)\b/',
93
        'boolean'  => '/\b(False|None|True)\b/',
94
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
95
        'number'   => '/' . $int . '/',
96
      ],
97
      'ruby' => [
98
        'string_interp' => '/(".*?")/',
99
        'string'        => '/(\'.*?\')/',
100
        'comment'       => '/(#[^\r\n]*)/m',
101
        'keyword'       => '/\b(alias|and|begin|break|case|class|def|defined|do|else|elsif|end|ensure|for|if|in|module|next|not|or|redo|rescue|retry|return|self|super|then|undef|unless|until|when|while|yield)\b/',
102
        'boolean'       => '/\b(true|false|nil)\b/',
103
        'function'      => '/\b([a-zA-Z_][a-zA-Z0-9_]*[?!]?)\s*(?=\()/',
104
        'variable'      => '/(@[a-zA-Z_]\w*|\$[a-zA-Z_]\w*)/',
105
        'number'        => '/' . $int . '/',
106
      ],
107
      'lua' => [
108
        'string'   => '/(".*?"|\'.*?\'|\[\[.*?\]\])/s',
109
        'comment'  => '/(--\[\[.*?\]\]|--[^\r\n]*)/ms',
110
        'keyword'  => '/\b(and|break|do|else|elseif|end|for|function|if|in|local|not|or|repeat|return|then|until|while)\b/',
111
        'boolean'  => '/\b(false|nil|true)\b/',
112
        'function' => '/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/',
113
        'number'   => '/' . $int . '/',
114
      ],
115
      'javascript' => [
116
        'string'   => '/(".*?"|\'.*?\'|`.*?`)/s',
117
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
118
        'keyword'  => '/\b(async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|function|if|import|in|instanceof|new|return|super|switch|this|throw|try|typeof|var|void|while|with|yield|let|static|enum)\b/',
119
        'boolean'  => '/\b(true|false|null|undefined)\b/',
120
        'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/',
121
        'number'   => '/' . $int . '/',
122
      ],
123
      'typescript' => [
124
        'string'   => '/(".*?"|\'.*?\'|`.*?`)/s',
125
        'comment'  => '/(\/\/[^\r\n]*|\/\*.*?\*\/)/ms',
126
        'keyword'  => '/\b(any|as|break|case|catch|class|const|continue|debugger|declare|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|let|module|namespace|new|of|package|private|protected|public|require|return|static|super|switch|this|throw|try|type|typeof|var|void|while|with|yield)\b/',
127
        'type'     => '/\b(boolean|number|string|void|any)\b/',
128
        'boolean'  => '/\b(true|false|null|undefined)\b/',
129
        'function' => '/\b([a-zA-Z_$][a-zA-Z0-9_$]*)\s*(?=\()/',
130
        'number'   => '/' . $int . '/',
131
      ],
132
      'xml' => [
133
        'comment'   => '/()/s',
134
        'string'    => '/' . $str . '/',
135
        'tag'       => '/(<\/?[a-zA-Z0-9:-]+|\s*\/?>|<\?xml|\?>)/',
136
        'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/',
137
      ],
138
      'html' => [
139
        'comment'   => '/()/s',
140
        'string'    => '/' . $str . '/',
141
        'tag'       => '/(<\/?[a-zA-Z0-9:-]+|\s*\/?>)/',
142
        'attribute' => '/([a-zA-Z0-9:-]+)(?=\=)/',
143
      ],
144
      'css' => [
145
        'comment'  => '/(\/\*.*?\*\/)/s',
146
        'tag'      => '/(?<=^|\}|\{)\s*([a-zA-Z0-9_\-#\.\s,>+~]+)(?=\{)/m',
147
        'property' => '/([a-zA-Z-]+)(?=\s*:)/',
148
        'string'   => '/' . $str . '/',
149
        'number'   => '/(-?(\d*\.)?\d+(px|em|rem|%|vh|vw|s|ms|deg))/',
150
      ],
151
      'json' => [
152
        'attribute' => '/(".*?")(?=\s*:)/',
153
        'string'    => '/(".*?")/',
154
        'boolean'   => '/\b(true|false|null)\b/',
155
        'number'    => '/\b(-?\d+(\.\d+)?([eE][+-]?\d+)?)\b/',
156
      ],
157
      'sql' => [
158
        'string'  => '/(\'.*?\')/',
159
        'comment' => '/(--[^\r\n]*|\/\*.*?\*\/)/ms',
160
        'keyword' => '/(?i)\b(SELECT|FROM|WHERE|INSERT|INTO|UPDATE|DELETE|JOIN|LEFT|RIGHT|INNER|OUTER|ON|GROUP|BY|ORDER|HAVING|LIMIT|OFFSET|CREATE|TABLE|DROP|ALTER|INDEX|KEY|PRIMARY|FOREIGN|CONSTRAINT|DEFAULT|NOT|AND|OR|IN|VALUES|SET|AS|DISTINCT|UNION|ALL|CASE|WHEN|THEN|ELSE|END)\b/',
161
        'boolean' => '/(?i)\b(NULL|TRUE|FALSE)\b/',
162
        'number'  => '/' . $int . '/',
163
      ],
164
      'yaml' => [
165
        'string'    => '/' . $str . '/',
166
        'comment'   => '/(#[^\r\n]*)/m',
167
        'attribute' => '/^(\s*[a-zA-Z0-9_-]+:)/m',
168
        'number'    => '/' . $float . '/',
169
      ],
170
      'markdown' => [
171
        'code'     => '/(^(?:    |\t)[^\n]*(?:\n(?:    |\t)[^\n]*)*)/',
172
        'comment'  => '/(```[\s\S]*?```|~~~[\s\S]*?~~~)/',
173
        'math'     => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/',
174
        'keyword'  => '/^(#{1,6})(?=\s)/m',
175
        'string'   => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/',
176
        'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|(?<!_)(_[^\n_]+_)(?!_)/',
177
        'function' => '/(`[^`\n]+`)/',
178
        'variable' => '/(\[[^\]]+\]\([^\)]+\))/',
179
        'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m',
180
      ],
181
      'rmd' => [
182
        'code'     => '/(^(?:    |\t)[^\n]*(?:\n(?:    |\t)[^\n]*)*)/',
183
        'comment'  => '/(```\{r[^\}]*\}[\s\S]*?```)/',
184
        'math'     => '/(\$((?:[^`\n$]|`[^`\n]*`)+)\$)/',
185
        'keyword'  => '/^(#{1,6})(?=\s)/m',
186
        'string'   => '/(\*\*[^\n*]+\*\*|__[^\n_]+__)/',
187
        'attribute' => '/(?<!\*)(\*[^\n*]+\*)(?!\*)|(?<!_)(_[^\n_]+_)(?!_)/',
188
        'function' => '/(`[^`\n]+`)/',
189
        'variable' => '/(\[[^\]]+\]\([^\)]+\))/',
190
        'operator' => '/^(\s*[-*+](?=\s)|\s*\d+\.(?=\s))/m',
191
      ],
192
      'r' => [
193
        'string'   => '/' . $str . '/',
194
        'comment'  => '/(#[^\r\n]*)/m',
195
        'keyword'  => '/\b(if|else|repeat|while|function|for|in|next|break)\b/',
196
        'boolean'  => '/\b(TRUE|FALSE|NULL|Inf|NaN|NA)\b/',
197
        'function' => '/\b([a-zA-Z_.][a-zA-Z0-9_.]*)\s*(?=\()/',
198
        'number'   => '/' . $float . '/',
199
      ]
200
    ];
201
202
    return $rules[strtolower($lang)] ?? [];
203
  }
204
}
1205
M render/TagRenderer.php
1010
  ): void;
1111
12
  public function renderTime(int $timestamp): void;
12
  public function renderTime( int $timestamp ): void;
1313
}
1414
1515
class HtmlTagRenderer implements TagRenderer {
1616
  private string $repoSafeName;
1717
18
  public function __construct(string $repoSafeName) {
18
  public function __construct( string $repoSafeName ) {
1919
    $this->repoSafeName = $repoSafeName;
2020
  }
...
2828
    string $author
2929
  ): void {
30
    $repoParam = '&repo=' . urlencode($this->repoSafeName);
31
    $filesUrl  = '?hash=' . $targetSha . $repoParam;
30
    $repoParam = '&repo=' . urlencode( $this->repoSafeName );
31
    $filesUrl = '?hash=' . $targetSha . $repoParam;
3232
    $commitUrl = '?action=commit&hash=' . $targetSha . $repoParam;
3333
3434
    echo '<tr>';
35
3635
    echo '<td class="tag-name">';
37
    echo   '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' . htmlspecialchars($name) . '</a>';
36
    echo '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' .
37
         htmlspecialchars( $name ) . '</a>';
3838
    echo '</td>';
39
4039
    echo '<td class="tag-message">';
41
    echo ($message !== '') ? htmlspecialchars(strtok($message, "\n")) : '<span style="color: #484f58; font-style: italic;">No description</span>';
42
    echo '</td>';
4340
44
    echo '<td class="tag-author">' . htmlspecialchars($author) . '</td>';
41
    echo ($message !== '') ? htmlspecialchars( strtok( $message, "\n" ) ) :
42
      '<span style="color: #484f58; font-style: italic;">No description</span>';
4543
44
    echo '</td>';
45
    echo '<td class="tag-author">' . htmlspecialchars( $author ) . '</td>';
4646
    echo '<td class="tag-time">';
47
    $this->renderTime($timestamp);
47
    $this->renderTime( $timestamp );
4848
    echo '</td>';
49
5049
    echo '<td class="tag-hash">';
51
    echo   '<a href="' . $commitUrl . '" class="commit-hash">' . substr($sha, 0, 7) . '</a>';
50
    echo '<a href="' . $commitUrl . '" class="commit-hash">' .
51
         substr( $sha, 0, 7 ) . '</a>';
5252
    echo '</td>';
53
5453
    echo '</tr>';
5554
  }
5655
57
  public function renderTime(int $timestamp): void {
58
    if (!$timestamp) { echo 'never'; return; }
56
  public function renderTime( int $timestamp ): void {
57
    if( !$timestamp ) {
58
      echo 'never';
59
      return;
60
    }
61
5962
    $diff = time() - $timestamp;
60
    if ($diff < 5) { echo 'just now'; return; }
63
64
    if( $diff < 5 ) {
65
      echo 'just now';
66
      return;
67
    }
6168
6269
    $tokens = [
6370
      31536000 => 'year',
64
      2592000  => 'month',
65
      604800   => 'week',
66
      86400    => 'day',
67
      3600     => 'hour',
68
      60       => 'minute',
69
      1        => 'second'
71
      2592000 => 'month',
72
      604800 => 'week',
73
      86400 => 'day',
74
      3600 => 'hour',
75
      60 => 'minute',
76
      1 => 'second'
7077
    ];
7178
72
    foreach ($tokens as $unit => $text) {
73
      if ($diff < $unit) continue;
74
      $num = floor($diff / $unit);
75
      echo $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago';
79
    foreach( $tokens as $unit => $text ) {
80
      if( $diff < $unit ) {
81
        continue;
82
      }
83
84
      $num = floor( $diff / $unit );
85
86
      echo $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
7687
      return;
7788
    }
M repo.css
679679
}
680680
681
.blob-code {
682
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
683
  background-color: #161b22;
684
  color: #fcfcfa;
685
}
686
687
.hl-comment,
688
.hl-doc-comment {
689
  color: #727072;
690
  font-style: italic;
691
}
692
693
.hl-keyword,
694
.hl-tag,
695
.hl-storage,
696
.hl-modifier {
697
  color: #ff6188;
698
  font-weight: 600;
699
}
700
701
.hl-function,
702
.hl-method,
703
.hl-class,
704
.hl-type,
705
.hl-label {
706
  color: #a9dc76;
707
}
708
709
.hl-string,
710
.hl-string_interp {
711
  color: #ffd866;
712
}
713
714
.hl-number,
715
.hl-boolean,
716
.hl-constant {
717
  color: #ab9df2;
718
}
719
720
.hl-attribute,
721
.hl-property {
722
  color: #fc9867;
723
}
724
725
.hl-operator,
726
.hl-punctuation,
727
.hl-escape {
728
  color: #78dce8;
729
}
730
731
.hl-variable {
732
  color: #fcfcfa;
733
}
734
735
.hl-interp-punct {
736
  color: #ff6188;
737
}
738
739
.hl-interp-punct {
740
  color: #ff6188;
741
}
742
743
.hl-code {
744
  display: inline-block;
745
  width: 100%;
746
  background-color: #21262d;
747
  color: #c9d1d9;
748
}
749
750
.hl-math {
751
  color: #78dce8;
752
}
681753