Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M File.php
7373
  }
7474
75
  public function isEmpty(): bool {
76
    return $this->size === 0;
77
  }
78
7579
  public function compare( File $other ): int {
7680
    return $this->isDir !== $other->isDir
A git/BufferedFileReader.php
1
<?php
2
class BufferedFileReader {
3
  private mixed $handle;
4
  private bool  $temporary;
5
6
  private function __construct( mixed $handle, bool $temporary ) {
7
    $this->handle    = $handle;
8
    $this->temporary = $temporary;
9
  }
10
11
  public static function open( string $path ): self {
12
    return new self( fopen( $path, 'rb' ), false );
13
  }
14
15
  public static function createTemp(): self {
16
    return new self( tmpfile(), true );
17
  }
18
19
  public function __destruct() {
20
    if( $this->isOpen() ) {
21
      fclose( $this->handle );
22
    }
23
  }
24
25
  public function isOpen(): bool {
26
    return is_resource( $this->handle );
27
  }
28
29
  public function read( int $length ): string {
30
    return $this->isOpen() && !feof( $this->handle )
31
      ? (string)fread( $this->handle, $length )
32
      : '';
33
  }
34
35
  public function write( string $data ): bool {
36
    return $this->temporary &&
37
           $this->isOpen() &&
38
           fwrite( $this->handle, $data ) !== false;
39
  }
40
41
  public function seek( int $offset, int $whence = SEEK_SET ): bool {
42
    return $this->isOpen() &&
43
           fseek( $this->handle, $offset, $whence ) === 0;
44
  }
45
46
  public function tell(): int {
47
    return $this->isOpen()
48
      ? (int)ftell( $this->handle )
49
      : 0;
50
  }
51
52
  public function eof(): bool {
53
    return $this->isOpen() ? feof( $this->handle ) : true;
54
  }
55
56
  public function rewind(): void {
57
    if( $this->isOpen() ) {
58
      rewind( $this->handle );
59
    }
60
  }
61
}
162
A git/CompressionStream.php
1
<?php
2
class CompressionStream {
3
  private Closure $pumper;
4
  private Closure $finisher;
5
  private Closure $status;
6
7
  private function __construct(
8
    Closure $pumper,
9
    Closure $finisher,
10
    Closure $status
11
  ) {
12
    $this->pumper   = $pumper;
13
    $this->finisher = $finisher;
14
    $this->status   = $status;
15
  }
16
17
  public static function createInflater(): self {
18
    $context = inflate_init( ZLIB_ENCODING_DEFLATE );
19
20
    return new self(
21
      function( string $chunk ) use ( $context ): string {
22
        $data = @inflate_add( $context, $chunk );
23
24
        return $data === false ? '' : $data;
25
      },
26
      function(): string {
27
        return '';
28
      },
29
      function() use ( $context ): bool {
30
        return inflate_get_status( $context ) === ZLIB_STREAM_END;
31
      }
32
    );
33
  }
34
35
  public static function createDeflater(): self {
36
    $context = deflate_init( ZLIB_ENCODING_DEFLATE );
37
38
    return new self(
39
      function( string $chunk ) use ( $context ): string {
40
        $data = deflate_add( $context, $chunk, ZLIB_NO_FLUSH );
41
42
        return $data === false ? '' : $data;
43
      },
44
      function() use ( $context ): string {
45
        $data = deflate_add( $context, '', ZLIB_FINISH );
46
47
        return $data === false ? '' : $data;
48
      },
49
      function(): bool {
50
        return false;
51
      }
52
    );
53
  }
54
55
  public function pump( string $chunk ): string {
56
    return $chunk === '' ? '' : ($this->pumper)( $chunk );
57
  }
58
59
  public function finish(): string {
60
    return ($this->finisher)();
61
  }
62
63
  public function finished(): bool {
64
    return ($this->status)();
65
  }
66
}
167
M git/Git.php
44
require_once __DIR__ . '/GitRefs.php';
55
require_once __DIR__ . '/GitPacks.php';
6
7
class Git {
8
  private const MAX_READ = 1048576;
9
10
  private string   $repoPath;
11
  private string   $objPath;
12
  private GitRefs  $refs;
13
  private GitPacks $packs;
14
15
  public function __construct( string $repoPath ) {
16
    $this->setRepository( $repoPath );
17
  }
18
19
  public function setRepository( string $repoPath ): void {
20
    $this->repoPath = rtrim( $repoPath, '/' );
21
    $this->objPath  = $this->repoPath . '/objects';
22
    $this->refs     = new GitRefs( $this->repoPath );
23
    $this->packs    = new GitPacks( $this->objPath );
24
  }
25
26
  public function resolve( string $reference ): string {
27
    return $this->refs->resolve( $reference );
28
  }
29
30
  public function getMainBranch(): array {
31
    return $this->refs->getMainBranch();
32
  }
33
34
  public function eachBranch( callable $callback ): void {
35
    $this->refs->scanRefs( 'refs/heads', $callback );
36
  }
37
38
  public function eachTag( callable $callback ): void {
39
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
40
      $callback
41
    ) {
42
      $data = $this->read( $sha );
43
      $tag  = $this->parseTagData( $name, $sha, $data );
44
45
      $callback( $tag );
46
    } );
47
  }
48
49
  public function walk(
50
    string $refOrSha,
51
    callable $callback,
52
    string $path = ''
53
  ): void {
54
    $sha     = $this->resolve( $refOrSha );
55
    $treeSha = '';
56
57
    if( $sha !== '' ) {
58
      $treeSha = $this->getTreeSha( $sha );
59
    }
60
61
    if( $path !== '' && $treeSha !== '' ) {
62
      $info    = $this->resolvePath( $treeSha, $path );
63
      $treeSha = $info['isDir'] ? $info['sha'] : '';
64
    }
65
66
    if( $treeSha !== '' ) {
67
      $this->walkTree( $treeSha, $callback );
68
    }
69
  }
70
71
  public function readFile( string $ref, string $path ): File {
72
    $sha  = $this->resolve( $ref );
73
    $tree = $sha !== '' ? $this->getTreeSha( $sha ) : '';
74
    $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : [];
75
    $file = new MissingFile();
76
77
    if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) {
78
      $file = new File(
79
        basename( $path ),
80
        $info['sha'],
81
        $info['mode'],
82
        0,
83
        $this->getObjectSize( $info['sha'] ),
84
        $this->peek( $info['sha'] )
85
      );
86
    }
87
88
    return $file;
89
  }
90
91
  public function getObjectSize( string $sha, string $path = '' ): int {
92
    $target = $sha;
93
    $result = 0;
94
95
    if( $path !== '' ) {
96
      $info   = $this->resolvePath(
97
        $this->getTreeSha( $this->resolve( $sha ) ),
98
        $path
99
      );
100
      $target = $info['sha'] ?? '';
101
    }
102
103
    if( $target !== '' ) {
104
      $result = $this->packs->getSize( $target );
105
106
      if( $result === 0 ) {
107
        $result = $this->getLooseObjectSize( $target );
108
      }
109
    }
110
111
    return $result;
112
  }
113
114
  public function stream(
115
    string $sha,
116
    callable $callback,
117
    string $path = ''
118
  ): void {
119
    $target = $sha;
120
121
    if( $path !== '' ) {
122
      $info   = $this->resolvePath(
123
        $this->getTreeSha( $this->resolve( $sha ) ),
124
        $path
125
      );
126
      $target = isset( $info['isDir'] ) && !$info['isDir']
127
        ? $info['sha']
128
        : '';
129
    }
130
131
    if( $target !== '' ) {
132
      $this->slurp( $target, $callback );
133
    }
134
  }
135
136
  public function peek( string $sha, int $length = 255 ): string {
137
    $size = $this->packs->getSize( $sha );
138
139
    return $size === 0
140
      ? $this->peekLooseObject( $sha, $length )
141
      : $this->packs->peek( $sha, $length );
142
  }
143
144
  public function read( string $sha ): string {
145
    $size    = $this->getObjectSize( $sha );
146
    $content = '';
147
148
    if( $size > 0 && $size <= self::MAX_READ ) {
149
      $this->slurp( $sha, function( $chunk ) use ( &$content ) {
150
        $content .= $chunk;
151
      } );
152
    }
153
154
    return $content;
155
  }
156
157
  public function history(
158
    string $ref,
159
    int $limit,
160
    callable $callback
161
  ): void {
162
    $sha   = $this->resolve( $ref );
163
    $count = 0;
164
165
    while( $sha !== '' && $count < $limit ) {
166
      $commit = $this->parseCommit( $sha );
167
168
      if( $commit->sha === '' ) {
169
        $sha = '';
170
      }
171
172
      if( $sha !== '' ) {
173
        $callback( $commit );
174
        $sha = $commit->parentSha;
175
        $count++;
176
      }
177
    }
178
  }
179
180
  public function streamRaw( string $subPath ): bool {
181
    $result = false;
182
183
    if( strpos( $subPath, '..' ) === false ) {
184
      $path = "{$this->repoPath}/$subPath";
185
186
      if( is_file( $path ) ) {
187
        $real = realpath( $path );
188
        $repo = realpath( $this->repoPath );
189
190
        if( $real && strpos( $real, $repo ) === 0 ) {
191
          $result = $this->streamFileContent( $path );
192
        }
193
      }
194
    }
195
196
    return $result;
197
  }
198
199
  private function streamFileContent( string $path ): bool {
200
    $result = false;
201
202
    if( $path !== '' ) {
203
      header( 'X-Accel-Redirect: ' . $path );
204
      header( 'Content-Type: application/octet-stream' );
205
206
      $result = true;
207
    }
208
209
    return $result;
210
  }
211
212
  public function eachRef( callable $callback ): void {
213
    $head = $this->resolve( 'HEAD' );
214
215
    if( $head !== '' ) {
216
      $callback( 'HEAD', $head );
217
    }
218
219
    $this->refs->scanRefs( 'refs/heads', function( $n, $s ) use ( $callback ) {
220
      $callback( "refs/heads/$n", $s );
221
    } );
222
223
    $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) {
224
      $callback( "refs/tags/$n", $s );
225
    } );
226
  }
227
228
  public function generatePackfile( array $objs ): Generator {
229
    $ctx  = hash_init( 'sha1' );
230
    $head = "PACK" . pack( 'N', 2 ) . pack( 'N', count( $objs ) );
231
232
    hash_update( $ctx, $head );
233
    yield $head;
234
235
    foreach( $objs as $sha => $type ) {
236
      $size = $this->getObjectSize( $sha );
237
      $byte = $type << 4 | $size & 0x0f;
238
      $sz   = $size >> 4;
239
      $hdr  = '';
240
241
      while( $sz > 0 ) {
242
        $hdr .= chr( $byte | 0x80 );
243
        $byte = $sz & 0x7f;
244
        $sz >>= 7;
245
      }
246
247
      $hdr .= chr( $byte );
248
      hash_update( $ctx, $hdr );
249
      yield $hdr;
250
251
      $deflate = deflate_init( ZLIB_ENCODING_DEFLATE );
252
253
      foreach( $this->slurpChunks( $sha ) as $raw ) {
254
        $compressed = deflate_add( $deflate, $raw, ZLIB_NO_FLUSH );
255
256
        if( $compressed !== '' ) {
257
          hash_update( $ctx, $compressed );
258
          yield $compressed;
259
        }
260
      }
261
262
      $final = deflate_add( $deflate, '', ZLIB_FINISH );
263
264
      if( $final !== '' ) {
265
        hash_update( $ctx, $final );
266
        yield $final;
267
      }
268
    }
269
270
    yield hash_final( $ctx, true );
271
  }
272
273
  private function slurpChunks( string $sha ): Generator {
274
    $path = $this->getLoosePath( $sha );
275
276
    if( is_file( $path ) ) {
277
      yield from $this->looseObjectChunks( $path );
278
    } else {
279
      $any = false;
280
281
      foreach( $this->packs->streamGenerator( $sha ) as $chunk ) {
282
        $any = true;
283
        yield $chunk;
284
      }
285
286
      if( !$any ) {
287
        $data = $this->packs->read( $sha );
288
289
        if( $data !== '' ) {
290
          yield $data;
291
        }
292
      }
293
    }
294
  }
295
296
  private function looseObjectChunks( string $path ): Generator {
297
    $handle = fopen( $path, 'rb' );
298
    $infl   = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
299
300
    if( !$handle || !$infl ) {
301
      return;
302
    }
303
304
    $found  = false;
305
    $buffer = '';
306
307
    while( !feof( $handle ) ) {
308
      $chunk    = fread( $handle, 16384 );
309
      $inflated = inflate_add( $infl, $chunk );
310
311
      if( $inflated === false ) {
312
        break;
313
      }
314
315
      if( !$found ) {
316
        $buffer .= $inflated;
317
        $eos     = strpos( $buffer, "\0" );
318
319
        if( $eos !== false ) {
320
          $found = true;
321
          $body  = substr( $buffer, $eos + 1 );
322
323
          if( $body !== '' ) {
324
            yield $body;
325
          }
326
327
          $buffer = '';
328
        }
329
      } elseif( $inflated !== '' ) {
330
        yield $inflated;
331
      }
332
    }
333
334
    fclose( $handle );
335
  }
336
337
  private function streamCompressedObject( string $sha, $ctx ): Generator {
338
    $deflate = deflate_init( ZLIB_ENCODING_DEFLATE );
339
    $buffer  = '';
340
341
    $this->slurp( $sha, function( $chunk ) use (
342
      $deflate,
343
      $ctx,
344
      &$buffer
345
    ) {
346
      $compressed = deflate_add( $deflate, $chunk, ZLIB_NO_FLUSH );
347
348
      if( $compressed !== '' ) {
349
        hash_update( $ctx, $compressed );
350
        $buffer .= $compressed;
351
      }
352
    } );
353
354
    $final = deflate_add( $deflate, '', ZLIB_FINISH );
355
356
    if( $final !== '' ) {
357
      hash_update( $ctx, $final );
358
      $buffer .= $final;
359
    }
360
361
    $pos = 0;
362
    $len = strlen( $buffer );
363
364
    while( $pos < $len ) {
365
      $chunk = substr( $buffer, $pos, 32768 );
366
      yield $chunk;
367
      $pos  += 32768;
368
    }
369
  }
370
371
  private function getTreeSha( string $commitOrTreeSha ): string {
372
    $data = $this->read( $commitOrTreeSha );
373
    $sha  = $commitOrTreeSha;
374
375
    if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) {
376
      $sha = $this->getTreeSha( $matches[1] );
377
    }
378
379
    if( $sha === $commitOrTreeSha &&
380
        preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
381
      $sha = $matches[1];
382
    }
383
384
    return $sha;
385
  }
386
387
  private function resolvePath( string $treeSha, string $path ): array {
388
    $parts = explode( '/', trim( $path, '/' ) );
389
    $sha   = $treeSha;
390
    $mode  = '40000';
391
392
    foreach( $parts as $part ) {
393
      $entry = [ 'sha' => '', 'mode' => '' ];
394
395
      if( $part !== '' && $sha !== '' ) {
396
        $entry = $this->findTreeEntry( $sha, $part );
397
      }
398
399
      $sha   = $entry['sha'];
400
      $mode  = $entry['mode'];
401
    }
402
403
    return [
404
      'sha'   => $sha,
405
      'mode'  => $mode,
406
      'isDir' => $mode === '40000' || $mode === '040000'
407
    ];
408
  }
409
410
  private function findTreeEntry( string $treeSha, string $name ): array {
411
    $data  = $this->read( $treeSha );
412
    $pos   = 0;
413
    $len   = strlen( $data );
414
    $entry = [ 'sha' => '', 'mode' => '' ];
415
416
    while( $pos < $len ) {
417
      $space = strpos( $data, ' ', $pos );
418
      $eos   = strpos( $data, "\0", $space );
419
420
      if( $space === false || $eos === false ) {
421
        break;
422
      }
423
424
      if( substr( $data, $space + 1, $eos - $space - 1 ) === $name ) {
425
        $entry = [
426
          'sha'  => bin2hex( substr( $data, $eos + 1, 20 ) ),
427
          'mode' => substr( $data, $pos, $space - $pos )
428
        ];
429
        break;
430
      }
431
432
      $pos = $eos + 21;
433
    }
434
435
    return $entry;
436
  }
437
438
  private function parseTagData(
439
    string $name,
440
    string $sha,
441
    string $data
442
  ): Tag {
443
    $isAnn   = strncmp( $data, 'object ', 7 ) === 0;
444
    $pattern = $isAnn
445
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
446
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
447
    $id      = $this->parseIdentity( $data, $pattern );
448
    $target  = $isAnn
449
      ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha )
450
      : $sha;
451
452
    return new Tag(
453
      $name,
454
      $sha,
455
      $target,
456
      $id['timestamp'],
457
      $this->extractMessage( $data ),
458
      $id['name']
459
    );
460
  }
461
462
  private function extractPattern(
463
    string $data,
464
    string $pattern,
465
    int $group,
466
    string $default = ''
467
  ): string {
468
    return preg_match( $pattern, $data, $matches )
469
      ? $matches[$group]
470
      : $default;
471
  }
472
473
  private function parseIdentity( string $data, string $pattern ): array {
474
    $found = preg_match( $pattern, $data, $matches );
475
476
    return [
477
      'name'      => $found ? trim( $matches[1] ) : 'Unknown',
478
      'email'     => $found ? $matches[2] : '',
479
      'timestamp' => $found ? (int)$matches[3] : 0
480
    ];
481
  }
482
483
  private function extractMessage( string $data ): string {
484
    $pos = strpos( $data, "\n\n" );
485
486
    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
487
  }
488
489
  private function slurp( string $sha, callable $callback ): void {
490
    $path = $this->getLoosePath( $sha );
491
492
    if( is_file( $path ) ) {
493
      $this->slurpLooseObject( $path, $callback );
494
    } else {
495
      $this->slurpPackedObject( $sha, $callback );
496
    }
497
  }
498
499
  private function slurpLooseObject( string $path, callable $callback ): void {
500
    $this->iterateInflated(
501
      $path,
502
      function( $chunk ) use ( $callback ) {
503
        if( $chunk !== '' ) {
504
          $callback( $chunk );
505
        }
506
        return true;
507
      }
508
    );
509
  }
510
511
  private function slurpPackedObject( string $sha, callable $callback ): void {
512
    $streamed = $this->packs->stream( $sha, $callback );
513
514
    if( !$streamed ) {
515
      $data = $this->packs->read( $sha );
516
517
      if( $data !== '' ) {
518
        $callback( $data );
519
      }
520
    }
521
  }
522
523
  private function iterateInflated(
524
    string $path,
525
    callable $processor
526
  ): void {
527
    $handle = fopen( $path, 'rb' );
528
    $infl   = $handle ? inflate_init( ZLIB_ENCODING_DEFLATE ) : null;
529
    $found  = false;
530
    $buffer = '';
531
532
    if( $handle && $infl ) {
533
      while( !feof( $handle ) ) {
534
        $chunk    = fread( $handle, 16384 );
535
        $inflated = inflate_add( $infl, $chunk );
536
537
        if( $inflated === false ) {
538
          break;
539
        }
540
541
        if( !$found ) {
542
          $buffer .= $inflated;
543
          $eos     = strpos( $buffer, "\0" );
544
545
          if( $eos !== false ) {
546
            $found = true;
547
            $body  = substr( $buffer, $eos + 1 );
548
            $head  = substr( $buffer, 0, $eos );
549
550
            if( $processor( $body, $head ) === false ) {
551
              break;
552
            }
553
          }
554
        } elseif( $processor( $inflated, null ) === false ) {
555
          break;
556
        }
557
      }
558
559
      fclose( $handle );
560
    }
561
  }
562
563
  private function peekLooseObject( string $sha, int $length ): string {
564
    $path = $this->getLoosePath( $sha );
565
    $buf  = '';
566
567
    if( is_file( $path ) ) {
568
      $this->iterateInflated(
569
        $path,
570
        function( $chunk ) use ( $length, &$buf ) {
571
          $buf .= $chunk;
572
          return strlen( $buf ) < $length;
573
        }
574
      );
575
    }
576
577
    return substr( $buf, 0, $length );
578
  }
579
580
  private function parseCommit( string $sha ): object {
581
    $data   = $this->read( $sha );
582
    $result = (object)[ 'sha' => '' ];
583
584
    if( $data !== '' ) {
585
      $id = $this->parseIdentity(
586
        $data,
587
        '/^author (.*) <(.*)> (\d+)/m'
588
      );
589
590
      $result = (object)[
591
        'sha'       => $sha,
592
        'message'   => $this->extractMessage( $data ),
593
        'author'    => $id['name'],
594
        'email'     => $id['email'],
595
        'date'      => $id['timestamp'],
596
        'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 )
597
      ];
598
    }
599
600
    return $result;
601
  }
602
603
  private function walkTree( string $sha, callable $callback ): void {
604
    $data = $this->read( $sha );
605
    $tree = $data;
606
607
    if( $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) ) {
608
      $tree = $this->read( $m[1] );
609
    }
610
611
    if( $tree !== '' && $this->isTreeData( $tree ) ) {
612
      $this->processTree( $tree, $callback );
613
    }
614
  }
615
616
  private function processTree( string $data, callable $callback ): void {
617
    $pos = 0;
618
    $len = strlen( $data );
619
620
    while( $pos < $len ) {
621
      $space = strpos( $data, ' ', $pos );
622
      $eos   = strpos( $data, "\0", $space );
623
      $entry = null;
624
625
      if( $space !== false && $eos !== false && $eos + 21 <= $len ) {
626
        $mode  = substr( $data, $pos, $space - $pos );
627
        $sha   = bin2hex( substr( $data, $eos + 1, 20 ) );
628
        $dir   = $mode === '40000' || $mode === '040000';
629
        $isSub = $mode === '160000';
630
631
        $entry = [
632
          'file' => new File(
633
            substr( $data, $space + 1, $eos - $space - 1 ),
634
            $sha,
635
            $mode,
636
            0,
637
            $dir || $isSub ? 0 : $this->getObjectSize( $sha ),
638
            $dir || $isSub ? '' : $this->peek( $sha )
639
          ),
640
          'nextPosition' => $eos + 21
641
        ];
642
      }
643
644
      if( $entry === null ) {
645
        break;
646
      }
647
648
      $callback( $entry['file'] );
649
      $pos = $entry['nextPosition'];
650
    }
651
  }
652
653
  private function isTreeData( string $data ): bool {
654
    $len   = strlen( $data );
655
    $patt  = '/^(40000|100644|100755|120000|160000) /';
656
    $match = $len >= 25 && preg_match( $patt, $data );
657
    $eos   = $match ? strpos( $data, "\0" ) : false;
658
659
    return $match && $eos !== false && $eos + 21 <= $len;
660
  }
661
662
  private function getLoosePath( string $sha ): string {
663
    return "{$this->objPath}/" . substr( $sha, 0, 2 ) . "/" .
664
      substr( $sha, 2 );
665
  }
666
667
  private function getLooseObjectSize( string $sha ): int {
668
    $path = $this->getLoosePath( $sha );
669
    $size = 0;
670
671
    if( is_file( $path ) ) {
672
      $this->iterateInflated(
673
        $path,
674
        function( $c, $head ) use ( &$size ) {
675
          if( $head !== null ) {
676
            $parts = explode( ' ', $head );
677
            $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
678
          }
6
require_once __DIR__ . '/BufferedFileReader.php';
7
8
class Git {
9
  private const MAX_READ = 1048576;
10
11
  private string   $repoPath;
12
  private string   $objPath;
13
  private GitRefs  $refs;
14
  private GitPacks $packs;
15
16
  public function __construct( string $repoPath ) {
17
    $this->setRepository( $repoPath );
18
  }
19
20
  public function setRepository( string $repoPath ): void {
21
    $this->repoPath = rtrim( $repoPath, '/' );
22
    $this->objPath  = $this->repoPath . '/objects';
23
    $this->refs     = new GitRefs( $this->repoPath );
24
    $this->packs    = new GitPacks( $this->objPath );
25
  }
26
27
  public function resolve( string $reference ): string {
28
    return $this->refs->resolve( $reference );
29
  }
30
31
  public function getMainBranch(): array {
32
    return $this->refs->getMainBranch();
33
  }
34
35
  public function eachBranch( callable $callback ): void {
36
    $this->refs->scanRefs( 'refs/heads', $callback );
37
  }
38
39
  public function eachTag( callable $callback ): void {
40
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use (
41
      $callback
42
    ) {
43
      $data = $this->read( $sha );
44
      $tag  = $this->parseTagData( $name, $sha, $data );
45
46
      $callback( $tag );
47
    } );
48
  }
49
50
  public function walk(
51
    string $refOrSha,
52
    callable $callback,
53
    string $path = ''
54
  ): void {
55
    $sha     = $this->resolve( $refOrSha );
56
    $treeSha = '';
57
58
    if( $sha !== '' ) {
59
      $treeSha = $this->getTreeSha( $sha );
60
    }
61
62
    if( $path !== '' && $treeSha !== '' ) {
63
      $info    = $this->resolvePath( $treeSha, $path );
64
      $treeSha = $info['isDir'] ? $info['sha'] : '';
65
    }
66
67
    if( $treeSha !== '' ) {
68
      $this->walkTree( $treeSha, $callback );
69
    }
70
  }
71
72
  public function readFile( string $ref, string $path ): File {
73
    $sha  = $this->resolve( $ref );
74
    $tree = $sha !== '' ? $this->getTreeSha( $sha ) : '';
75
    $info = $tree !== '' ? $this->resolvePath( $tree, $path ) : [];
76
    $file = new MissingFile();
77
78
    if( isset( $info['sha'] ) && !$info['isDir'] && $info['sha'] !== '' ) {
79
      $file = new File(
80
        basename( $path ),
81
        $info['sha'],
82
        $info['mode'],
83
        0,
84
        $this->getObjectSize( $info['sha'] ),
85
        $this->peek( $info['sha'] )
86
      );
87
    }
88
89
    return $file;
90
  }
91
92
  public function getObjectSize( string $sha, string $path = '' ): int {
93
    $target = $sha;
94
    $result = 0;
95
96
    if( $path !== '' ) {
97
      $info   = $this->resolvePath(
98
        $this->getTreeSha( $this->resolve( $sha ) ),
99
        $path
100
      );
101
      $target = $info['sha'] ?? '';
102
    }
103
104
    if( $target !== '' ) {
105
      $result = $this->packs->getSize( $target );
106
107
      if( $result === 0 ) {
108
        $result = $this->getLooseObjectSize( $target );
109
      }
110
    }
111
112
    return $result;
113
  }
114
115
  public function stream(
116
    string $sha,
117
    callable $callback,
118
    string $path = ''
119
  ): void {
120
    $target = $sha;
121
122
    if( $path !== '' ) {
123
      $info   = $this->resolvePath(
124
        $this->getTreeSha( $this->resolve( $sha ) ),
125
        $path
126
      );
127
      $target = isset( $info['isDir'] ) && !$info['isDir']
128
        ? $info['sha']
129
        : '';
130
    }
131
132
    if( $target !== '' ) {
133
      $this->slurp( $target, $callback );
134
    }
135
  }
136
137
  public function peek( string $sha, int $length = 255 ): string {
138
    $size = $this->packs->getSize( $sha );
139
140
    return $size === 0
141
      ? $this->peekLooseObject( $sha, $length )
142
      : $this->packs->peek( $sha, $length );
143
  }
144
145
  public function read( string $sha ): string {
146
    $size    = $this->getObjectSize( $sha );
147
    $content = '';
148
149
    if( $size > 0 && $size <= self::MAX_READ ) {
150
      $this->slurp( $sha, function( $chunk ) use ( &$content ) {
151
        $content .= $chunk;
152
      } );
153
    }
154
155
    return $content;
156
  }
157
158
  public function history(
159
    string $ref,
160
    int $limit,
161
    callable $callback
162
  ): void {
163
    $sha   = $this->resolve( $ref );
164
    $count = 0;
165
    $done  = false;
166
167
    while( !$done && $sha !== '' && $count < $limit ) {
168
      $commit = $this->parseCommit( $sha );
169
170
      if( $commit->sha === '' ) {
171
        $sha  = '';
172
        $done = true;
173
      }
174
175
      if( !$done && $sha !== '' ) {
176
        if( $callback( $commit ) === false ) {
177
          $done = true;
178
        }
179
180
        if( !$done ) {
181
          $sha   = $commit->parentSha;
182
          $count++;
183
        }
184
      }
185
    }
186
  }
187
188
  public function streamRaw( string $subPath ): bool {
189
    $result = false;
190
191
    if( strpos( $subPath, '..' ) === false ) {
192
      $path = "{$this->repoPath}/$subPath";
193
194
      if( is_file( $path ) ) {
195
        $real = realpath( $path );
196
        $repo = realpath( $this->repoPath );
197
198
        if( $real !== false && strpos( $real, $repo ) === 0 ) {
199
          $result = $this->streamFileContent( $path );
200
        }
201
      }
202
    }
203
204
    return $result;
205
  }
206
207
  private function streamFileContent( string $path ): bool {
208
    $result = false;
209
210
    if( $path !== '' ) {
211
      header( 'X-Accel-Redirect: ' . $path );
212
      header( 'Content-Type: application/octet-stream' );
213
214
      $result = true;
215
    }
216
217
    return $result;
218
  }
219
220
  public function eachRef( callable $callback ): void {
221
    $head = $this->resolve( 'HEAD' );
222
223
    if( $head !== '' ) {
224
      $callback( 'HEAD', $head );
225
    }
226
227
    $this->refs->scanRefs( 'refs/heads', function( $n, $s ) use ( $callback ) {
228
      $callback( "refs/heads/$n", $s );
229
    } );
230
231
    $this->refs->scanRefs( 'refs/tags', function( $n, $s ) use ( $callback ) {
232
      $callback( "refs/tags/$n", $s );
233
    } );
234
  }
235
236
  public function generatePackfile( array $objs ): Generator {
237
    $ctx  = hash_init( 'sha1' );
238
    $head = "PACK" . pack( 'N', 2 ) . pack( 'N', count( $objs ) );
239
240
    hash_update( $ctx, $head );
241
    yield $head;
242
243
    foreach( $objs as $sha => $type ) {
244
      $size = $this->getObjectSize( $sha );
245
      $byte = $type << 4 | $size & 0x0f;
246
      $sz   = $size >> 4;
247
      $hdr  = '';
248
249
      while( $sz > 0 ) {
250
        $hdr  .= chr( $byte | 0x80 );
251
        $byte  = $sz & 0x7f;
252
        $sz  >>= 7;
253
      }
254
255
      $hdr .= chr( $byte );
256
      hash_update( $ctx, $hdr );
257
      yield $hdr;
258
259
      $deflate = deflate_init( ZLIB_ENCODING_DEFLATE );
260
261
      foreach( $this->slurpChunks( $sha ) as $raw ) {
262
        $compressed = deflate_add( $deflate, $raw, ZLIB_NO_FLUSH );
263
264
        if( $compressed !== '' ) {
265
          hash_update( $ctx, $compressed );
266
          yield $compressed;
267
        }
268
      }
269
270
      $final = deflate_add( $deflate, '', ZLIB_FINISH );
271
272
      if( $final !== '' ) {
273
        hash_update( $ctx, $final );
274
        yield $final;
275
      }
276
    }
277
278
    yield hash_final( $ctx, true );
279
  }
280
281
  private function slurpChunks( string $sha ): Generator {
282
    $path = $this->getLoosePath( $sha );
283
284
    if( is_file( $path ) ) {
285
      yield from $this->looseObjectChunks( $path );
286
    } else {
287
      $any = false;
288
289
      foreach( $this->packs->streamGenerator( $sha ) as $chunk ) {
290
        $any = true;
291
        yield $chunk;
292
      }
293
294
      if( !$any ) {
295
        $data = $this->packs->read( $sha );
296
297
        if( $data !== '' ) {
298
          yield $data;
299
        }
300
      }
301
    }
302
  }
303
304
  private function looseObjectChunks( string $path ): Generator {
305
    $reader = BufferedFileReader::open( $path );
306
    $infl   = $reader->isOpen()
307
      ? inflate_init( ZLIB_ENCODING_DEFLATE )
308
      : false;
309
310
    if( $reader->isOpen() && $infl !== false ) {
311
      $found  = false;
312
      $buffer = '';
313
314
      while( !$reader->eof() ) {
315
        $chunk    = $reader->read( 16384 );
316
        $inflated = inflate_add( $infl, $chunk );
317
318
        if( $inflated === false ) {
319
          break;
320
        }
321
322
        if( !$found ) {
323
          $buffer .= $inflated;
324
          $eos     = strpos( $buffer, "\0" );
325
326
          if( $eos !== false ) {
327
            $found  = true;
328
            $body   = substr( $buffer, $eos + 1 );
329
330
            if( $body !== '' ) {
331
              yield $body;
332
            }
333
334
            $buffer = '';
335
          }
336
        } elseif( $inflated !== '' ) {
337
          yield $inflated;
338
        }
339
      }
340
    }
341
  }
342
343
  private function streamCompressedObject( string $sha, $ctx ): Generator {
344
    $stream = CompressionStream::createDeflater();
345
    $buffer = '';
346
347
    $this->slurp( $sha, function( $chunk ) use (
348
      $stream,
349
      $ctx,
350
      &$buffer
351
    ) {
352
      $compressed = $stream->pump( $chunk );
353
354
      if( $compressed !== '' ) {
355
        hash_update( $ctx, $compressed );
356
        $buffer .= $compressed;
357
      }
358
    } );
359
360
    $final = $stream->finish();
361
362
    if( $final !== '' ) {
363
      hash_update( $ctx, $final );
364
      $buffer .= $final;
365
    }
366
367
    $pos = 0;
368
    $len = strlen( $buffer );
369
370
    while( $pos < $len ) {
371
      $chunk = substr( $buffer, $pos, 32768 );
372
373
      yield $chunk;
374
      $pos += 32768;
375
    }
376
  }
377
378
  private function getTreeSha( string $commitOrTreeSha ): string {
379
    $data = $this->read( $commitOrTreeSha );
380
    $sha  = $commitOrTreeSha;
381
382
    if( preg_match( '/^object ([0-9a-f]{40})/m', $data, $matches ) ) {
383
      $sha = $this->getTreeSha( $matches[1] );
384
    }
385
386
    if( $sha === $commitOrTreeSha &&
387
        preg_match( '/^tree ([0-9a-f]{40})/m', $data, $matches ) ) {
388
      $sha = $matches[1];
389
    }
390
391
    return $sha;
392
  }
393
394
  private function resolvePath( string $treeSha, string $path ): array {
395
    $parts = explode( '/', trim( $path, '/' ) );
396
    $sha   = $treeSha;
397
    $mode  = '40000';
398
399
    foreach( $parts as $part ) {
400
      $entry = [ 'sha' => '', 'mode' => '' ];
401
402
      if( $part !== '' && $sha !== '' ) {
403
        $entry = $this->findTreeEntry( $sha, $part );
404
      }
405
406
      $sha  = $entry['sha'];
407
      $mode = $entry['mode'];
408
    }
409
410
    return [
411
      'sha'   => $sha,
412
      'mode'  => $mode,
413
      'isDir' => $mode === '40000' || $mode === '040000'
414
    ];
415
  }
416
417
  private function findTreeEntry( string $treeSha, string $name ): array {
418
    $data  = $this->read( $treeSha );
419
    $entry = [ 'sha' => '', 'mode' => '' ];
420
421
    $this->parseTreeData(
422
      $data,
423
      function( $file, $n, $sha, $mode ) use ( $name, &$entry ) {
424
        if( $file->isName( $name ) ) {
425
          $entry = [ 'sha' => $sha, 'mode' => $mode ];
426
427
          return false;
428
        }
429
      }
430
    );
431
432
    return $entry;
433
  }
434
435
  private function parseTagData(
436
    string $name,
437
    string $sha,
438
    string $data
439
  ): Tag {
440
    $isAnn   = strncmp( $data, 'object ', 7 ) === 0;
441
    $pattern = $isAnn
442
      ? '/^tagger (.*) <(.*)> (\d+) [+\-]\d{4}$/m'
443
      : '/^author (.*) <(.*)> (\d+) [+\-]\d{4}$/m';
444
    $id      = $this->parseIdentity( $data, $pattern );
445
    $target  = $isAnn
446
      ? $this->extractPattern( $data, '/^object (.*)$/m', 1, $sha )
447
      : $sha;
448
449
    return new Tag(
450
      $name,
451
      $sha,
452
      $target,
453
      $id['timestamp'],
454
      $this->extractMessage( $data ),
455
      $id['name']
456
    );
457
  }
458
459
  private function extractPattern(
460
    string $data,
461
    string $pattern,
462
    int $group,
463
    string $default = ''
464
  ): string {
465
    return preg_match( $pattern, $data, $matches )
466
      ? $matches[$group]
467
      : $default;
468
  }
469
470
  private function parseIdentity( string $data, string $pattern ): array {
471
    $found = preg_match( $pattern, $data, $matches );
472
473
    return [
474
      'name'      => $found ? trim( $matches[1] ) : 'Unknown',
475
      'email'     => $found ? $matches[2] : '',
476
      'timestamp' => $found ? (int)$matches[3] : 0
477
    ];
478
  }
479
480
  private function extractMessage( string $data ): string {
481
    $pos = strpos( $data, "\n\n" );
482
483
    return $pos !== false ? trim( substr( $data, $pos + 2 ) ) : '';
484
  }
485
486
  private function slurp( string $sha, callable $callback ): void {
487
    $path = $this->getLoosePath( $sha );
488
489
    if( is_file( $path ) ) {
490
      $this->slurpLooseObject( $path, $callback );
491
    } else {
492
      $this->slurpPackedObject( $sha, $callback );
493
    }
494
  }
495
496
  private function slurpLooseObject( string $path, callable $callback ): void {
497
    $this->iterateInflated(
498
      $path,
499
      function( $chunk ) use ( $callback ) {
500
        if( $chunk !== '' ) {
501
          $callback( $chunk );
502
        }
503
504
        return true;
505
      }
506
    );
507
  }
508
509
  private function slurpPackedObject( string $sha, callable $callback ): void {
510
    $streamed = $this->packs->stream( $sha, $callback );
511
512
    if( !$streamed ) {
513
      $data = $this->packs->read( $sha );
514
515
      if( $data !== '' ) {
516
        $callback( $data );
517
      }
518
    }
519
  }
520
521
  private function iterateInflated(
522
    string $path,
523
    callable $processor
524
  ): void {
525
    $reader = BufferedFileReader::open( $path );
526
    $infl   = $reader->isOpen()
527
      ? inflate_init( ZLIB_ENCODING_DEFLATE )
528
      : false;
529
    $found  = false;
530
    $buffer = '';
531
532
    if( $reader->isOpen() && $infl !== false ) {
533
      while( !$reader->eof() ) {
534
        $chunk    = $reader->read( 16384 );
535
        $inflated = inflate_add( $infl, $chunk );
536
537
        if( $inflated === false ) {
538
          break;
539
        }
540
541
        if( !$found ) {
542
          $buffer .= $inflated;
543
          $eos     = strpos( $buffer, "\0" );
544
545
          if( $eos !== false ) {
546
            $found = true;
547
            $body  = substr( $buffer, $eos + 1 );
548
            $head  = substr( $buffer, 0, $eos );
549
550
            if( $processor( $body, $head ) === false ) {
551
              break;
552
            }
553
          }
554
        } elseif( $processor( $inflated, '' ) === false ) {
555
          break;
556
        }
557
      }
558
    }
559
  }
560
561
  private function peekLooseObject( string $sha, int $length ): string {
562
    $path = $this->getLoosePath( $sha );
563
    $buf  = '';
564
565
    if( is_file( $path ) ) {
566
      $this->iterateInflated(
567
        $path,
568
        function( $chunk ) use ( $length, &$buf ) {
569
          $buf .= $chunk;
570
571
          return strlen( $buf ) < $length;
572
        }
573
      );
574
    }
575
576
    return substr( $buf, 0, $length );
577
  }
578
579
  private function parseCommit( string $sha ): object {
580
    $data   = $this->read( $sha );
581
    $result = (object)[ 'sha' => '' ];
582
583
    if( $data !== '' ) {
584
      $id = $this->parseIdentity(
585
        $data,
586
        '/^author (.*) <(.*)> (\d+)/m'
587
      );
588
589
      $result = (object)[
590
        'sha'       => $sha,
591
        'message'   => $this->extractMessage( $data ),
592
        'author'    => $id['name'],
593
        'email'     => $id['email'],
594
        'date'      => $id['timestamp'],
595
        'parentSha' => $this->extractPattern( $data, '/^parent (.*)$/m', 1 )
596
      ];
597
    }
598
599
    return $result;
600
  }
601
602
  private function walkTree( string $sha, callable $callback ): void {
603
    $data = $this->read( $sha );
604
    $tree = $data;
605
606
    if( $data !== '' && preg_match( '/^tree (.*)$/m', $data, $m ) ) {
607
      $tree = $this->read( $m[1] );
608
    }
609
610
    if( $tree !== '' && $this->isTreeData( $tree ) ) {
611
      $this->processTree( $tree, $callback );
612
    }
613
  }
614
615
  private function processTree( string $data, callable $callback ): void {
616
    $this->parseTreeData(
617
      $data,
618
      function( $file, $n, $s, $m ) use ( $callback ) {
619
        $callback( $file );
620
      }
621
    );
622
  }
623
624
  public function parseTreeData( string $data, callable $callback ): void {
625
    $pos = 0;
626
    $len = strlen( $data );
627
628
    while( $pos < $len ) {
629
      $space = strpos( $data, ' ', $pos );
630
      $eos   = strpos( $data, "\0", $space );
631
632
      if( $space === false || $eos === false || $eos + 21 > $len ) {
633
        break;
634
      }
635
636
      $mode  = substr( $data, $pos, $space - $pos );
637
      $name  = substr( $data, $space + 1, $eos - $space - 1 );
638
      $sha   = bin2hex( substr( $data, $eos + 1, 20 ) );
639
      $dir   = $mode === '40000' || $mode === '040000';
640
      $isSub = $mode === '160000';
641
642
      $file = new File(
643
        $name,
644
        $sha,
645
        $mode,
646
        0,
647
        $dir || $isSub ? 0 : $this->getObjectSize( $sha ),
648
        $dir || $isSub ? '' : $this->peek( $sha )
649
      );
650
651
      if( $callback( $file, $name, $sha, $mode ) === false ) {
652
        break;
653
      }
654
655
      $pos = $eos + 21;
656
    }
657
  }
658
659
  private function isTreeData( string $data ): bool {
660
    $len   = strlen( $data );
661
    $patt  = '/^(40000|100644|100755|120000|160000) /';
662
    $match = $len >= 25 && preg_match( $patt, $data );
663
    $eos   = $match ? strpos( $data, "\0" ) : false;
664
665
    return $match && $eos !== false && $eos + 21 <= $len;
666
  }
667
668
  private function getLoosePath( string $sha ): string {
669
    return "{$this->objPath}/" . substr( $sha, 0, 2 ) . "/" .
670
      substr( $sha, 2 );
671
  }
672
673
  private function getLooseObjectSize( string $sha ): int {
674
    $path = $this->getLoosePath( $sha );
675
    $size = 0;
676
677
    if( is_file( $path ) ) {
678
      $this->iterateInflated(
679
        $path,
680
        function( $c, $head ) use ( &$size ) {
681
          if( $head !== '' ) {
682
            $parts = explode( ' ', $head );
683
            $size  = isset( $parts[1] ) ? (int)$parts[1] : 0;
684
          }
685
679686
          return false;
680687
        }
M git/GitDiff.php
6666
6767
        if( !$old && $new ) {
68
          if( $new['is_dir'] ) {
68
          if( $new['file']->isDir() ) {
6969
            yield from $this->diffTrees( '', $new['sha'], $currentPath );
7070
          } else {
71
            yield $this->createChange( 'A', $currentPath, '', $new['sha'] );
71
            yield $this->createChange(
72
              'A',
73
              $currentPath,
74
              '',
75
              $new['sha'],
76
              null,
77
              $new['file']
78
            );
7279
          }
7380
        } elseif( !$new && $old ) {
74
          if( $old['is_dir'] ) {
81
          if( $old['file']->isDir() ) {
7582
            yield from $this->diffTrees( $old['sha'], '', $currentPath );
7683
          } else {
77
            yield $this->createChange( 'D', $currentPath, $old['sha'], '' );
84
            yield $this->createChange(
85
              'D',
86
              $currentPath,
87
              $old['sha'],
88
              '',
89
              $old['file'],
90
              null
91
            );
7892
          }
7993
        } elseif( $old && $new && $old['sha'] !== $new['sha'] ) {
80
          if( $old['is_dir'] && $new['is_dir'] ) {
94
          if( $old['file']->isDir() && $new['file']->isDir() ) {
8195
            yield from $this->diffTrees(
8296
              $old['sha'],
8397
              $new['sha'],
8498
              $currentPath
8599
            );
86
          } elseif( !$old['is_dir'] && !$new['is_dir'] ) {
100
          } elseif( !$old['file']->isDir() && !$new['file']->isDir() ) {
87101
            yield $this->createChange(
88102
              'M',
89103
              $currentPath,
90104
              $old['sha'],
91
              $new['sha']
105
              $new['sha'],
106
              $old['file'],
107
              $new['file']
92108
            );
93109
          }
...
100116
    $data    = $this->git->read( $sha );
101117
    $entries = [];
102
    $len     = strlen( $data );
103
    $pos     = 0;
104
105
    while( $pos < $len ) {
106
      $space = strpos( $data, ' ', $pos );
107
      $null  = strpos( $data, "\0", $space );
108118
109
      if( $space === false || $null === false ) {
110
        break;
119
    $this->git->parseTreeData(
120
      $data,
121
      function( $file, $name, $hash, $mode ) use ( &$entries ) {
122
        $entries[$name] = [
123
          'file' => $file,
124
          'sha'  => $hash
125
        ];
111126
      }
112
113
      $mode = substr( $data, $pos, $space - $pos );
114
      $name = substr( $data, $space + 1, $null - $space - 1 );
115
      $hash = bin2hex( substr( $data, $null + 1, 20 ) );
116
117
      $entries[$name] = [
118
        'mode'   => $mode,
119
        'sha'    => $hash,
120
        'is_dir' => $mode === '40000' || $mode === '040000'
121
      ];
122
123
      $pos = $null + 21;
124
    }
127
    );
125128
126129
    return $entries;
127130
  }
128131
129132
  private function createChange(
130133
    string $type,
131134
    string $path,
132135
    string $oldSha,
133
    string $newSha
136
    string $newSha,
137
    ?File $oldFile = null,
138
    ?File $newFile = null
134139
  ): array {
135140
    $oldSize = $oldSha !== '' ? $this->git->getObjectSize( $oldSha ) : 0;
...
147152
      $oldContent = $oldSha !== '' ? $this->git->read( $oldSha ) : '';
148153
      $newContent = $newSha !== '' ? $this->git->read( $newSha ) : '';
149
      $vDiffOld   = new VirtualDiffFile( $path, $oldContent );
150
      $vDiffNew   = new VirtualDiffFile( $path, $newContent );
154
      $isBinary   = false;
151155
152
      $isBinary = ($newSha !== '' && $vDiffNew->isBinary()) ||
153
                  ($newSha === '' && $oldSha !== '' && $vDiffOld->isBinary());
156
      if( $newFile !== null ) {
157
        $isBinary = $newFile->isBinary();
158
      } elseif( $oldFile !== null ) {
159
        $isBinary = $oldFile->isBinary();
160
      }
154161
155162
      $result = [
...
261268
            }
262269
          }
270
263271
          $buffer = [];
264272
        }
...
364372
365373
    return array_reverse( $diff );
366
  }
367
}
368
369
class VirtualDiffFile extends File {
370
  public function __construct( string $name, string $content ) {
371
    parent::__construct(
372
      $name,
373
      '',
374
      '100644',
375
      0,
376
      strlen( $content ),
377
      $content
378
    );
379374
  }
380375
}
M git/GitPacks.php
11
<?php
2
class GitPacks {
3
  private const MAX_READ     = 1040576;
4
  private const MAX_RAM      = 1048576;
5
  private const MAX_BASE_RAM = 2097152;
6
  private const MAX_DEPTH    = 200;
7
8
  private string $objectsPath;
9
  private array  $packFiles;
10
  private string $lastPack = '';
11
  private array  $fileHandles;
12
  private array  $fanoutCache;
13
  private array  $shaBucketCache;
14
  private array  $offsetBucketCache;
15
16
  public function __construct( string $objectsPath ) {
17
    $this->objectsPath       = $objectsPath;
18
    $this->packFiles         = glob( "{$this->objectsPath}/pack/*.idx" ) ?: [];
19
    $this->fileHandles       = [];
20
    $this->fanoutCache       = [];
21
    $this->shaBucketCache    = [];
22
    $this->offsetBucketCache = [];
23
  }
24
25
  public function __destruct() {
26
    foreach( $this->fileHandles as $handle ) {
27
      if( is_resource( $handle ) ) {
28
        fclose( $handle );
29
      }
30
    }
31
  }
32
33
  public function peek( string $sha, int $len = 12 ): string {
34
    $info   = $this->findPackInfo( $sha );
35
    $result = '';
36
37
    if( $info['offset'] !== 0 ) {
38
      $handle = $this->getHandle( $info['file'] );
39
40
      if( $handle ) {
41
        $result = $this->readPackEntry(
42
          $handle,
43
          $info['offset'],
44
          $len,
45
          $len
46
        );
47
      }
48
    }
49
50
    return $result;
51
  }
52
53
  public function read( string $sha ): string {
54
    $info   = $this->findPackInfo( $sha );
55
    $result = '';
56
57
    if( $info['offset'] !== 0 ) {
58
      $size = $this->extractPackedSize( $info['file'], $info['offset'] );
59
60
      if( $size <= self::MAX_RAM ) {
61
        $handle = $this->getHandle( $info['file'] );
62
63
        if( $handle ) {
64
          $result = $this->readPackEntry(
65
            $handle,
66
            $info['offset'],
67
            $size
68
          );
69
        }
70
      }
71
    }
72
73
    return $result;
74
  }
75
76
  public function stream( string $sha, callable $callback ): bool {
77
    return $this->streamInternal( $sha, $callback, 0 );
78
  }
79
80
  public function streamGenerator( string $sha ): Generator {
81
    $info = $this->findPackInfo( $sha );
82
83
    if( $info['offset'] !== 0 ) {
84
      $handle = $this->getHandle( $info['file'] );
85
86
      if( $handle ) {
87
        yield from $this->streamPackEntryGenerator(
88
          $handle,
89
          $info['offset'],
90
          0
91
        );
92
      }
93
    }
94
  }
95
96
  private function streamInternal(
97
    string $sha,
98
    callable $callback,
99
    int $depth
100
  ): bool {
101
    $info   = $this->findPackInfo( $sha );
102
    $result = false;
103
104
    if( $info['offset'] !== 0 ) {
105
      $size   = $this->extractPackedSize( $info['file'], $info['offset'] );
106
      $handle = $this->getHandle( $info['file'] );
107
108
      if( $handle ) {
109
        $result = $this->streamPackEntry(
110
          $handle,
111
          $info['offset'],
112
          $size,
113
          $callback,
114
          $depth
115
        );
116
      }
117
    }
118
119
    return $result;
120
  }
121
122
  public function getSize( string $sha ): int {
123
    $info   = $this->findPackInfo( $sha );
124
    $result = 0;
125
126
    if( $info['offset'] !== 0 ) {
127
      $result = $this->extractPackedSize( $info['file'], $info['offset'] );
128
    }
129
130
    return $result;
131
  }
132
133
  private function findPackInfo( string $sha ): array {
134
    $result = [ 'offset' => 0, 'file' => '' ];
135
136
    if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) {
137
      $binarySha = hex2bin( $sha );
138
139
      if( $this->lastPack !== '' ) {
140
        $offset = $this->findInIdx( $this->lastPack, $binarySha );
141
142
        if( $offset !== 0 ) {
143
          $result = [
144
            'file'   => str_replace( '.idx', '.pack', $this->lastPack ),
145
            'offset' => $offset
146
          ];
147
        }
148
      }
149
150
      if( $result['offset'] === 0 ) {
151
        foreach( $this->packFiles as $indexFile ) {
152
          if( $indexFile !== $this->lastPack ) {
153
            $offset = $this->findInIdx( $indexFile, $binarySha );
154
155
            if( $offset !== 0 ) {
156
              $this->lastPack = $indexFile;
157
              $result         = [
158
                'file'   => str_replace( '.idx', '.pack', $indexFile ),
159
                'offset' => $offset
160
              ];
161
              break;
162
            }
163
          }
164
        }
165
      }
166
    }
167
168
    return $result;
169
  }
170
171
  private function findInIdx( string $indexFile, string $binarySha ): int {
172
    $handle = $this->getHandle( $indexFile );
173
    $result = 0;
174
175
    if( $handle ) {
176
      if( !isset( $this->fanoutCache[$indexFile] ) ) {
177
        fseek( $handle, 0 );
178
        $head = fread( $handle, 8 );
179
180
        if( $head === "\377tOc\0\0\0\2" ) {
181
          $this->fanoutCache[$indexFile] = array_values(
182
            unpack( 'N*', fread( $handle, 1024 ) )
183
          );
184
        }
185
      }
186
187
      if( isset( $this->fanoutCache[$indexFile] ) ) {
188
        $fanout = $this->fanoutCache[$indexFile];
189
        $byte   = ord( $binarySha[0] );
190
        $start  = $byte === 0 ? 0 : $fanout[$byte - 1];
191
        $end    = $fanout[$byte];
192
193
        if( $end > $start ) {
194
          $result = $this->binarySearchIdx(
195
            $indexFile,
196
            $handle,
197
            $start,
198
            $end,
199
            $binarySha,
200
            $fanout[255]
201
          );
202
        }
203
      }
204
    }
205
206
    return $result;
207
  }
208
209
  private function binarySearchIdx(
210
    string $indexFile,
211
    $handle,
212
    int $start,
213
    int $end,
214
    string $binarySha,
215
    int $total
216
  ): int {
217
    $key    = "$indexFile:$start";
218
    $count  = $end - $start;
219
    $result = 0;
220
221
    if( !isset( $this->shaBucketCache[$key] ) ) {
222
      fseek( $handle, 1032 + ($start * 20) );
223
      $this->shaBucketCache[$key] = fread( $handle, $count * 20 );
224
225
      fseek( $handle, 1032 + ($total * 24) + ($start * 4) );
226
      $this->offsetBucketCache[$key] = fread( $handle, $count * 4 );
227
    }
228
229
    $shaBlock = $this->shaBucketCache[$key];
230
    $low      = 0;
231
    $high     = $count - 1;
232
    $found    = -1;
233
234
    while( $low <= $high ) {
235
      $mid = ($low + $high) >> 1;
236
      $cmp = substr( $shaBlock, $mid * 20, 20 );
237
238
      if( $cmp < $binarySha ) {
239
        $low = $mid + 1;
240
      } elseif( $cmp > $binarySha ) {
241
        $high = $mid - 1;
242
      } else {
243
        $found = $mid;
244
        break;
245
      }
246
    }
247
248
    if( $found !== -1 ) {
249
      $packed = substr( $this->offsetBucketCache[$key], $found * 4, 4 );
250
      $offset = unpack( 'N', $packed )[1];
251
252
      if( $offset & 0x80000000 ) {
253
        $pos64 = 1032 + ($total * 28) + (($offset & 0x7FFFFFFF) * 8);
254
        fseek( $handle, $pos64 );
255
        $offset = unpack( 'J', fread( $handle, 8 ) )[1];
256
      }
257
      $result = (int)$offset;
258
    }
259
260
    return $result;
261
  }
262
263
  private function readPackEntry(
264
    $handle,
265
    int $offset,
266
    int $size,
267
    int $cap = 0
268
  ): string {
269
    fseek( $handle, $offset );
270
    $header = $this->readVarInt( $handle );
271
    $type   = ($header['byte'] >> 4) & 7;
272
273
    return ($type === 6)
274
      ? $this->handleOfsDelta( $handle, $offset, $size, $cap )
275
      : (($type === 7)
276
        ? $this->handleRefDelta( $handle, $size, $cap )
277
        : $this->decompressToString( $handle, $cap ));
278
  }
279
280
  private function streamPackEntry(
281
    $handle,
282
    int $offset,
283
    int $size,
284
    callable $callback,
285
    int $depth = 0
286
  ): bool {
287
    fseek( $handle, $offset );
288
    $header = $this->readVarInt( $handle );
289
    $type   = ($header['byte'] >> 4) & 7;
290
291
    return ($type === 6 || $type === 7)
292
      ? $this->streamDeltaObject( $handle, $offset, $type, $callback, $depth )
293
      : $this->streamDecompression( $handle, $callback );
294
  }
295
296
  private function streamDeltaObject(
297
    $handle,
298
    int $offset,
299
    int $type,
300
    callable $callback,
301
    int $depth = 0
302
  ): bool {
303
    if( $depth >= self::MAX_DEPTH ) {
304
      error_log( "[GitPacks] delta depth limit exceeded at offset $offset" );
305
      return false;
306
    }
307
308
    fseek( $handle, $offset );
309
    $this->readVarInt( $handle );
310
    $result = false;
311
312
    if( $type === 6 ) {
313
      $neg      = $this->readOffsetDelta( $handle );
314
      $deltaPos = ftell( $handle );
315
      $base     = '';
316
      $baseSize = $this->extractPackedSize( $handle, $offset - $neg );
317
318
      if( $baseSize > self::MAX_BASE_RAM ) {
319
        error_log( "[GitPacks] ofs-delta base too large for RAM path: $baseSize" );
320
        return false;
321
      }
322
323
      $this->streamPackEntry(
324
        $handle,
325
        $offset - $neg,
326
        0,
327
        function( $c ) use ( &$base ) { $base .= $c; },
328
        $depth + 1
329
      );
330
331
      fseek( $handle, $deltaPos );
332
      $result = $this->applyDeltaStream( $handle, $base, $callback );
333
    } else {
334
      $baseSha  = bin2hex( fread( $handle, 20 ) );
335
      $baseSize = $this->getSize( $baseSha );
336
337
      if( $baseSize > self::MAX_BASE_RAM ) {
338
        error_log( "[GitPacks] ref-delta base too large for RAM path: $baseSize (sha=$baseSha)" );
339
        return false;
340
      }
341
342
      $base = '';
343
344
      if( $this->streamInternal( $baseSha, function( $c ) use ( &$base ) {
345
        $base .= $c;
346
      }, $depth + 1 ) ) {
347
        $result = $this->applyDeltaStream( $handle, $base, $callback );
348
      }
349
    }
350
351
    return $result;
352
  }
353
354
  private function applyDeltaStream(
355
    $handle,
356
    string $base,
357
    callable $callback
358
  ): bool {
359
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
360
    $ok   = false;
361
362
    if( $infl ) {
363
      $state  = 0;
364
      $buffer = '';
365
      $ok     = true;
366
367
      while( !feof( $handle ) ) {
368
        $chunk = fread( $handle, 8192 );
369
370
        if( $chunk === '' ) {
371
          break;
372
        }
373
374
        $data = @inflate_add( $infl, $chunk );
375
376
        if( $data === false ) {
377
          $ok = false;
378
          break;
379
        }
380
381
        $buffer .= $data;
382
383
        while( true ) {
384
          $len = strlen( $buffer );
385
386
          if( $len === 0 ) {
387
            break;
388
          }
389
390
          if( $state < 2 ) {
391
            $pos = 0;
392
            while( $pos < $len && (ord( $buffer[$pos] ) & 128) ) { $pos++; }
393
394
            if( $pos === $len && (ord( $buffer[$pos - 1] ) & 128) ) {
395
              break;
396
            }
397
398
            $buffer = substr( $buffer, $pos + 1 );
399
            $state++;
400
            continue;
401
          }
402
403
          $op = ord( $buffer[0] );
404
405
          if( $op & 128 ) {
406
            $need = $this->getCopyInstructionSize( $op );
407
408
            if( $len < 1 + $need ) {
409
              break;
410
            }
411
412
            $info = $this->parseCopyInstruction( $op, $buffer, 1 );
413
414
            $callback( substr( $base, $info['off'], $info['len'] ) );
415
            $buffer = substr( $buffer, 1 + $need );
416
          } else {
417
            $ln = $op & 127;
418
419
            if( $len < 1 + $ln ) {
420
              break;
421
            }
422
423
            $callback( substr( $buffer, 1, $ln ) );
424
            $buffer = substr( $buffer, 1 + $ln );
425
          }
426
        }
427
428
        if( inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
429
          break;
430
        }
431
      }
432
    }
433
434
    return $ok;
435
  }
436
437
  private function streamPackEntryGenerator(
438
    $handle,
439
    int $offset,
440
    int $depth
441
  ): Generator {
442
    fseek( $handle, $offset );
443
    $header = $this->readVarInt( $handle );
444
    $type   = ($header['byte'] >> 4) & 7;
445
446
    if( $type === 6 || $type === 7 ) {
447
      yield from $this->streamDeltaObjectGenerator(
448
        $handle,
449
        $offset,
450
        $type,
451
        $depth
452
      );
453
    } else {
454
      yield from $this->streamDecompressionGenerator( $handle );
455
    }
456
  }
457
458
  /**
459
   * Decompresses the pack entry at $baseOffset into a temp file and returns
460
   * the open handle rewound to byte 0, or null if tmpfile() fails.
461
   * The caller is responsible for fclose()-ing the handle.
462
   */
463
  private function resolveBaseToTempFile(
464
    $packHandle,
465
    int $baseOffset,
466
    int $depth
467
  ) {
468
    $tmpHandle = tmpfile();
469
470
    if( !$tmpHandle ) {
471
      error_log(
472
        "[GitPacks] tmpfile() failed for ofs-delta base at offset $baseOffset"
473
      );
474
      return null;
475
    }
476
477
    foreach( $this->streamPackEntryGenerator( $packHandle, $baseOffset, $depth + 1 ) as $chunk ) {
478
      fwrite( $tmpHandle, $chunk );
479
    }
480
481
    rewind( $tmpHandle );
482
483
    return $tmpHandle;
484
  }
485
486
  private function streamDeltaObjectGenerator(
487
    $handle,
488
    int $offset,
489
    int $type,
490
    int $depth
491
  ): Generator {
492
    if( $depth >= self::MAX_DEPTH ) {
493
      error_log( "[GitPacks] delta depth limit exceeded at offset $offset" );
494
      return;
495
    }
496
497
    fseek( $handle, $offset );
498
    $this->readVarInt( $handle );
499
500
    if( $type === 6 ) {
501
      $neg      = $this->readOffsetDelta( $handle );
502
      $deltaPos = ftell( $handle );
503
      $baseSize = $this->extractPackedSize( $handle, $offset - $neg );
504
505
      if( $baseSize > self::MAX_BASE_RAM ) {
506
        $tmpHandle = $this->resolveBaseToTempFile(
507
          $handle,
508
          $offset - $neg,
509
          $depth
510
        );
511
512
        if( $tmpHandle === null ) {
513
          return;
514
        }
515
516
        fseek( $handle, $deltaPos );
517
        yield from $this->applyDeltaStreamFromFileGenerator(
518
          $handle,
519
          $tmpHandle
520
        );
521
        fclose( $tmpHandle );
522
      } else {
523
        $base = '';
524
        $this->streamPackEntry(
525
          $handle,
526
          $offset - $neg,
527
          0,
528
          function( $c ) use ( &$base ) { $base .= $c; },
529
          $depth + 1
530
        );
531
        fseek( $handle, $deltaPos );
532
        yield from $this->applyDeltaStreamGenerator( $handle, $base );
533
      }
534
    } else {
535
      $baseSha  = bin2hex( fread( $handle, 20 ) );
536
      $baseSize = $this->getSize( $baseSha );
537
538
      if( $baseSize > self::MAX_BASE_RAM ) {
539
        $tmpHandle = tmpfile();
540
541
        if( !$tmpHandle ) {
542
          error_log(
543
            "[GitPacks] tmpfile() failed for ref-delta base (sha=$baseSha)"
544
          );
545
          return;
546
        }
547
548
        $written = $this->streamInternal(
549
          $baseSha,
550
          function( $c ) use ( $tmpHandle ) { fwrite( $tmpHandle, $c ); },
551
          $depth + 1
552
        );
553
554
        if( $written ) {
555
          rewind( $tmpHandle );
556
          yield from $this->applyDeltaStreamFromFileGenerator(
557
            $handle,
558
            $tmpHandle
559
          );
560
        }
561
562
        fclose( $tmpHandle );
563
      } else {
564
        $base = '';
565
566
        if( $this->streamInternal(
567
          $baseSha,
568
          function( $c ) use ( &$base ) { $base .= $c; },
569
          $depth + 1
570
        ) ) {
571
          yield from $this->applyDeltaStreamGenerator( $handle, $base );
572
        }
573
      }
574
    }
575
  }
576
577
  private function applyDeltaStreamGenerator(
578
    $handle,
579
    string $base
580
  ): Generator {
581
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
582
583
    if( !$infl ) {
584
      return;
585
    }
586
587
    $state  = 0;
588
    $buffer = '';
589
590
    while( !feof( $handle ) ) {
591
      $chunk = fread( $handle, 8192 );
592
593
      if( $chunk === '' ) {
594
        break;
595
      }
596
597
      $data = @inflate_add( $infl, $chunk );
598
599
      if( $data === false ) {
600
        break;
601
      }
602
603
      $buffer .= $data;
604
605
      while( true ) {
606
        $len = strlen( $buffer );
607
608
        if( $len === 0 ) {
609
          break;
610
        }
611
612
        if( $state < 2 ) {
613
          $pos = 0;
614
          while( $pos < $len && (ord( $buffer[$pos] ) & 128) ) { $pos++; }
615
616
          if( $pos === $len && (ord( $buffer[$pos - 1] ) & 128) ) {
617
            break;
618
          }
619
620
          $buffer = substr( $buffer, $pos + 1 );
621
          $state++;
622
          continue;
623
        }
624
625
        $op = ord( $buffer[0] );
626
627
        if( $op & 128 ) {
628
          $need = $this->getCopyInstructionSize( $op );
629
630
          if( $len < 1 + $need ) {
631
            break;
632
          }
633
634
          $info = $this->parseCopyInstruction( $op, $buffer, 1 );
635
          yield substr( $base, $info['off'], $info['len'] );
636
          $buffer = substr( $buffer, 1 + $need );
637
        } else {
638
          $ln = $op & 127;
639
640
          if( $len < 1 + $ln ) {
641
            break;
642
          }
643
644
          yield substr( $buffer, 1, $ln );
645
          $buffer = substr( $buffer, 1 + $ln );
646
        }
647
      }
648
649
      if( inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
650
        break;
651
      }
652
    }
653
  }
654
655
  private function applyDeltaStreamFromFileGenerator(
656
    $deltaHandle,
657
    $baseHandle
658
  ): Generator {
659
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
660
661
    if( !$infl ) {
662
      return;
663
    }
664
665
    $state  = 0;
666
    $buffer = '';
667
668
    while( !feof( $deltaHandle ) ) {
669
      $chunk = fread( $deltaHandle, 8192 );
670
671
      if( $chunk === '' ) {
672
        break;
673
      }
674
675
      $data = @inflate_add( $infl, $chunk );
676
677
      if( $data === false ) {
678
        break;
679
      }
680
681
      $buffer .= $data;
682
683
      while( true ) {
684
        $len = strlen( $buffer );
685
686
        if( $len === 0 ) {
687
          break;
688
        }
689
690
        if( $state < 2 ) {
691
          $pos = 0;
692
          while( $pos < $len && (ord( $buffer[$pos] ) & 128) ) { $pos++; }
693
694
          if( $pos === $len && (ord( $buffer[$pos - 1] ) & 128) ) {
695
            break;
696
          }
697
698
          $buffer = substr( $buffer, $pos + 1 );
699
          $state++;
700
          continue;
701
        }
702
703
        $op = ord( $buffer[0] );
704
705
        if( $op & 128 ) {
706
          $need = $this->getCopyInstructionSize( $op );
707
708
          if( $len < 1 + $need ) {
709
            break;
710
          }
711
712
          $info = $this->parseCopyInstruction( $op, $buffer, 1 );
713
          fseek( $baseHandle, $info['off'] );
714
          $remaining = $info['len'];
715
716
          while( $remaining > 0 ) {
717
            $slice = fread( $baseHandle, min( 65536, $remaining ) );
718
719
            if( $slice === false || $slice === '' ) {
720
              break;
721
            }
722
723
            yield $slice;
724
            $remaining -= strlen( $slice );
725
          }
726
727
          $buffer = substr( $buffer, 1 + $need );
728
        } else {
729
          $ln = $op & 127;
730
731
          if( $len < 1 + $ln ) {
732
            break;
733
          }
734
735
          yield substr( $buffer, 1, $ln );
736
          $buffer = substr( $buffer, 1 + $ln );
737
        }
738
      }
739
740
      if( inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
741
        break;
742
      }
743
    }
744
  }
745
746
  private function streamDecompressionGenerator( $handle ): Generator {
747
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
748
749
    if( !$infl ) {
750
      return;
751
    }
752
753
    while( !feof( $handle ) ) {
754
      $chunk = fread( $handle, 8192 );
755
756
      if( $chunk === '' ) {
757
        break;
758
      }
759
760
      $data = @inflate_add( $infl, $chunk );
761
762
      if( $data !== false && $data !== '' ) {
763
        yield $data;
764
      }
765
766
      if( $data === false ||
767
          inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
768
        break;
769
      }
770
    }
771
  }
772
773
  private function streamDecompression( $handle, callable $callback ): bool {
774
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
775
776
    if( !$infl ) {
777
      return false;
778
    }
779
780
    while( !feof( $handle ) ) {
781
      $chunk = fread( $handle, 8192 );
782
783
      if( $chunk === '' ) {
784
        break;
785
      }
786
787
      $data = @inflate_add( $infl, $chunk );
788
789
      if( $data !== false && $data !== '' ) {
790
        $callback( $data );
791
      }
792
793
      if( $data === false ||
794
          inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
795
        break;
796
      }
797
    }
798
799
    return true;
800
  }
801
802
  private function decompressToString(
803
    $handle,
804
    int $cap = 0
805
  ): string {
806
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
807
    $res  = '';
808
809
    if( $infl ) {
810
      while( !feof( $handle ) ) {
811
        $chunk = fread( $handle, 8192 );
812
813
        if( $chunk === '' ) {
814
          break;
815
        }
816
817
        $data = @inflate_add( $infl, $chunk );
818
819
        if( $data !== false ) {
820
          $res .= $data;
821
        }
822
823
        if( $cap > 0 && strlen( $res ) >= $cap ) {
824
          $res = substr( $res, 0, $cap );
825
          break;
826
        }
827
828
        if( $data === false ||
829
            inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
830
          break;
831
        }
832
      }
833
    }
834
835
    return $res;
836
  }
837
838
  private function extractPackedSize( $packPathOrHandle, int $offset ): int {
839
    $handle = is_resource( $packPathOrHandle )
840
      ? $packPathOrHandle
841
      : $this->getHandle( $packPathOrHandle );
842
    $size   = 0;
843
844
    if( $handle ) {
845
      fseek( $handle, $offset );
846
      $header = $this->readVarInt( $handle );
847
      $size   = $header['value'];
848
      $type   = ($header['byte'] >> 4) & 7;
849
850
      if( $type === 6 || $type === 7 ) {
851
        $size = $this->readDeltaTargetSize( $handle, $type );
852
      }
853
    }
854
855
    return $size;
856
  }
857
858
  private function handleOfsDelta(
859
    $handle,
860
    int $offset,
861
    int $size,
862
    int $cap
863
  ): string {
864
    $neg  = $this->readOffsetDelta( $handle );
865
    $cur  = ftell( $handle );
866
    $base = $offset - $neg;
867
868
    fseek( $handle, $base );
869
    $bHead = $this->readVarInt( $handle );
870
871
    fseek( $handle, $base );
872
    $bData = $this->readPackEntry( $handle, $base, $bHead['value'], $cap );
873
874
    fseek( $handle, $cur );
875
    $rem   = min( self::MAX_READ, max( $size * 2, 1048576 ) );
876
    $comp  = fread( $handle, $rem );
877
    $delta = @gzuncompress( $comp ) ?: '';
878
879
    return $this->applyDelta( $bData, $delta, $cap );
880
  }
881
882
  private function handleRefDelta( $handle, int $size, int $cap ): string {
883
    $sha = bin2hex( fread( $handle, 20 ) );
884
    $bas = $cap > 0 ? $this->peek( $sha, $cap ) : $this->read( $sha );
885
    $rem = min( self::MAX_READ, max( $size * 2, 1048576 ) );
886
    $cmp = fread( $handle, $rem );
887
    $del = @gzuncompress( $cmp ) ?: '';
888
889
    return $this->applyDelta( $bas, $del, $cap );
890
  }
891
892
  private function applyDelta( string $base, string $delta, int $cap ): string {
893
    $pos = 0;
894
    $res = $this->readDeltaSize( $delta, $pos );
895
    $pos += $res['used'];
896
    $res = $this->readDeltaSize( $delta, $pos );
897
    $pos += $res['used'];
898
899
    $out = '';
900
    $len = strlen( $delta );
901
902
    while( $pos < $len ) {
903
      if( $cap > 0 && strlen( $out ) >= $cap ) {
904
        break;
905
      }
906
907
      $op = ord( $delta[$pos++] );
908
909
      if( $op & 128 ) {
910
        $info = $this->parseCopyInstruction( $op, $delta, $pos );
911
        $out .= substr( $base, $info['off'], $info['len'] );
912
        $pos += $info['used'];
913
      } else {
914
        $ln = $op & 127;
915
        $out .= substr( $delta, $pos, $ln );
916
        $pos += $ln;
917
      }
918
    }
919
920
    return $out;
921
  }
922
923
  private function parseCopyInstruction(
924
    int $op,
925
    string $data,
926
    int $pos
927
  ): array {
928
    $off = 0;
929
    $len = 0;
930
    $ptr = $pos;
931
932
    if( $op & 0x01 ) { $off |= ord( $data[$ptr++] ); }
933
    if( $op & 0x02 ) { $off |= ord( $data[$ptr++] ) << 8; }
934
    if( $op & 0x04 ) { $off |= ord( $data[$ptr++] ) << 16; }
935
    if( $op & 0x08 ) { $off |= ord( $data[$ptr++] ) << 24; }
936
937
    if( $op & 0x10 ) { $len |= ord( $data[$ptr++] ); }
938
    if( $op & 0x20 ) { $len |= ord( $data[$ptr++] ) << 8; }
939
    if( $op & 0x40 ) { $len |= ord( $data[$ptr++] ) << 16; }
940
941
    return [
942
      'off'  => $off,
943
      'len'  => $len ?: 0x10000,
944
      'used' => $ptr - $pos
945
    ];
946
  }
947
948
  private function getCopyInstructionSize( int $op ): int {
949
    $c = $op & 0x7F;
950
    $c = $c - (( $c >> 1 ) & 0x55);
951
    $c = (( $c >> 2 ) & 0x33) + ( $c & 0x33 );
952
    $c = (( $c >> 4 ) + $c) & 0x0F;
953
954
    return $c;
955
  }
956
957
  private function readVarInt( $handle ): array {
958
    $byte = ord( fread( $handle, 1 ) );
959
    $val  = $byte & 15;
960
    $shft = 4;
961
    $fst  = $byte;
962
963
    while( $byte & 128 ) {
964
      $byte = ord( fread( $handle, 1 ) );
965
      $val |= (($byte & 127) << $shft);
966
      $shft += 7;
967
    }
968
969
    return [ 'value' => $val, 'byte' => $fst ];
970
  }
971
972
  private function readOffsetDelta( $handle ): int {
973
    $byte = ord( fread( $handle, 1 ) );
974
    $neg  = $byte & 127;
975
976
    while( $byte & 128 ) {
977
      $byte = ord( fread( $handle, 1 ) );
978
      $neg  = (($neg + 1) << 7) | ($byte & 127);
979
    }
980
981
    return $neg;
982
  }
983
984
  private function readDeltaTargetSize( $handle, int $type ): int {
985
    if( $type === 6 ) {
986
      $b = ord( fread( $handle, 1 ) );
987
      while( $b & 128 ) { $b = ord( fread( $handle, 1 ) ); }
988
    } else {
989
      fseek( $handle, 20, SEEK_CUR );
990
    }
991
992
    $infl = inflate_init( ZLIB_ENCODING_DEFLATE );
993
    $head = '';
994
    $try  = 0;
995
996
    if( $infl ) {
997
      while( !feof( $handle ) && strlen( $head ) < 32 && $try < 64 ) {
998
        $chunk = fread( $handle, 512 );
999
1000
        if( $chunk === '' ) {
1001
          break;
1002
        }
1003
1004
        $out = @inflate_add( $infl, $chunk, ZLIB_NO_FLUSH );
1005
1006
        if( $out !== false ) {
1007
          $head .= $out;
1008
        }
1009
1010
        if( inflate_get_status( $infl ) === ZLIB_STREAM_END ) {
1011
          break;
1012
        }
1013
1014
        $try++;
1015
      }
1016
    }
1017
1018
    $pos = 0;
1019
1020
    if( strlen( $head ) > 0 ) {
1021
      $res = $this->readDeltaSize( $head, $pos );
1022
      $pos += $res['used'];
1023
      $res = $this->readDeltaSize( $head, $pos );
1024
1025
      return $res['val'];
1026
    }
1027
1028
    return 0;
1029
  }
1030
1031
  private function readDeltaSize( string $data, int $pos ): array {
1032
    $len   = strlen( $data );
1033
    $val   = 0;
1034
    $shift = 0;
1035
    $start = $pos;
1036
1037
    while( $pos < $len ) {
1038
      $byte = ord( $data[$pos++] );
1039
      $val |= ($byte & 0x7F) << $shift;
1040
1041
      if( !($byte & 0x80) ) {
1042
        break;
1043
      }
1044
1045
      $shift += 7;
2
require_once __DIR__ . '/CompressionStream.php';
3
4
class GitPacks {
5
  private const MAX_READ     = 1040576;
6
  private const MAX_RAM      = 1048576;
7
  private const MAX_BASE_RAM = 2097152;
8
  private const MAX_DEPTH    = 200;
9
10
  private string $objectsPath;
11
  private array  $packFiles;
12
  private string $lastPack = '';
13
  private array  $fileHandles;
14
  private array  $fanoutCache;
15
  private array  $shaBucketCache;
16
  private array  $offsetBucketCache;
17
18
  public function __construct( string $objectsPath ) {
19
    $this->objectsPath       = $objectsPath;
20
    $this->packFiles         = glob( "{$this->objectsPath}/pack/*.idx" ) ?: [];
21
    $this->fileHandles       = [];
22
    $this->fanoutCache       = [];
23
    $this->shaBucketCache    = [];
24
    $this->offsetBucketCache = [];
25
  }
26
27
  public function __destruct() {
28
    foreach( $this->fileHandles as $handle ) {
29
      if( is_resource( $handle ) ) {
30
        fclose( $handle );
31
      }
32
    }
33
  }
34
35
  public function peek( string $sha, int $len = 12 ): string {
36
    $info   = $this->findPackInfo( $sha );
37
    $result = '';
38
39
    if( $info['offset'] !== 0 ) {
40
      $handle = $this->getHandle( $info['file'] );
41
42
      if( $handle ) {
43
        $result = $this->readPackEntry(
44
          $handle,
45
          $info['offset'],
46
          $len,
47
          $len
48
        );
49
      }
50
    }
51
52
    return $result;
53
  }
54
55
  public function read( string $sha ): string {
56
    $info   = $this->findPackInfo( $sha );
57
    $result = '';
58
59
    if( $info['offset'] !== 0 ) {
60
      $size = $this->extractPackedSize( $info['file'], $info['offset'] );
61
62
      if( $size <= self::MAX_RAM ) {
63
        $handle = $this->getHandle( $info['file'] );
64
65
        if( $handle ) {
66
          $result = $this->readPackEntry(
67
            $handle,
68
            $info['offset'],
69
            $size
70
          );
71
        }
72
      }
73
    }
74
75
    return $result;
76
  }
77
78
  public function stream( string $sha, callable $callback ): bool {
79
    $result = false;
80
81
    foreach( $this->streamGenerator( $sha ) as $chunk ) {
82
      $callback( $chunk );
83
      $result = true;
84
    }
85
86
    return $result;
87
  }
88
89
  public function streamGenerator( string $sha ): Generator {
90
    yield from $this->streamShaGenerator( $sha, 0 );
91
  }
92
93
  private function streamShaGenerator( string $sha, int $depth ): Generator {
94
    $info = $this->findPackInfo( $sha );
95
96
    if( $info['offset'] !== 0 ) {
97
      $handle = $this->getHandle( $info['file'] );
98
99
      if( $handle ) {
100
        yield from $this->streamPackEntryGenerator(
101
          $handle,
102
          $info['offset'],
103
          $depth
104
        );
105
      }
106
    }
107
  }
108
109
  public function getSize( string $sha ): int {
110
    $info   = $this->findPackInfo( $sha );
111
    $result = 0;
112
113
    if( $info['offset'] !== 0 ) {
114
      $result = $this->extractPackedSize( $info['file'], $info['offset'] );
115
    }
116
117
    return $result;
118
  }
119
120
  private function findPackInfo( string $sha ): array {
121
    $result = [ 'offset' => 0, 'file' => '' ];
122
123
    if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) {
124
      $binarySha = hex2bin( $sha );
125
126
      if( $this->lastPack !== '' ) {
127
        $offset = $this->findInIdx( $this->lastPack, $binarySha );
128
129
        if( $offset !== 0 ) {
130
          $result = [
131
            'file'   => str_replace( '.idx', '.pack', $this->lastPack ),
132
            'offset' => $offset
133
          ];
134
        }
135
      }
136
137
      if( $result['offset'] === 0 ) {
138
        $count = count( $this->packFiles );
139
        $idx   = 0;
140
        $found = false;
141
142
        while( !$found && $idx < $count ) {
143
          $indexFile = $this->packFiles[$idx];
144
145
          if( $indexFile !== $this->lastPack ) {
146
            $offset = $this->findInIdx( $indexFile, $binarySha );
147
148
            if( $offset !== 0 ) {
149
              $this->lastPack = $indexFile;
150
              $result         = [
151
                'file'   => str_replace( '.idx', '.pack', $indexFile ),
152
                'offset' => $offset
153
              ];
154
              $found          = true;
155
            }
156
          }
157
158
          $idx++;
159
        }
160
      }
161
    }
162
163
    return $result;
164
  }
165
166
  private function findInIdx( string $indexFile, string $binarySha ): int {
167
    $handle = $this->getHandle( $indexFile );
168
    $result = 0;
169
170
    if( $handle ) {
171
      if( !isset( $this->fanoutCache[$indexFile] ) ) {
172
        fseek( $handle, 0 );
173
        $head = fread( $handle, 8 );
174
175
        if( $head === "\377tOc\0\0\0\2" ) {
176
          $this->fanoutCache[$indexFile] = array_values(
177
            unpack( 'N*', fread( $handle, 1024 ) )
178
          );
179
        }
180
      }
181
182
      if( isset( $this->fanoutCache[$indexFile] ) ) {
183
        $fanout = $this->fanoutCache[$indexFile];
184
        $byte   = ord( $binarySha[0] );
185
        $start  = $byte === 0 ? 0 : $fanout[$byte - 1];
186
        $end    = $fanout[$byte];
187
188
        if( $end > $start ) {
189
          $result = $this->binarySearchIdx(
190
            $indexFile,
191
            $handle,
192
            $start,
193
            $end,
194
            $binarySha,
195
            $fanout[255]
196
          );
197
        }
198
      }
199
    }
200
201
    return $result;
202
  }
203
204
  private function binarySearchIdx(
205
    string $indexFile,
206
    $handle,
207
    int $start,
208
    int $end,
209
    string $binarySha,
210
    int $total
211
  ): int {
212
    $key    = "$indexFile:$start";
213
    $count  = $end - $start;
214
    $result = 0;
215
216
    if( !isset( $this->shaBucketCache[$key] ) ) {
217
      fseek( $handle, 1032 + ($start * 20) );
218
      $this->shaBucketCache[$key] = fread( $handle, $count * 20 );
219
220
      fseek( $handle, 1032 + ($total * 24) + ($start * 4) );
221
      $this->offsetBucketCache[$key] = fread( $handle, $count * 4 );
222
    }
223
224
    $shaBlock = $this->shaBucketCache[$key];
225
    $low      = 0;
226
    $high     = $count - 1;
227
    $found    = -1;
228
229
    while( $found === -1 && $low <= $high ) {
230
      $mid = ($low + $high) >> 1;
231
      $cmp = substr( $shaBlock, $mid * 20, 20 );
232
233
      if( $cmp < $binarySha ) {
234
        $low = $mid + 1;
235
      } elseif( $cmp > $binarySha ) {
236
        $high = $mid - 1;
237
      } else {
238
        $found = $mid;
239
      }
240
    }
241
242
    if( $found !== -1 ) {
243
      $packed = substr( $this->offsetBucketCache[$key], $found * 4, 4 );
244
      $offset = unpack( 'N', $packed )[1];
245
246
      if( $offset & 0x80000000 ) {
247
        $pos64 = 1032 + ($total * 28) + (($offset & 0x7FFFFFFF) * 8);
248
249
        fseek( $handle, $pos64 );
250
        $offset = unpack( 'J', fread( $handle, 8 ) )[1];
251
      }
252
253
      $result = (int)$offset;
254
    }
255
256
    return $result;
257
  }
258
259
  private function readPackEntry(
260
    $handle,
261
    int $offset,
262
    int $size,
263
    int $cap = 0
264
  ): string {
265
    fseek( $handle, $offset );
266
    $header = $this->readVarInt( $handle );
267
    $type   = ($header['byte'] >> 4) & 7;
268
    $result = '';
269
270
    if( $type === 6 ) {
271
      $result = $this->handleOfsDelta( $handle, $offset, $size, $cap );
272
    } elseif( $type === 7 ) {
273
      $result = $this->handleRefDelta( $handle, $size, $cap );
274
    } else {
275
      $result = $this->decompressToString( $handle, $cap );
276
    }
277
278
    return $result;
279
  }
280
281
  private function streamPackEntryGenerator(
282
    $handle,
283
    int $offset,
284
    int $depth
285
  ): Generator {
286
    fseek( $handle, $offset );
287
    $header = $this->readVarInt( $handle );
288
    $type   = ($header['byte'] >> 4) & 7;
289
290
    if( $type === 6 || $type === 7 ) {
291
      yield from $this->streamDeltaObjectGenerator(
292
        $handle,
293
        $offset,
294
        $type,
295
        $depth
296
      );
297
    } else {
298
      yield from $this->streamDecompressionGenerator( $handle );
299
    }
300
  }
301
302
  private function resolveBaseToTempFile(
303
    $packHandle,
304
    int $baseOffset,
305
    int $depth
306
  ) {
307
    $tmpHandle = tmpfile();
308
309
    if( $tmpHandle !== false ) {
310
      foreach( $this->streamPackEntryGenerator(
311
        $packHandle,
312
        $baseOffset,
313
        $depth + 1
314
      ) as $chunk ) {
315
        fwrite( $tmpHandle, $chunk );
316
      }
317
318
      rewind( $tmpHandle );
319
    } else {
320
      error_log(
321
        "[GitPacks] tmpfile failed for ofs-delta base at $baseOffset"
322
      );
323
    }
324
325
    return $tmpHandle;
326
  }
327
328
  private function streamDeltaObjectGenerator(
329
    $handle,
330
    int $offset,
331
    int $type,
332
    int $depth
333
  ): Generator {
334
    if( $depth < self::MAX_DEPTH ) {
335
      fseek( $handle, $offset );
336
      $this->readVarInt( $handle );
337
338
      if( $type === 6 ) {
339
        $neg      = $this->readOffsetDelta( $handle );
340
        $deltaPos = ftell( $handle );
341
        $baseSize = $this->extractPackedSize( $handle, $offset - $neg );
342
343
        if( $baseSize > self::MAX_BASE_RAM ) {
344
          $tmpHandle = $this->resolveBaseToTempFile(
345
            $handle,
346
            $offset - $neg,
347
            $depth
348
          );
349
350
          if( $tmpHandle !== false ) {
351
            fseek( $handle, $deltaPos );
352
            yield from $this->applyDeltaStreamGenerator(
353
              $handle,
354
              $tmpHandle
355
            );
356
357
            fclose( $tmpHandle );
358
          }
359
        } else {
360
          $base = '';
361
362
          foreach( $this->streamPackEntryGenerator(
363
            $handle,
364
            $offset - $neg,
365
            $depth + 1
366
          ) as $chunk ) {
367
            $base .= $chunk;
368
          }
369
370
          fseek( $handle, $deltaPos );
371
          yield from $this->applyDeltaStreamGenerator( $handle, $base );
372
        }
373
      } else {
374
        $baseSha  = bin2hex( fread( $handle, 20 ) );
375
        $baseSize = $this->getSize( $baseSha );
376
377
        if( $baseSize > self::MAX_BASE_RAM ) {
378
          $tmpHandle = tmpfile();
379
380
          if( $tmpHandle !== false ) {
381
            $written = false;
382
383
            foreach( $this->streamShaGenerator(
384
              $baseSha,
385
              $depth + 1
386
            ) as $chunk ) {
387
              fwrite( $tmpHandle, $chunk );
388
              $written = true;
389
            }
390
391
            if( $written ) {
392
              rewind( $tmpHandle );
393
              yield from $this->applyDeltaStreamGenerator(
394
                $handle,
395
                $tmpHandle
396
              );
397
            }
398
399
            fclose( $tmpHandle );
400
          } else {
401
            error_log(
402
              "[GitPacks] tmpfile() failed for ref-delta (sha=$baseSha)"
403
            );
404
          }
405
        } else {
406
          $base    = '';
407
          $written = false;
408
409
          foreach( $this->streamShaGenerator(
410
            $baseSha,
411
            $depth + 1
412
          ) as $chunk ) {
413
            $base    .= $chunk;
414
            $written  = true;
415
          }
416
417
          if( $written ) {
418
            yield from $this->applyDeltaStreamGenerator( $handle, $base );
419
          }
420
        }
421
      }
422
    } else {
423
      error_log( "[GitPacks] delta depth limit exceeded at offset $offset" );
424
    }
425
  }
426
427
  private function applyDeltaStreamGenerator(
428
    $handle,
429
    $base
430
  ): Generator {
431
    $stream = CompressionStream::createInflater();
432
    $state  = 0;
433
    $buffer = '';
434
    $done   = false;
435
    $isFile = is_resource( $base );
436
437
    while( !$done && !feof( $handle ) ) {
438
      $chunk = fread( $handle, 8192 );
439
      $done  = $chunk === false || $chunk === '';
440
441
      if( !$done ) {
442
        $data = $stream->pump( $chunk );
443
444
        if( $data !== '' ) {
445
          $buffer     .= $data;
446
          $doneBuffer  = false;
447
448
          while( !$doneBuffer ) {
449
            $len = strlen( $buffer );
450
451
            if( $len === 0 ) {
452
              $doneBuffer = true;
453
            }
454
455
            if( !$doneBuffer ) {
456
              if( $state < 2 ) {
457
                $pos = 0;
458
459
                while( $pos < $len && (ord( $buffer[$pos] ) & 128) ) {
460
                  $pos++;
461
                }
462
463
                if( $pos === $len && (ord( $buffer[$pos - 1] ) & 128) ) {
464
                  $doneBuffer = true;
465
                }
466
467
                if( !$doneBuffer ) {
468
                  $buffer = substr( $buffer, $pos + 1 );
469
                  $state++;
470
                }
471
              } else {
472
                $op = ord( $buffer[0] );
473
474
                if( $op & 128 ) {
475
                  $need = $this->getCopyInstructionSize( $op );
476
477
                  if( $len < 1 + $need ) {
478
                    $doneBuffer = true;
479
                  }
480
481
                  if( !$doneBuffer ) {
482
                    $info = $this->parseCopyInstruction( $op, $buffer, 1 );
483
484
                    if( $isFile ) {
485
                      fseek( $base, $info['off'] );
486
                      $rem = $info['len'];
487
488
                      while( $rem > 0 ) {
489
                        $slc = fread( $base, min( 65536, $rem ) );
490
491
                        if( $slc === false || $slc === '' ) {
492
                          $rem = 0;
493
                        } else {
494
                          yield $slc;
495
                          $rem -= strlen( $slc );
496
                        }
497
                      }
498
                    } else {
499
                      yield substr( $base, $info['off'], $info['len'] );
500
                    }
501
502
                    $buffer = substr( $buffer, 1 + $need );
503
                  }
504
                } else {
505
                  $ln = $op & 127;
506
507
                  if( $len < 1 + $ln ) {
508
                    $doneBuffer = true;
509
                  }
510
511
                  if( !$doneBuffer ) {
512
                    yield substr( $buffer, 1, $ln );
513
                    $buffer = substr( $buffer, 1 + $ln );
514
                  }
515
                }
516
              }
517
            }
518
          }
519
        }
520
521
        $done = $stream->finished();
522
      }
523
    }
524
  }
525
526
  private function streamDecompressionGenerator( $handle ): Generator {
527
    $stream = CompressionStream::createInflater();
528
    $done   = false;
529
530
    while( !$done && !feof( $handle ) ) {
531
      $chunk = fread( $handle, 8192 );
532
      $done  = $chunk === false || $chunk === '';
533
534
      if( !$done ) {
535
        $data = $stream->pump( $chunk );
536
537
        if( $data !== '' ) {
538
          yield $data;
539
        }
540
541
        $done = $stream->finished();
542
      }
543
    }
544
  }
545
546
  private function decompressToString(
547
    $handle,
548
    int $cap = 0
549
  ): string {
550
    $stream = CompressionStream::createInflater();
551
    $res    = '';
552
    $done   = false;
553
554
    while( !$done && !feof( $handle ) ) {
555
      $chunk = fread( $handle, 8192 );
556
      $done  = $chunk === false || $chunk === '';
557
558
      if( !$done ) {
559
        $data = $stream->pump( $chunk );
560
561
        if( $data !== '' ) {
562
          $res .= $data;
563
        }
564
565
        if( $cap > 0 && strlen( $res ) >= $cap ) {
566
          $res  = substr( $res, 0, $cap );
567
          $done = true;
568
        }
569
570
        if( !$done ) {
571
          $done = $stream->finished();
572
        }
573
      }
574
    }
575
576
    return $res;
577
  }
578
579
  private function extractPackedSize( $packPathOrHandle, int $offset ): int {
580
    $handle = is_resource( $packPathOrHandle )
581
      ? $packPathOrHandle
582
      : $this->getHandle( $packPathOrHandle );
583
    $size   = 0;
584
585
    if( $handle ) {
586
      fseek( $handle, $offset );
587
      $header = $this->readVarInt( $handle );
588
      $size   = $header['value'];
589
      $type   = ($header['byte'] >> 4) & 7;
590
591
      if( $type === 6 || $type === 7 ) {
592
        $size = $this->readDeltaTargetSize( $handle, $type );
593
      }
594
    }
595
596
    return $size;
597
  }
598
599
  private function handleOfsDelta(
600
    $handle,
601
    int $offset,
602
    int $size,
603
    int $cap
604
  ): string {
605
    $neg  = $this->readOffsetDelta( $handle );
606
    $cur  = ftell( $handle );
607
    $base = $offset - $neg;
608
609
    fseek( $handle, $base );
610
    $bHead = $this->readVarInt( $handle );
611
612
    fseek( $handle, $base );
613
    $bData = $this->readPackEntry( $handle, $base, $bHead['value'], $cap );
614
615
    fseek( $handle, $cur );
616
    $rem   = min( self::MAX_READ, max( $size * 2, 1048576 ) );
617
    $comp  = fread( $handle, $rem );
618
    $delta = @gzuncompress( $comp ) ?: '';
619
620
    return $this->applyDelta( $bData, $delta, $cap );
621
  }
622
623
  private function handleRefDelta( $handle, int $size, int $cap ): string {
624
    $sha = bin2hex( fread( $handle, 20 ) );
625
    $bas = $cap > 0 ? $this->peek( $sha, $cap ) : $this->read( $sha );
626
    $rem = min( self::MAX_READ, max( $size * 2, 1048576 ) );
627
    $cmp = fread( $handle, $rem );
628
    $del = @gzuncompress( $cmp ) ?: '';
629
630
    return $this->applyDelta( $bas, $del, $cap );
631
  }
632
633
  private function applyDelta( string $base, string $delta, int $cap ): string {
634
    $pos = 0;
635
    $res = $this->readDeltaSize( $delta, $pos );
636
    $pos += $res['used'];
637
    $res = $this->readDeltaSize( $delta, $pos );
638
    $pos += $res['used'];
639
640
    $out  = '';
641
    $len  = strlen( $delta );
642
    $done = false;
643
644
    while( !$done && $pos < $len ) {
645
      if( $cap > 0 && strlen( $out ) >= $cap ) {
646
        $done = true;
647
      }
648
649
      if( !$done ) {
650
        $op = ord( $delta[$pos++] );
651
652
        if( $op & 128 ) {
653
          $info = $this->parseCopyInstruction( $op, $delta, $pos );
654
          $out  .= substr( $base, $info['off'], $info['len'] );
655
          $pos  += $info['used'];
656
        } else {
657
          $ln   = $op & 127;
658
          $out  .= substr( $delta, $pos, $ln );
659
          $pos  += $ln;
660
        }
661
      }
662
    }
663
664
    return $out;
665
  }
666
667
  private function parseCopyInstruction(
668
    int $op,
669
    string $data,
670
    int $pos
671
  ): array {
672
    $off = 0;
673
    $len = 0;
674
    $ptr = $pos;
675
676
    if( $op & 0x01 ) {
677
      $off |= ord( $data[$ptr++] );
678
    }
679
680
    if( $op & 0x02 ) {
681
      $off |= ord( $data[$ptr++] ) << 8;
682
    }
683
684
    if( $op & 0x04 ) {
685
      $off |= ord( $data[$ptr++] ) << 16;
686
    }
687
688
    if( $op & 0x08 ) {
689
      $off |= ord( $data[$ptr++] ) << 24;
690
    }
691
692
    if( $op & 0x10 ) {
693
      $len |= ord( $data[$ptr++] );
694
    }
695
696
    if( $op & 0x20 ) {
697
      $len |= ord( $data[$ptr++] ) << 8;
698
    }
699
700
    if( $op & 0x40 ) {
701
      $len |= ord( $data[$ptr++] ) << 16;
702
    }
703
704
    return [
705
      'off'  => $off,
706
      'len'  => $len === 0 ? 0x10000 : $len,
707
      'used' => $ptr - $pos
708
    ];
709
  }
710
711
  private function getCopyInstructionSize( int $op ): int {
712
    $c = $op & 0x7F;
713
    $c = $c - (($c >> 1) & 0x55);
714
    $c = (($c >> 2) & 0x33) + ($c & 0x33);
715
    $c = (($c >> 4) + $c) & 0x0F;
716
717
    return $c;
718
  }
719
720
  private function readVarInt( $handle ): array {
721
    $byte = ord( fread( $handle, 1 ) );
722
    $val  = $byte & 15;
723
    $shft = 4;
724
    $fst  = $byte;
725
726
    while( $byte & 128 ) {
727
      $byte  = ord( fread( $handle, 1 ) );
728
      $val  |= (($byte & 127) << $shft);
729
      $shft += 7;
730
    }
731
732
    return [ 'value' => $val, 'byte' => $fst ];
733
  }
734
735
  private function readOffsetDelta( $handle ): int {
736
    $byte = ord( fread( $handle, 1 ) );
737
    $neg  = $byte & 127;
738
739
    while( $byte & 128 ) {
740
      $byte = ord( fread( $handle, 1 ) );
741
      $neg  = (($neg + 1) << 7) | ($byte & 127);
742
    }
743
744
    return $neg;
745
  }
746
747
  private function readDeltaTargetSize( $handle, int $type ): int {
748
    if( $type === 6 ) {
749
      $b = ord( fread( $handle, 1 ) );
750
751
      while( $b & 128 ) {
752
        $b = ord( fread( $handle, 1 ) );
753
      }
754
    } else {
755
      fseek( $handle, 20, SEEK_CUR );
756
    }
757
758
    $stream = CompressionStream::createInflater();
759
    $head   = '';
760
    $try    = 0;
761
    $done   = false;
762
763
    while( !$done && !feof( $handle ) && strlen( $head ) < 32 && $try < 64 ) {
764
      $chunk = fread( $handle, 512 );
765
      $done  = $chunk === false || $chunk === '';
766
767
      if( !$done ) {
768
        $out = $stream->pump( $chunk );
769
770
        if( $out !== '' ) {
771
          $head .= $out;
772
        }
773
774
        $done = $stream->finished();
775
        $try++;
776
      }
777
    }
778
779
    $pos    = 0;
780
    $result = 0;
781
782
    if( strlen( $head ) > 0 ) {
783
      $res  = $this->readDeltaSize( $head, $pos );
784
      $pos += $res['used'];
785
      $res  = $this->readDeltaSize( $head, $pos );
786
787
      $result = $res['val'];
788
    }
789
790
    return $result;
791
  }
792
793
  private function readDeltaSize( string $data, int $pos ): array {
794
    $len   = strlen( $data );
795
    $val   = 0;
796
    $shift = 0;
797
    $start = $pos;
798
    $done  = false;
799
800
    while( !$done && $pos < $len ) {
801
      $byte  = ord( $data[$pos++] );
802
      $val  |= ($byte & 0x7F) << $shift;
803
804
      if( !($byte & 0x80) ) {
805
        $done = true;
806
      }
807
808
      if( !$done ) {
809
        $shift += 7;
810
      }
1046811
    }
1047812
M git/GitRefs.php
11
<?php
2
require_once __DIR__ . '/BufferedFileReader.php';
3
24
class GitRefs {
35
  private string $repoPath;
...
1517
      $headFile = "{$this->repoPath}/HEAD";
1618
17
      if( $input === 'HEAD' && file_exists( $headFile ) ) {
18
        $head   = trim( file_get_contents( $headFile ) );
19
      if( $input === 'HEAD' && is_file( $headFile ) ) {
20
        $size = filesize( $headFile );
21
        $head = '';
22
23
        if( $size > 0 ) {
24
          $reader = BufferedFileReader::open( $headFile );
25
          $head   = trim( $reader->read( $size ) );
26
        }
27
1928
        $result = strpos( $head, 'ref: ' ) === 0
2029
          ? $this->resolve( substr( $head, 5 ) )
...
7887
          $this->traverseDirectory( $path, $callback, $name );
7988
        } elseif( is_file( $path ) ) {
80
          $sha = trim( file_get_contents( $path ) );
89
          $size = filesize( $path );
8190
82
          if( preg_match( '/^[0-9a-f]{40}$/', $sha ) ) {
83
            $callback( $name, $sha );
91
          if( $size > 0 ) {
92
            $reader = BufferedFileReader::open( $path );
93
            $sha    = trim( $reader->read( $size ) );
94
95
            if( preg_match( '/^[0-9a-f]{40}$/', $sha ) ) {
96
              $callback( $name, $sha );
97
            }
8498
          }
8599
        }
...
95109
      $path = "{$this->repoPath}/$ref";
96110
97
      if( file_exists( $path ) ) {
98
        $result = trim( file_get_contents( $path ) );
111
      if( is_file( $path ) ) {
112
        $size = filesize( $path );
113
114
        if( $size > 0 ) {
115
          $reader = BufferedFileReader::open( $path );
116
          $result = trim( $reader->read( $size ) );
117
        }
118
99119
        break;
100120
      }
101121
    }
102122
103123
    if( $result === '' ) {
104124
      $packedPath = "{$this->repoPath}/packed-refs";
105125
106
      if( file_exists( $packedPath ) ) {
126
      if( is_file( $packedPath ) ) {
107127
        $result = $this->findInPackedRefs( $packedPath, $input );
108128
      }
...
118138
  private function findInPackedRefs( string $path, string $input ): string {
119139
    $targets = [$input, "refs/heads/$input", "refs/tags/$input"];
120
    $lines   = file( $path );
140
    $size    = filesize( $path );
141
    $lines   = [];
121142
    $result  = '';
143
144
    if( $size > 0 ) {
145
      $reader = BufferedFileReader::open( $path );
146
      $lines  = explode( "\n", $reader->read( $size ) );
147
    }
122148
123149
    foreach( $lines as $line ) {
124
      if( $line[0] !== '#' && $line[0] !== '^' ) {
150
      if( $line !== '' && $line[0] !== '#' && $line[0] !== '^' ) {
125151
        $parts = explode( ' ', trim( $line ) );
126152
M pages/CommitsPage.php
44
55
class CommitsPage extends BasePage {
6
  private const PER_PAGE = 100;
7
68
  private $currentRepo;
79
  private $git;
...
2325
  public function render() {
2426
    $this->renderLayout( function() {
25
      $main = $this->git->getMainBranch();
27
      $main  = $this->git->getMainBranch();
28
      $start = '';
29
      $count = 0;
2630
2731
      if( !$main ) {
...
3539
        echo '<div class="commit-list">';
3640
37
        $start = $this->hash ?: $main['hash'];
41
        $start = $this->hash !== '' ? $this->hash : $main['hash'];
3842
39
        $this->git->history( $start, 100, function( $commit ) {
40
          $msg = htmlspecialchars( explode( "\n", $commit->message )[0] );
43
        $commits = [];
4144
42
          $url = (new UrlBuilder())
43
            ->withRepo( $this->currentRepo['safe_name'] )
44
            ->withAction( 'commit' )
45
            ->withHash( $commit->sha )
46
            ->build();
45
        $this->git->history(
46
          $start,
47
          self::PER_PAGE,
48
          function( $commit ) use ( &$commits ) {
49
            $commits[] = $commit;
50
          }
51
        );
4752
48
          echo '<div class="commit-row">';
49
          echo '<a href="' . $url . '" class="sha">' .
50
               substr( $commit->sha, 0, 7 ) . '</a>';
51
          echo '<span class="message">' . $msg . '</span>';
52
          echo '<span class="meta">' .
53
               htmlspecialchars( $commit->author ) .
54
               ' &bull; ' . date( 'Y-m-d', $commit->date ) . '</span>';
55
          echo '</div>';
56
        } );
53
        $count = count( $commits );
54
        $nav   = $this->buildPagination( $main['hash'], $count );
55
56
        $this->renderPagination( $nav );
57
58
        foreach( $commits as $commit ) {
59
          $this->renderCommitRow( $commit );
60
        }
5761
5862
        echo '</div>';
63
64
        $this->renderPagination( $nav );
5965
      }
6066
    }, $this->currentRepo );
67
  }
68
69
  private function renderCommitRow( object $commit ) {
70
    $msg = htmlspecialchars( explode( "\n", $commit->message )[0] );
71
    $url = $this->buildCommitUrl( $commit->sha );
72
73
    echo '<div class="commit-row">';
74
    echo '<a href="' . $url . '" class="sha">' .
75
         substr( $commit->sha, 0, 7 ) . '</a>';
76
    echo '<span class="message">' . $msg . '</span>';
77
    echo '<span class="meta">' . htmlspecialchars( $commit->author ) .
78
         ' &bull; ' . date( 'Y-m-d', $commit->date ) . '</span>';
79
    echo '</div>';
80
  }
81
82
  private function renderPagination( array $nav ) {
83
    $pages   = $nav['pages'];
84
    $current = $nav['current'];
85
    $hasNext = $nav['hasNext'];
86
    $hasAll  = $nav['hasAll'];
87
    $hasPrev = $current > 1;
88
    $total   = count( $pages );
89
90
    if( $hasPrev || $hasNext ) {
91
      echo '<div class="pagination">';
92
93
      if( $hasPrev ) {
94
        $firstUrl = $this->buildPageUrl( $pages[0] );
95
96
        echo '<a href="' . $firstUrl . '" class="page-link page-nav" ' .
97
             'aria-label="first">' . $this->svgArrow( 'first' ) .
98
             '</a>';
99
100
        $prevUrl = $this->buildPageUrl( $pages[$current - 2] );
101
102
        echo '<a href="' . $prevUrl . '" class="page-link page-nav" ' .
103
             'aria-label="back">' . $this->svgArrow( 'back' ) .
104
             '</a>';
105
      } else {
106
        echo '<span class="page-link page-nav page-nav-hidden" ' .
107
             'aria-hidden="true">' . $this->svgArrow( 'first' ) .
108
             '</span>';
109
110
        echo '<span class="page-link page-nav page-nav-hidden" ' .
111
             'aria-hidden="true">' . $this->svgArrow( 'back' ) .
112
             '</span>';
113
      }
114
115
      $this->renderPageNumbers( $pages, $current );
116
117
      if( $hasNext ) {
118
        $nextUrl = $this->buildPageUrl( $pages[$current] );
119
        $lastUrl = $this->buildPageUrl( $pages[$total - 1] );
120
121
        echo '<a href="' . $nextUrl . '" class="page-link page-nav" ' .
122
             'aria-label="next">' . $this->svgArrow( 'next' ) .
123
             '</a>';
124
125
        echo '<a href="' . $lastUrl . '" class="page-link page-nav" ' .
126
             'aria-label="last">' . $this->svgArrow( 'last' ) .
127
             '</a>';
128
      } else {
129
        echo '<span class="page-link page-nav page-nav-hidden" ' .
130
             'aria-hidden="true">' . $this->svgArrow( 'next' ) .
131
             '</span>';
132
133
        echo '<span class="page-link page-nav page-nav-hidden" ' .
134
             'aria-hidden="true">' . $this->svgArrow( 'last' ) .
135
             '</span>';
136
      }
137
138
      echo '</div>';
139
    }
140
  }
141
142
  private function svgArrow( string $type ): string {
143
    $icons = [
144
      'back'  => '<path d="M14 17 L9 12 L14 7" />',
145
      'next'  => '<path d="M10 17 L15 12 L10 7" />',
146
      'first' => '<path d="M13 17 L6 12 L13 7 M19 17 L12 12 L19 7" />',
147
      'last'  => '<path d="M11 17 L18 12 L11 7 M5 17 L12 12 L5 7" />',
148
    ];
149
150
    $inner = $icons[$type] ?? '';
151
    $svg   = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ' .
152
             'fill="none" stroke="currentColor" stroke-width="2" ' .
153
             'stroke-linecap="round" stroke-linejoin="round" ' .
154
             'aria-label="' . $type . '" role="img">' .
155
             '<title>' . $type . '</title>' . $inner . '</svg>';
156
157
    return $svg;
158
  }
159
160
  private function renderPageNumbers( array $pages, int $current ) {
161
    $total  = count( $pages );
162
    $start  = $current - 4;
163
    $actual = 1;
164
    $end    = 0;
165
    $sha    = '';
166
    $url    = '';
167
168
    if( $start < 1 ) {
169
      $start = 1;
170
    }
171
172
    $end = $start + 9;
173
174
    if( $end > $total ) {
175
      $end   = $total;
176
      $start = $end - 9;
177
178
      if( $start < 1 ) {
179
        $start = 1;
180
      }
181
    }
182
183
    while( $actual <= $total ) {
184
      if( $actual >= $start && $actual <= $end ) {
185
        if( $actual === $current ) {
186
          echo '<span class="page-badge">' . $actual . '</span>';
187
        } else {
188
          $sha = $pages[$actual - 1];
189
          $url = $this->buildPageUrl( $sha );
190
191
          echo '<a href="' . $url . '" class="page-link">' . $actual . '</a>';
192
        }
193
      }
194
195
      $actual++;
196
    }
197
  }
198
199
  private function buildPagination( string $mainHash, int $count ): array {
200
    $target      = $this->hash !== '' ? $this->hash : $mainHash;
201
    $pageHashes  = [];
202
    $commits     = 0;
203
    $currentPage = 1;
204
    $found       = false;
205
    $hitLimit    = false;
206
    $result      = [];
207
208
    $this->git->history(
209
      $mainHash,
210
      PHP_INT_MAX,
211
      function( $commit ) use (
212
        $target,
213
        &$pageHashes,
214
        &$commits,
215
        &$currentPage,
216
        &$found,
217
        &$hitLimit
218
      ) {
219
        $continue = true;
220
221
        if( $commits % self::PER_PAGE === 0 ) {
222
          $pageHashes[] = $commit->sha;
223
        }
224
225
        if( $commit->sha === $target ) {
226
          $currentPage = count( $pageHashes );
227
          $found       = true;
228
        }
229
230
        if( $found && count( $pageHashes ) > $currentPage + 10 ) {
231
          $hitLimit = true;
232
          $continue = false;
233
        }
234
235
        if( $continue ) {
236
          $commits++;
237
        }
238
239
        return $continue;
240
      }
241
    );
242
243
    $result['pages']   = $pageHashes;
244
    $result['current'] = $currentPage;
245
    $result['hasAll']  = !$hitLimit;
246
    $result['hasNext'] = $count === self::PER_PAGE &&
247
                         isset( $pageHashes[$currentPage] );
248
249
    return $result;
250
  }
251
252
  private function buildCommitUrl( string $targetHash ): string {
253
    $builder = new UrlBuilder();
254
    $result  = '';
255
256
    $builder->withRepo( $this->currentRepo['safe_name'] );
257
    $builder->withAction( 'commit' );
258
    $builder->withHash( $targetHash );
259
260
    $result = $builder->build();
261
262
    return $result;
263
  }
264
265
  private function buildPageUrl( string $targetHash ): string {
266
    $builder = new UrlBuilder();
267
    $result  = '';
268
269
    $builder->withRepo( $this->currentRepo['safe_name'] );
270
    $builder->withAction( 'commits' );
271
    $builder->withHash( $targetHash );
272
273
    $result = $builder->build();
274
275
    return $result;
61276
  }
62277
}
M pages/DiffPage.php
3434
        if( $line === '' ) {
3535
          $isMsg = true;
36
          continue;
37
        }
38
39
        if( $isMsg ) {
36
        } elseif( $isMsg ) {
4037
          $msg .= $line . "\n";
4138
        } else {
4239
          if( preg_match( '/^(\w+) (.*)$/', $line, $m ) ) {
4340
            $headers[$m[1]] = $m[2];
4441
          }
4542
        }
4643
      }
4744
48
      $changes = $diffEngine->compare( $this->hash );
45
      $changes = iterator_to_array( $diffEngine->compare( $this->hash ) );
46
      $added   = 0;
47
      $deleted = 0;
48
49
      foreach( $changes as $change ) {
50
        if( isset( $change['hunks'] ) ) {
51
          foreach( $change['hunks'] as $hunkLine ) {
52
            if( isset( $hunkLine['t'] ) ) {
53
              if( $hunkLine['t'] === '+' ) {
54
                $added++;
55
              } elseif( $hunkLine['t'] === '-' ) {
56
                $deleted++;
57
              }
58
            }
59
          }
60
        }
61
      }
4962
5063
      $commitsUrl = (new UrlBuilder())
...
5871
      ] );
5972
60
      $author = $headers['author'] ?? 'Unknown';
61
      $author = preg_replace( '/<[^>]+>/', '<email>', $author );
73
      $authorRaw  = $headers['author'] ?? 'Unknown';
74
      $authorName = preg_replace( '/<[^>]+>/', '<email>', $authorRaw );
75
      $authorName = htmlspecialchars( $authorName );
76
      $commitDate = '';
77
78
      if( preg_match( '/^(.*?) <.*?> (\d+) ([-+]\d{4})$/', $authorRaw, $m ) ) {
79
        $authorName = htmlspecialchars( $m[1] ) . ' &lt;email&gt;';
80
        $timestamp  = (int)$m[2];
81
        $offsetStr  = $m[3];
82
        $pattern    = '/([-+])(\d{2})(\d{2})/';
83
        $tzString   = preg_replace( $pattern, '$1$2:$3', $offsetStr );
84
        $dt         = new DateTime( '@' . $timestamp );
85
86
        $dt->setTimezone( new DateTimeZone( $tzString ) );
87
88
        $commitDate = $dt->format( 'Y-m-d H:i:s \G\M\TO' );
89
      }
6290
6391
      echo '<div class="commit-details">';
6492
      echo '<div class="commit-header">';
6593
      echo '<h1 class="commit-title">' .
6694
           htmlspecialchars( trim( $msg ) ) . '</h1>';
67
      echo '<div class="commit-info">';
68
      echo '<div class="commit-info-row">' .
69
           '<span class="commit-info-label">Author</span>' .
70
           '<span class="commit-author">' .
71
           htmlspecialchars( $author ) . '</span></div>';
72
      echo '<div class="commit-info-row">' .
73
           '<span class="commit-info-label">Commit</span>' .
74
           '<span class="commit-info-value">' .
75
           $this->hash . '</span></div>';
95
96
      echo '<table class="commit-info-table"><tbody>';
97
98
      echo '<tr>' .
99
           '<th class="commit-info-label">Author</th>' .
100
           '<td class="commit-info-value">' .
101
           $authorName . '</td></tr>';
102
103
      if( $commitDate !== '' ) {
104
        echo '<tr>' .
105
             '<th class="commit-info-label">Date</th>' .
106
             '<td class="commit-info-value">' .
107
             $commitDate . '</td></tr>';
108
      }
109
110
      echo '<tr>' .
111
           '<th class="commit-info-label">Commit</th>' .
112
           '<td class="commit-info-value">' .
113
           $this->hash . '</td></tr>';
76114
77115
      if( isset( $headers['parent'] ) ) {
78116
        $url = (new UrlBuilder())
79117
          ->withRepo( $this->currentRepo['safe_name'] )
80118
          ->withAction( 'commit' )
81119
          ->withHash( $headers['parent'] )
82120
          ->build();
83121
84
        echo '<div class="commit-info-row">' .
85
             '<span class="commit-info-label">Parent</span>' .
86
             '<span class="commit-info-value">';
122
        echo '<tr>' .
123
             '<th class="commit-info-label">Parent</th>' .
124
             '<td class="commit-info-value">';
87125
        echo '<a href="' . $url . '" class="parent-link">' .
88126
             substr( $headers['parent'], 0, 7 ) . '</a>';
89
        echo '</span></div>';
127
        echo '</td></tr>';
90128
      }
91129
92
      echo '</div></div></div>';
130
      $diffNet   = $added - $deleted;
131
      $pluralize = function( int $count ): string {
132
        $suffix = '';
133
134
        if( $count !== 1 ) {
135
          $suffix = 's';
136
        }
137
138
        return $count . ' line' . $suffix;
139
      };
140
141
      $deltaMsg = $pluralize( $added ) . ' added, ' .
142
                  $pluralize( $deleted ) . ' removed';
143
144
      if( $diffNet === 0 ) {
145
        $deltaMsg .= ', 0 lines changed';
146
      } elseif( $added > 0 && $deleted > 0 ) {
147
        if( $diffNet > 0 ) {
148
          $deltaMsg .= ', ' . $diffNet . '-line increase';
149
        } else {
150
          $deltaMsg .= ', ' . abs( $diffNet ) . '-line decrease';
151
        }
152
      }
153
154
      echo '<tr>' .
155
           '<th class="commit-info-label">Delta</th>' .
156
           '<td class="commit-info-value">' .
157
           $deltaMsg . '</td></tr>';
158
159
      echo '</tbody></table></div></div>';
93160
94161
      echo '<div class="diff-container">';
...
106173
  }
107174
108
  private function renderFileDiff( $change ) {
175
  private function renderFileDiff( array $change ) {
109176
    $statusIcon  = 'fa-file';
110177
    $statusClass = '';
111178
112179
    if( $change['type'] === 'A' ) {
113180
      $statusIcon  = 'fa-plus-circle';
114181
      $statusClass = 'status-add';
115
    }
116
117
    if( $change['type'] === 'D' ) {
182
    } elseif( $change['type'] === 'D' ) {
118183
      $statusIcon  = 'fa-minus-circle';
119184
      $statusClass = 'status-del';
120
    }
121
122
    if( $change['type'] === 'M' ) {
185
    } elseif( $change['type'] === 'M' ) {
123186
      $statusIcon  = 'fa-pencil-alt';
124187
      $statusClass = 'status-mod';
...
144207
          echo '<img src="/images/diff-gap.svg" class="diff-gap-icon" />';
145208
          echo '</td></tr>';
146
          continue;
147
        }
148
149
        $class = 'diff-ctx';
150
        $char  = ' ';
209
        } else {
210
          $class = 'diff-ctx';
211
          $char  = ' ';
151212
152
        if( $line['t'] === '+' ) {
153
          $class = 'diff-add';
154
          $char  = '+';
155
        }
213
          if( $line['t'] === '+' ) {
214
            $class = 'diff-add';
215
            $char  = '+';
216
          } elseif( $line['t'] === '-' ) {
217
            $class = 'diff-del';
218
            $char  = '-';
219
          }
156220
157
        if( $line['t'] === '-' ) {
158
          $class = 'diff-del';
159
          $char  = '-';
221
          echo '<tr class="' . $class . '">';
222
          echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
223
          echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
224
          echo '<td class="diff-code"><span class="diff-marker">' .
225
               $char . '</span>' . htmlspecialchars( $line['l'] ) . '</td>';
226
          echo '</tr>';
160227
        }
161
162
        echo '<tr class="' . $class . '">';
163
        echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
164
        echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
165
        echo '<td class="diff-code"><span class="diff-marker">' .
166
             $char . '</span>' . htmlspecialchars( $line['l'] ) . '</td>';
167
        echo '</tr>';
168228
      }
169229
M styles/repo.css
292292
}
293293
294
.commit-info {
295
  display: grid;
296
  gap: 8px;
297
  font-size: 0.875rem;
298
}
299
300
.commit-info-row {
301
  display: flex;
302
  gap: 10px;
303
}
304
305
.commit-info-label {
306
  color: #8b949e;
307
  width: 80px;
308
  flex-shrink: 0;
309
}
310
311
.commit-info-value {
312
  color: #c9d1d9;
313
  font-family: monospace;
314
}
315
316
.parent-link {
317
  color: #58a6ff;
318
  text-decoration: none;
319
}
320
321
.parent-link:hover {
322
  text-decoration: underline;
323
}
324
325
.repo-grid {
326
  display: grid;
327
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
328
  gap: 16px;
329
  margin-top: 20px;
330
}
331
332
.repo-card {
333
  background: #161b22;
334
  border: 1px solid #30363d;
335
  border-radius: 8px;
336
  padding: 20px;
337
  text-decoration: none;
338
  color: inherit;
339
  transition: border-color 0.2s, transform 0.1s;
340
}
341
342
.repo-card:hover {
343
  border-color: #58a6ff;
344
  transform: translateY(-2px);
345
}
346
347
.repo-card h3 {
348
  color: #58a6ff;
349
  margin-bottom: 8px;
350
  font-size: 1.1rem;
351
}
352
353
.repo-card p {
354
  color: #8b949e;
355
  font-size: 0.875rem;
356
  margin: 0;
357
}
358
359
.current-repo {
360
  background: #21262d;
361
  border: 1px solid #58a6ff;
362
  padding: 8px 16px;
363
  border-radius: 6px;
364
  font-size: 0.875rem;
365
  color: #f0f6fc;
366
}
367
368
.current-repo strong {
369
  color: #58a6ff;
370
}
371
372
.branch-badge {
373
  background: #238636;
374
  color: white;
375
  padding: 2px 8px;
376
  border-radius: 12px;
377
  font-size: 0.75rem;
378
  font-weight: 600;
379
  margin-left: 10px;
380
}
381
382
.commit-row {
383
  display: flex;
384
  padding: 10px 0;
385
  border-bottom: 1px solid #30363d;
386
  gap: 15px;
387
  align-items: baseline;
388
}
389
390
.commit-row:last-child {
391
  border-bottom: none;
392
}
393
394
.commit-row .sha {
395
  font-family: monospace;
396
  color: #58a6ff;
397
  text-decoration: none;
398
}
399
400
.commit-row .message {
401
  flex: 1;
402
  font-weight: 500;
403
}
404
405
.commit-row .meta {
406
  font-size: 0.85em;
407
  color: #8b949e;
408
  white-space: nowrap;
409
}
410
411
.blob-content-image {
412
  text-align: center;
413
  padding: 20px;
414
  background: #0d1117;
415
}
416
417
.blob-content-image img {
418
  max-width: 100%;
419
  border: 1px solid #30363d;
420
}
421
422
.blob-content-video {
423
  text-align: center;
424
  padding: 20px;
425
  background: #000;
426
}
427
428
.blob-content-video video {
429
  max-width: 100%;
430
  max-height: 80vh;
431
}
432
433
.blob-content-audio {
434
  text-align: center;
435
  padding: 40px;
436
  background: #161b22;
437
}
438
439
.blob-content-audio audio {
440
  width: 100%;
441
  max-width: 600px;
442
}
443
444
.download-state {
445
  text-align: center;
446
  padding: 40px;
447
  border: 1px solid #30363d;
448
  border-radius: 6px;
449
  margin-top: 10px;
450
}
451
452
.download-state p {
453
  margin-bottom: 20px;
454
  color: #8b949e;
455
}
456
457
.btn-download {
458
  display: inline-block;
459
  padding: 6px 16px;
460
  background: #238636;
461
  color: white;
462
  text-decoration: none;
463
  border-radius: 6px;
464
  font-weight: 600;
465
}
466
467
.repo-info-banner {
468
  margin-top: 15px;
469
}
470
471
.file-icon-container {
472
  width: 20px;
473
  text-align: center;
474
  margin-right: 5px;
475
  color: #8b949e;
476
}
477
478
.file-size {
479
  color: #8b949e;
480
  font-size: 0.8em;
481
  margin-left: 10px;
482
}
483
484
.file-date {
485
  color: #8b949e;
486
  font-size: 0.8em;
487
  margin-left: auto;
488
}
489
490
.repo-card-time {
491
  margin-top: 8px;
492
  color: #58a6ff;
493
}
494
495
.diff-container {
496
  display: flex;
497
  flex-direction: column;
498
  gap: 20px;
499
}
500
501
.diff-file {
502
  background: #161b22;
503
  border: 1px solid #30363d;
504
  border-radius: 6px;
505
  overflow: hidden;
506
}
507
508
.diff-header {
509
  background: #21262d;
510
  padding: 10px 16px;
511
  border-bottom: 1px solid #30363d;
512
  display: flex;
513
  align-items: center;
514
  gap: 10px;
515
}
516
517
.diff-path {
518
  font-family: monospace;
519
  font-size: 0.9rem;
520
  color: #f0f6fc;
521
}
522
523
.diff-binary {
524
  padding: 20px;
525
  text-align: center;
526
  color: #8b949e;
527
  font-style: italic;
528
}
529
530
.diff-content {
531
  overflow-x: auto;
532
}
533
534
.diff-content table {
535
  width: 100%;
536
  border-collapse: collapse;
537
  font-family: 'SFMono-Regular', Consolas, monospace;
538
  font-size: 12px;
539
}
540
541
.diff-content td {
542
  padding: 2px 0;
543
  line-height: 20px;
544
}
545
546
.diff-num {
547
  width: 1%;
548
  min-width: 40px;
549
  text-align: right;
550
  padding-right: 10px;
551
  color: #6e7681;
552
  user-select: none;
553
  background: #0d1117;
554
  border-right: 1px solid #30363d;
555
}
556
557
.diff-num::before {
558
  content: attr(data-num);
559
}
560
561
.diff-code {
562
  padding-left: 10px;
563
  white-space: pre-wrap;
564
  word-break: break-all;
565
  color: #c9d1d9;
566
}
567
568
.diff-marker {
569
  display: inline-block;
570
  width: 15px;
571
  user-select: none;
572
  color: #8b949e;
573
}
574
575
.diff-add {
576
  background-color: rgba(2, 59, 149, 0.25);
577
}
578
.diff-add .diff-code {
579
  color: #79c0ff;
580
}
581
.diff-add .diff-marker {
582
  color: #79c0ff;
583
}
584
585
.diff-del {
586
  background-color: rgba(148, 99, 0, 0.25);
587
}
588
.diff-del .diff-code {
589
  color: #d29922;
590
}
591
.diff-del .diff-marker {
592
  color: #d29922;
593
}
594
595
.diff-gap {
596
  background: #0d1117;
597
  color: #484f58;
598
  text-align: center;
599
  font-size: 0.8em;
600
  height: 20px;
601
}
602
.diff-gap td {
603
  padding: 8px 0;
604
  background: #161b22;
605
  border-top: 1px solid #30363d;
606
  border-bottom: 1px solid #30363d;
607
  text-align: center;
608
}
609
610
.diff-gap-icon {
611
  vertical-align: middle;
612
}
613
614
.status-add { color: #58a6ff; }
615
.status-del { color: #d29922; }
616
.status-mod { color: #a371f7; }
617
618
.tag-table, .file-list-table {
619
  width: 100%;
620
  border-collapse: collapse;
621
  margin-top: 10px;
622
  background: #161b22;
623
  border: 1px solid #30363d;
624
  border-radius: 6px;
625
  overflow: hidden;
626
}
627
628
.tag-table th, .file-list-table th {
629
  text-align: left;
630
  padding: 10px 16px;
631
  border-bottom: 2px solid #30363d;
632
  color: #8b949e;
633
  font-size: 0.875rem;
634
  font-weight: 600;
635
  white-space: nowrap;
636
}
637
638
.tag-table td, .file-list-table td {
639
  padding: 12px 16px;
640
  border-bottom: 1px solid #21262d;
641
  vertical-align: middle;
642
  color: #c9d1d9;
643
  font-size: 0.9rem;
644
}
645
646
.tag-table tr:hover td, .file-list-table tr:hover td {
647
  background: #161b22;
648
}
649
650
.tag-table .tag-name {
651
  min-width: 140px;
652
  width: 20%;
653
}
654
655
.tag-table .tag-message {
656
  width: auto;
657
  white-space: normal;
658
  word-break: break-word;
659
  color: #c9d1d9;
660
  font-weight: 500;
661
}
662
663
.tag-table .tag-author,
664
.tag-table .tag-time,
665
.tag-table .tag-hash {
666
  width: 1%;
667
  white-space: nowrap;
668
}
669
670
.tag-table .tag-time {
671
  text-align: right;
672
  color: #8b949e;
673
}
674
675
.tag-table .tag-hash {
676
  text-align: right;
677
}
678
679
.tag-table .tag-name a {
680
  color: #58a6ff;
681
  text-decoration: none;
682
  font-family: 'SFMono-Regular', Consolas, monospace;
683
}
684
685
.tag-table .tag-author {
686
  color: #c9d1d9;
687
}
688
689
.tag-table .tag-age-header {
690
  text-align: right;
691
}
692
693
.tag-table .tag-commit-header {
694
  text-align: right;
695
}
696
697
.tag-table .commit-hash {
698
  font-family: 'SFMono-Regular', Consolas, monospace;
699
  color: #58a6ff;
700
  text-decoration: none;
701
}
702
703
.tag-table .commit-hash:hover {
704
  text-decoration: underline;
705
}
706
707
.file-list-table .file-icon-cell {
708
  width: 20px;
709
  text-align: center;
710
  color: #8b949e;
711
  padding-right: 0;
712
}
713
714
.file-list-table .file-name-cell a {
715
  color: #58a6ff;
716
  text-decoration: none;
717
  font-weight: 500;
718
}
719
720
.file-list-table .file-name-cell a:hover {
721
  text-decoration: underline;
722
}
723
724
.file-list-table .file-mode-cell {
725
  font-family: 'SFMono-Regular', Consolas, monospace;
726
  color: #8b949e;
727
  font-size: 0.8rem;
728
  width: 1%;
729
  white-space: nowrap;
730
  text-align: center;
731
}
732
733
.file-list-table .file-size-cell {
734
  color: #8b949e;
735
  text-align: right;
736
  width: 1%;
737
  white-space: nowrap;
738
  font-size: 0.85rem;
739
}
740
741
.file-list-table .file-date-cell {
742
  color: #8b949e;
743
  text-align: right;
744
  width: 150px;
745
  font-size: 0.85rem;
746
  white-space: nowrap;
747
}
748
749
.blob-code {
750
  font-family: 'SFMono-Regular', Consolas, monospace;
751
  background-color: #161b22;
752
  color: #fcfcfa;
753
  font-size: 0.875rem;
754
  line-height: 1.6;
755
  tab-size: 2;
756
}
757
758
.hl-comment,
759
.hl-doc-comment {
760
  color: #727072;
761
  font-style: italic;
762
}
763
764
.hl-function,
765
.hl-method {
766
  color: #78dce8;
767
}
768
769
.hl-tag {
770
  color: #3e8bff;
771
}
772
773
.hl-class,
774
.hl-interface,
775
.hl-struct {
776
  color: #a9dc76;
777
}
778
779
.hl-type {
780
  color: #a9dc76;
781
}
782
783
.hl-keyword,
784
.hl-storage,
785
.hl-modifier,
786
.hl-statement {
787
  color: #ff6188;
788
  font-weight: 600;
789
}
790
791
.hl-string,
792
.hl-string_interp {
793
  color: #ffd866;
794
}
795
796
.hl-number,
797
.hl-boolean,
798
.hl-constant,
799
.hl-preprocessor {
800
  color: #ab9df2;
801
}
802
803
.hl-variable {
804
  color: #fcfcfa;
805
}
806
807
.hl-attribute,
808
.hl-property {
809
  color: #fc9867;
810
}
811
812
.hl-operator,
813
.hl-punctuation,
814
.hl-escape {
815
  color: #939293;
816
}
817
818
.hl-interp-punct {
819
  color: #ff6188;
820
}
821
822
.hl-math {
823
  color: #ab9df2;
824
  font-style: italic;
825
}
826
827
.hl-code {
828
  display: inline-block;
829
  width: 100%;
830
  background-color: #0d1117;
831
  color: #c9d1d9;
832
  padding: 2px 4px;
833
  border-radius: 3px;
834
}
835
836
@media (max-width: 768px) {
837
  .container {
838
    padding: 10px;
839
  }
840
841
  h1 { font-size: 1.5rem; }
842
  h2 { font-size: 1.2rem; }
843
844
  .nav {
845
    flex-direction: column;
846
    align-items: flex-start;
847
    gap: 10px;
848
  }
849
850
  .repo-selector {
851
    margin-left: 0;
852
    width: 100%;
853
  }
854
855
  .repo-selector select {
856
    flex: 1;
857
  }
858
859
  .file-list-table th,
860
  .file-list-table td {
861
    padding: 8px 10px;
862
  }
863
864
  .file-list-table .file-mode-cell,
865
  .file-list-table .file-date-cell {
866
    display: none;
867
  }
868
869
  .commit-details {
870
    padding: 15px;
871
  }
872
873
  .commit-title {
874
    font-size: 1.1rem;
875
    word-break: break-word;
876
  }
877
878
  .commit-info-row {
879
    flex-direction: column;
880
    gap: 2px;
881
    margin-bottom: 10px;
882
  }
883
884
  .commit-info-label {
885
    width: 100%;
886
    font-size: 0.8rem;
887
    color: #8b949e;
888
  }
889
890
  .commit-info-value {
891
    word-break: break-all;
892
    font-family: 'SFMono-Regular', Consolas, monospace;
893
    font-size: 0.9rem;
894
    padding-left: 0;
895
  }
896
897
  .commit-row {
898
    flex-direction: column;
899
    gap: 5px;
900
  }
901
902
  .commit-row .message {
903
    width: 100%;
904
    white-space: normal;
905
  }
906
907
  .commit-row .meta {
908
    font-size: 0.8rem;
909
  }
910
911
  .tag-table .tag-author,
912
  .tag-table .tag-time,
913
  .tag-table .tag-hash {
914
    font-size: 0.8rem;
915
  }
916
917
  .blob-code, .diff-content {
918
    overflow-x: scroll;
919
    -webkit-overflow-scrolling: touch;
920
  }
921
}
922
923
@media screen and (orientation: landscape) and (max-height: 600px) {
924
  .container {
925
    max-width: 100%;
926
  }
927
928
  header {
929
    margin-bottom: 15px;
930
    padding-bottom: 10px;
931
  }
932
933
  .file-list-table .file-date-cell {
934
    display: table-cell;
935
  }
936
}
937
938
.clone-checkbox {
939
  display: none;
940
}
941
942
.clone-link {
943
  cursor: pointer;
944
  color: #58a6ff;
945
  text-decoration: none;
946
}
947
948
.clone-link:hover {
949
  text-decoration: underline;
950
}
951
952
.clone-region {
953
  display: none;
954
  margin-top: 10px;
955
  padding: 10px;
956
  background-color: #161b22;
957
  border: 1px solid #30363d;
958
  border-radius: 6px;
959
}
960
961
.clone-checkbox:checked ~ .clone-region {
962
  display: block;
963
}
964
965
.clone-wrapper {
966
  display: inline-grid;
967
  vertical-align: top;
968
}
969
970
.clone-sizer {
971
  grid-area: 1 / 1;
972
  visibility: hidden;
973
  white-space: pre;
974
  font-family: monospace;
975
  font-size: 13px;
976
  padding: 8px;
977
  border: 1px solid transparent;
978
}
979
980
.clone-input {
981
  grid-area: 1 / 1;
982
  width: 100%;
983
  padding: 8px;
984
  background: #0d1117;
985
  color: #c9d1d9;
986
  border: 1px solid #30363d;
987
  border-radius: 4px;
988
  font-family: monospace;
989
  font-size: 13px;
990
  box-sizing: border-box;
294
.commit-info-table {
295
  width: 100%;
296
  border-collapse: collapse;
297
  font-size: 0.875rem;
298
}
299
300
.commit-info-label {
301
  text-align: left;
302
  font-weight: normal;
303
  color: #8b949e;
304
  width: 80px;
305
  padding: 4px 10px 4px 0;
306
}
307
308
.commit-info-value {
309
  color: #c9d1d9;
310
  font-family: monospace;
311
  padding: 4px 0;
312
}
313
314
.parent-link {
315
  color: #58a6ff;
316
  text-decoration: none;
317
}
318
319
.parent-link:hover {
320
  text-decoration: underline;
321
}
322
323
.repo-grid {
324
  display: grid;
325
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
326
  gap: 16px;
327
  margin-top: 20px;
328
}
329
330
.repo-card {
331
  background: #161b22;
332
  border: 1px solid #30363d;
333
  border-radius: 8px;
334
  padding: 20px;
335
  text-decoration: none;
336
  color: inherit;
337
  transition: border-color 0.2s, transform 0.1s;
338
}
339
340
.repo-card:hover {
341
  border-color: #58a6ff;
342
  transform: translateY(-2px);
343
}
344
345
.repo-card h3 {
346
  color: #58a6ff;
347
  margin-bottom: 8px;
348
  font-size: 1.1rem;
349
}
350
351
.repo-card p {
352
  color: #8b949e;
353
  font-size: 0.875rem;
354
  margin: 0;
355
}
356
357
.current-repo {
358
  background: #21262d;
359
  border: 1px solid #58a6ff;
360
  padding: 8px 16px;
361
  border-radius: 6px;
362
  font-size: 0.875rem;
363
  color: #f0f6fc;
364
}
365
366
.current-repo strong {
367
  color: #58a6ff;
368
}
369
370
.branch-badge {
371
  background: #238636;
372
  color: white;
373
  padding: 2px 8px;
374
  border-radius: 12px;
375
  font-size: 0.75rem;
376
  font-weight: 600;
377
  margin-left: 10px;
378
}
379
380
.commit-row {
381
  display: flex;
382
  padding: 10px 0;
383
  border-bottom: 1px solid #30363d;
384
  gap: 15px;
385
  align-items: baseline;
386
}
387
388
.commit-row:last-child {
389
  border-bottom: none;
390
}
391
392
.commit-row .sha {
393
  font-family: monospace;
394
  color: #58a6ff;
395
  text-decoration: none;
396
}
397
398
.commit-row .message {
399
  flex: 1;
400
  font-weight: 500;
401
}
402
403
.commit-row .meta {
404
  font-size: 0.85em;
405
  color: #8b949e;
406
  white-space: nowrap;
407
}
408
409
.blob-content-image {
410
  text-align: center;
411
  padding: 20px;
412
  background: #0d1117;
413
}
414
415
.blob-content-image img {
416
  max-width: 100%;
417
  border: 1px solid #30363d;
418
}
419
420
.blob-content-video {
421
  text-align: center;
422
  padding: 20px;
423
  background: #000;
424
}
425
426
.blob-content-video video {
427
  max-width: 100%;
428
  max-height: 80vh;
429
}
430
431
.blob-content-audio {
432
  text-align: center;
433
  padding: 40px;
434
  background: #161b22;
435
}
436
437
.blob-content-audio audio {
438
  width: 100%;
439
  max-width: 600px;
440
}
441
442
.download-state {
443
  text-align: center;
444
  padding: 40px;
445
  border: 1px solid #30363d;
446
  border-radius: 6px;
447
  margin-top: 10px;
448
}
449
450
.download-state p {
451
  margin-bottom: 20px;
452
  color: #8b949e;
453
}
454
455
.btn-download {
456
  display: inline-block;
457
  padding: 6px 16px;
458
  background: #238636;
459
  color: white;
460
  text-decoration: none;
461
  border-radius: 6px;
462
  font-weight: 600;
463
}
464
465
.repo-info-banner {
466
  margin-top: 15px;
467
}
468
469
.file-icon-container {
470
  width: 20px;
471
  text-align: center;
472
  margin-right: 5px;
473
  color: #8b949e;
474
}
475
476
.file-size {
477
  color: #8b949e;
478
  font-size: 0.8em;
479
  margin-left: 10px;
480
}
481
482
.file-date {
483
  color: #8b949e;
484
  font-size: 0.8em;
485
  margin-left: auto;
486
}
487
488
.repo-card-time {
489
  margin-top: 8px;
490
  color: #58a6ff;
491
}
492
493
.diff-container {
494
  display: flex;
495
  flex-direction: column;
496
  gap: 20px;
497
}
498
499
.diff-file {
500
  background: #161b22;
501
  border: 1px solid #30363d;
502
  border-radius: 6px;
503
  overflow: hidden;
504
}
505
506
.diff-header {
507
  background: #21262d;
508
  padding: 10px 16px;
509
  border-bottom: 1px solid #30363d;
510
  display: flex;
511
  align-items: center;
512
  gap: 10px;
513
}
514
515
.diff-path {
516
  font-family: monospace;
517
  font-size: 0.9rem;
518
  color: #f0f6fc;
519
}
520
521
.diff-binary {
522
  padding: 20px;
523
  text-align: center;
524
  color: #8b949e;
525
  font-style: italic;
526
}
527
528
.diff-content {
529
  overflow-x: auto;
530
}
531
532
.diff-content table {
533
  width: 100%;
534
  border-collapse: collapse;
535
  font-family: 'SFMono-Regular', Consolas, monospace;
536
  font-size: 12px;
537
}
538
539
.diff-content td {
540
  padding: 2px 0;
541
  line-height: 20px;
542
}
543
544
.diff-num {
545
  width: 1%;
546
  min-width: 40px;
547
  text-align: right;
548
  padding-right: 10px;
549
  color: #6e7681;
550
  user-select: none;
551
  background: #0d1117;
552
  border-right: 1px solid #30363d;
553
}
554
555
.diff-num::before {
556
  content: attr(data-num);
557
}
558
559
.diff-code {
560
  padding-left: 10px;
561
  white-space: pre-wrap;
562
  word-break: break-all;
563
  color: #c9d1d9;
564
}
565
566
.diff-marker {
567
  display: inline-block;
568
  width: 15px;
569
  user-select: none;
570
  color: #8b949e;
571
}
572
573
.diff-add {
574
  background-color: rgba(2, 59, 149, 0.25);
575
}
576
.diff-add .diff-code {
577
  color: #79c0ff;
578
}
579
.diff-add .diff-marker {
580
  color: #79c0ff;
581
}
582
583
.diff-del {
584
  background-color: rgba(148, 99, 0, 0.25);
585
}
586
.diff-del .diff-code {
587
  color: #d29922;
588
}
589
.diff-del .diff-marker {
590
  color: #d29922;
591
}
592
593
.diff-gap {
594
  background: #0d1117;
595
  color: #484f58;
596
  text-align: center;
597
  font-size: 0.8em;
598
  height: 20px;
599
}
600
.diff-gap td {
601
  padding: 8px 0;
602
  background: #161b22;
603
  border-top: 1px solid #30363d;
604
  border-bottom: 1px solid #30363d;
605
  text-align: center;
606
}
607
608
.diff-gap-icon {
609
  vertical-align: middle;
610
}
611
612
.status-add { color: #58a6ff; }
613
.status-del { color: #d29922; }
614
.status-mod { color: #a371f7; }
615
616
.tag-table, .file-list-table {
617
  width: 100%;
618
  border-collapse: collapse;
619
  margin-top: 10px;
620
  background: #161b22;
621
  border: 1px solid #30363d;
622
  border-radius: 6px;
623
  overflow: hidden;
624
}
625
626
.tag-table th, .file-list-table th {
627
  text-align: left;
628
  padding: 10px 16px;
629
  border-bottom: 2px solid #30363d;
630
  color: #8b949e;
631
  font-size: 0.875rem;
632
  font-weight: 600;
633
  white-space: nowrap;
634
}
635
636
.tag-table td, .file-list-table td {
637
  padding: 12px 16px;
638
  border-bottom: 1px solid #21262d;
639
  vertical-align: middle;
640
  color: #c9d1d9;
641
  font-size: 0.9rem;
642
}
643
644
.tag-table tr:hover td, .file-list-table tr:hover td {
645
  background: #161b22;
646
}
647
648
.tag-table .tag-name {
649
  min-width: 140px;
650
  width: 20%;
651
}
652
653
.tag-table .tag-message {
654
  width: auto;
655
  white-space: normal;
656
  word-break: break-word;
657
  color: #c9d1d9;
658
  font-weight: 500;
659
}
660
661
.tag-table .tag-author,
662
.tag-table .tag-time,
663
.tag-table .tag-hash {
664
  width: 1%;
665
  white-space: nowrap;
666
}
667
668
.tag-table .tag-time {
669
  text-align: right;
670
  color: #8b949e;
671
}
672
673
.tag-table .tag-hash {
674
  text-align: right;
675
}
676
677
.tag-table .tag-name a {
678
  color: #58a6ff;
679
  text-decoration: none;
680
  font-family: 'SFMono-Regular', Consolas, monospace;
681
}
682
683
.tag-table .tag-author {
684
  color: #c9d1d9;
685
}
686
687
.tag-table .tag-age-header {
688
  text-align: right;
689
}
690
691
.tag-table .tag-commit-header {
692
  text-align: right;
693
}
694
695
.tag-table .commit-hash {
696
  font-family: 'SFMono-Regular', Consolas, monospace;
697
  color: #58a6ff;
698
  text-decoration: none;
699
}
700
701
.tag-table .commit-hash:hover {
702
  text-decoration: underline;
703
}
704
705
.file-list-table .file-icon-cell {
706
  width: 20px;
707
  text-align: center;
708
  color: #8b949e;
709
  padding-right: 0;
710
}
711
712
.file-list-table .file-name-cell a {
713
  color: #58a6ff;
714
  text-decoration: none;
715
  font-weight: 500;
716
}
717
718
.file-list-table .file-name-cell a:hover {
719
  text-decoration: underline;
720
}
721
722
.file-list-table .file-mode-cell {
723
  font-family: 'SFMono-Regular', Consolas, monospace;
724
  color: #8b949e;
725
  font-size: 0.8rem;
726
  width: 1%;
727
  white-space: nowrap;
728
  text-align: center;
729
}
730
731
.file-list-table .file-size-cell {
732
  color: #8b949e;
733
  text-align: right;
734
  width: 1%;
735
  white-space: nowrap;
736
  font-size: 0.85rem;
737
}
738
739
.file-list-table .file-date-cell {
740
  color: #8b949e;
741
  text-align: right;
742
  width: 150px;
743
  font-size: 0.85rem;
744
  white-space: nowrap;
745
}
746
747
.blob-code {
748
  font-family: 'SFMono-Regular', Consolas, monospace;
749
  background-color: #161b22;
750
  color: #fcfcfa;
751
  font-size: 0.875rem;
752
  line-height: 1.6;
753
  tab-size: 2;
754
}
755
756
.hl-comment,
757
.hl-doc-comment {
758
  color: #727072;
759
  font-style: italic;
760
}
761
762
.hl-function,
763
.hl-method {
764
  color: #78dce8;
765
}
766
767
.hl-tag {
768
  color: #3e8bff;
769
}
770
771
.hl-class,
772
.hl-interface,
773
.hl-struct {
774
  color: #a9dc76;
775
}
776
777
.hl-type {
778
  color: #a9dc76;
779
}
780
781
.hl-keyword,
782
.hl-storage,
783
.hl-modifier,
784
.hl-statement {
785
  color: #ff6188;
786
  font-weight: 600;
787
}
788
789
.hl-string,
790
.hl-string_interp {
791
  color: #ffd866;
792
}
793
794
.hl-number,
795
.hl-boolean,
796
.hl-constant,
797
.hl-preprocessor {
798
  color: #ab9df2;
799
}
800
801
.hl-variable {
802
  color: #fcfcfa;
803
}
804
805
.hl-attribute,
806
.hl-property {
807
  color: #fc9867;
808
}
809
810
.hl-operator,
811
.hl-punctuation,
812
.hl-escape {
813
  color: #939293;
814
}
815
816
.hl-interp-punct {
817
  color: #ff6188;
818
}
819
820
.hl-math {
821
  color: #ab9df2;
822
  font-style: italic;
823
}
824
825
.hl-code {
826
  display: inline-block;
827
  width: 100%;
828
  background-color: #0d1117;
829
  color: #c9d1d9;
830
  padding: 2px 4px;
831
  border-radius: 3px;
832
}
833
834
@media (max-width: 768px) {
835
  .container {
836
    padding: 10px;
837
  }
838
839
  h1 { font-size: 1.5rem; }
840
  h2 { font-size: 1.2rem; }
841
842
  .nav {
843
    flex-direction: column;
844
    align-items: flex-start;
845
    gap: 10px;
846
  }
847
848
  .repo-selector {
849
    margin-left: 0;
850
    width: 100%;
851
  }
852
853
  .repo-selector select {
854
    flex: 1;
855
  }
856
857
  .file-list-table th,
858
  .file-list-table td {
859
    padding: 8px 10px;
860
  }
861
862
  .file-list-table .file-mode-cell,
863
  .file-list-table .file-date-cell {
864
    display: none;
865
  }
866
867
  .commit-details {
868
    padding: 15px;
869
  }
870
871
  .commit-title {
872
    font-size: 1.1rem;
873
    word-break: break-word;
874
  }
875
876
  .commit-info-table,
877
  .commit-info-table tbody,
878
  .commit-info-table tr,
879
  .commit-info-table th,
880
  .commit-info-table td {
881
    display: block;
882
  }
883
884
  .commit-info-table tr {
885
    margin-bottom: 10px;
886
  }
887
888
  .commit-info-label {
889
    width: 100%;
890
    font-size: 0.8rem;
891
    color: #8b949e;
892
    padding: 0 0 2px 0;
893
  }
894
895
  .commit-info-value {
896
    word-break: break-all;
897
    font-family: 'SFMono-Regular', Consolas, monospace;
898
    font-size: 0.9rem;
899
    padding: 0;
900
  }
901
902
  .commit-row {
903
    flex-direction: column;
904
    gap: 5px;
905
  }
906
907
  .commit-row .message {
908
    width: 100%;
909
    white-space: normal;
910
  }
911
912
  .commit-row .meta {
913
    font-size: 0.8rem;
914
  }
915
916
  .tag-table .tag-author,
917
  .tag-table .tag-time,
918
  .tag-table .tag-hash {
919
    font-size: 0.8rem;
920
  }
921
922
  .blob-code, .diff-content {
923
    overflow-x: scroll;
924
    -webkit-overflow-scrolling: touch;
925
  }
926
}
927
928
@media screen and (orientation: landscape) and (max-height: 600px) {
929
  .container {
930
    max-width: 100%;
931
  }
932
933
  header {
934
    margin-bottom: 15px;
935
    padding-bottom: 10px;
936
  }
937
938
  .file-list-table .file-date-cell {
939
    display: table-cell;
940
  }
941
}
942
943
.clone-checkbox {
944
  display: none;
945
}
946
947
.clone-link {
948
  cursor: pointer;
949
  color: #58a6ff;
950
  text-decoration: none;
951
}
952
953
.clone-link:hover {
954
  text-decoration: underline;
955
}
956
957
.clone-region {
958
  display: none;
959
  margin-top: 10px;
960
  padding: 10px;
961
  background-color: #161b22;
962
  border: 1px solid #30363d;
963
  border-radius: 6px;
964
}
965
966
.clone-checkbox:checked ~ .clone-region {
967
  display: block;
968
}
969
970
.clone-wrapper {
971
  display: inline-grid;
972
  vertical-align: top;
973
}
974
975
.clone-sizer {
976
  grid-area: 1 / 1;
977
  visibility: hidden;
978
  white-space: pre;
979
  font-family: monospace;
980
  font-size: 13px;
981
  padding: 8px;
982
  border: 1px solid transparent;
983
}
984
985
.clone-input {
986
  grid-area: 1 / 1;
987
  width: 100%;
988
  padding: 8px;
989
  background: #0d1117;
990
  color: #c9d1d9;
991
  border: 1px solid #30363d;
992
  border-radius: 4px;
993
  font-family: monospace;
994
  font-size: 13px;
995
  box-sizing: border-box;
996
}
997
998
.pagination {
999
  margin-top: 20px;
1000
  display: flex;
1001
  gap: 8px;
1002
  align-items: center;
1003
  justify-content: center;
1004
  flex-wrap: wrap;
1005
  font-variant-numeric: tabular-nums;
1006
}
1007
1008
.page-link {
1009
  display: inline-block;
1010
  min-width: calc(4ch + 16px);
1011
  text-align: center;
1012
  box-sizing: border-box;
1013
  color: #58a6ff;
1014
  text-decoration: none;
1015
  padding: 4px 8px;
1016
  border-radius: 6px;
1017
  border: 1px solid transparent;
1018
}
1019
1020
.page-link:hover {
1021
  background: #1f242c;
1022
  border-color: #30363d;
1023
}
1024
1025
.page-nav {
1026
  min-width: auto;
1027
  display: inline-flex;
1028
  align-items: center;
1029
  justify-content: center;
1030
}
1031
1032
.page-nav svg {
1033
  height: 1.25em;
1034
  width: auto;
1035
  vertical-align: middle;
1036
}
1037
1038
.page-nav-hidden {
1039
  visibility: hidden;
1040
  pointer-events: none;
1041
}
1042
1043
.page-badge {
1044
  display: inline-block;
1045
  min-width: calc(4ch + 16px);
1046
  text-align: center;
1047
  box-sizing: border-box;
1048
  background: #21262d;
1049
  color: #f0f6fc;
1050
  padding: 4px 8px;
1051
  border-radius: 6px;
1052
  border: 1px solid #58a6ff;
1053
  font-weight: 600;
9911054
}
9921055