Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M .gitignore
1 1
.htaccess
2
order.txt
2 3
A DiffPage.php
1
<?php
2
require_once 'GitDiff.php';
3
4
class DiffPage extends BasePage {
5
  private $currentRepo;
6
  private $git;
7
  private $hash;
8
9
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) {
10
    parent::__construct($repositories);
11
    $this->currentRepo = $currentRepo;
12
    $this->git = $git;
13
    $this->hash = $hash;
14
    $this->title = substr($hash, 0, 7);
15
  }
16
17
  public function render() {
18
    $this->renderLayout(function() {
19
      $commitData = $this->git->read($this->hash);
20
      $diffEngine = new GitDiff($this->git);
21
22
      $lines = explode("\n", $commitData);
23
      $msg = '';
24
      $isMsg = false;
25
      $headers = [];
26
      foreach ($lines as $line) {
27
        if ($line === '') { $isMsg = true; continue; }
28
        if ($isMsg) { $msg .= $line . "\n"; }
29
        else {
30
            if (preg_match('/^(\w+) (.*)$/', $line, $m)) $headers[$m[1]] = $m[2];
31
        }
32
      }
33
34
      $changes = $diffEngine->compare($this->hash);
35
36
      $this->renderBreadcrumbs();
37
38
      echo '<div class="commit-details">';
39
      echo '<div class="commit-header">';
40
      echo '<h1 class="commit-title">' . htmlspecialchars(trim($msg)) . '</h1>';
41
      echo '<div class="commit-info">';
42
      echo '<div class="commit-info-row"><span class="commit-info-label">Author</span><span class="commit-author">' . htmlspecialchars($headers['author'] ?? 'Unknown') . '</span></div>';
43
      echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span><span class="commit-info-value">' . $this->hash . '</span></div>';
44
      if (isset($headers['parent'])) {
45
          $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']);
46
          echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span><span class="commit-info-value">';
47
          echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl . '" class="parent-link">' . substr($headers['parent'], 0, 7) . '</a>';
48
          echo '</span></div>';
49
      }
50
      echo '</div></div></div>';
51
52
      echo '<div class="diff-container">';
53
      foreach ($changes as $change) {
54
        $this->renderFileDiff($change);
55
      }
56
      if (empty($changes)) {
57
          echo '<div class="empty-state"><p>No changes detected.</p></div>';
58
      }
59
      echo '</div>';
60
61
    }, $this->currentRepo);
62
  }
63
64
  private function renderFileDiff($change) {
65
    $statusIcon = 'fa-file';
66
    $statusClass = '';
67
68
    if ($change['type'] === 'A') { $statusIcon = 'fa-plus-circle'; $statusClass = 'status-add'; }
69
    if ($change['type'] === 'D') { $statusIcon = 'fa-minus-circle'; $statusClass = 'status-del'; }
70
    if ($change['type'] === 'M') { $statusIcon = 'fa-pencil-alt'; $statusClass = 'status-mod'; }
71
72
    echo '<div class="diff-file">';
73
    echo '<div class="diff-header">';
74
    echo '<span class="diff-status ' . $statusClass . '"><i class="fa ' . $statusIcon . '"></i></span>';
75
    echo '<span class="diff-path">' . htmlspecialchars($change['path']) . '</span>';
76
    echo '</div>';
77
78
    if ($change['is_binary']) {
79
        echo '<div class="diff-binary">Binary files differ</div>';
80
    } else {
81
        echo '<div class="diff-content">';
82
        echo '<table><tbody>';
83
84
        foreach ($change['hunks'] as $line) {
85
            if (isset($line['t']) && $line['t'] === 'gap') {
86
                echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
87
                continue;
88
            }
89
90
            $class = 'diff-ctx';
91
            $char = ' ';
92
            if ($line['t'] === '+') { $class = 'diff-add'; $char = '+'; }
93
            if ($line['t'] === '-') { $class = 'diff-del'; $char = '-'; }
94
95
            echo '<tr class="' . $class . '">';
96
            echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
97
            echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
98
            echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . htmlspecialchars($line['l']) . '</td>';
99
            echo '</tr>';
100
        }
101
        echo '</tbody></table>';
102
        echo '</div>';
103
    }
104
    echo '</div>';
105
  }
106
107
  private function renderBreadcrumbs() {
108
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
109
    $crumbs = [
110
      '<a href="?">Repositories</a>',
111
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
112
      '<a href="?action=commits' . $repoUrl . '">Commits</a>',
113
      substr($this->hash, 0, 7)
114
    ];
115
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
116
  }
117
}
1 118
M Git.php
1 1
<?php
2 2
require_once 'File.php';
3
4
class Git {
5
  private const CHUNK_SIZE  = 128;
6
  private const MAX_READ    = 16777216;
7
  private const MODE_TREE   = '40000';
8
  private const MODE_TREE_A = '040000';
9
10
  private string $path;
11
  private string $objPath;
12
  private array $packFiles;
13
14
  private array $fileHandles = [];
15
  private array $fanoutCache = [];
16
  private array $shaBucketCache = [];
17
  private array $offsetBucketCache = [];
18
  private ?string $lastPack = null;
19
20
  // Profiling
21
  private array $pStats = [];
22
  private array $pTimers = [];
23
24
  public function __construct( string $repoPath ) {
25
    $this->setRepository($repoPath);
26
  }
27
28
  public function __destruct() {
29
    foreach( $this->fileHandles as $handle ) {
30
      if( is_resource( $handle ) ) {
31
        fclose( $handle );
32
      }
33
    }
34
  }
35
36
  // --- Profiling Methods ---
37
38
  private function enter( string $name ): void {
39
    $this->pTimers[$name] = microtime( true );
40
  }
41
42
  private function leave( string $name ): void {
43
    if( !isset( $this->pTimers[$name] ) ) return;
44
45
    $elapsed = microtime( true ) - $this->pTimers[$name];
46
47
    // Initialize stat entry if missing
48
    if( !isset( $this->pStats[$name] ) ) {
49
      $this->pStats[$name] = ['cnt' => 0, 'time' => 0.0];
50
    }
51
52
    $this->pStats[$name]['cnt']++;
53
    $this->pStats[$name]['time'] += $elapsed;
54
    unset( $this->pTimers[$name] );
55
  }
56
57
  public function profileReport(): string {
58
    if (empty($this->pStats)) {
59
        return "<p>No profiling data collected.</p>";
60
    }
61
62
    // Sort by total time descending
63
    uasort($this->pStats, fn($a, $b) => $b['time'] <=> $a['time']);
64
65
    $html = '<table border="1" cellspacing="0" cellpadding="5" style="border-collapse: collapse; font-family: monospace; width: 100%;">';
66
    $html .= '<thead style="background: #eee;"><tr>';
67
    $html .= '<th style="text-align: left;">Method</th>';
68
    $html .= '<th style="text-align: right;">Calls</th>';
69
    $html .= '<th style="text-align: right;">Total (ms)</th>';
70
    $html .= '<th style="text-align: right;">Avg (ms)</th>';
71
    $html .= '</tr></thead><tbody>';
72
73
    foreach ($this->pStats as $name => $stat) {
74
        $totalMs = $stat['time'] * 1000;
75
        $avgMs = $stat['cnt'] > 0 ? $totalMs / $stat['cnt'] : 0;
76
77
        // Remove namespace/class prefix for cleaner display
78
        $cleanName = str_replace(__CLASS__ . '::', '', $name);
79
80
        $html .= '<tr>';
81
        $html .= '<td>' . htmlspecialchars($cleanName) . '</td>';
82
        $html .= '<td style="text-align: right;">' . $stat['cnt'] . '</td>';
83
        $html .= '<td style="text-align: right;">' . number_format($totalMs, 2) . '</td>';
84
        $html .= '<td style="text-align: right;">' . number_format($avgMs, 2) . '</td>';
85
        $html .= '</tr>';
86
    }
87
88
    $html .= '</tbody></table>';
89
90
    return $html;
91
  }
92
93
  // --- Core Methods (Instrumented) ---
94
95
  public function setRepository($repoPath) {
96
    $this->path = rtrim( $repoPath, '/' );
97
    $this->objPath = $this->path . '/objects';
98
    $this->packFiles = glob( "{$this->objPath}/pack/*.idx" ) ?: [];
99
  }
100
101
  public function getObjectSize( string $sha ): int {
102
    $this->enter( __METHOD__ );
103
    $info = $this->getPackOffset( $sha );
104
105
    if( $info['offset'] !== -1 ) {
106
      $res = $this->extractPackedSize( $info );
107
      $this->leave( __METHOD__ );
108
      return $res;
109
    }
110
111
    $prefix = substr( $sha, 0, 2 );
112
    $suffix = substr( $sha, 2 );
113
    $loosePath = "{$this->objPath}/{$prefix}/{$suffix}";
114
115
    $res = file_exists( $loosePath )
116
      ? $this->getLooseObjectSize( $loosePath )
117
      : 0;
118
119
    $this->leave( __METHOD__ );
120
    return $res;
121
  }
122
123
  private function getLooseObjectSize( string $path ): int {
124
    $this->enter( __METHOD__ );
125
    $size = 0;
126
    $fileHandle = @fopen( $path, 'rb' );
127
128
    if( $fileHandle ) {
129
      $data = $this->decompressHeader( $fileHandle );
130
      $header = explode( "\0", $data, 2 )[0];
131
      $parts = explode( ' ', $header );
132
      $size = isset( $parts[1] ) ? (int)$parts[1] : 0;
133
      fclose( $fileHandle );
134
    }
135
136
    $this->leave( __METHOD__ );
137
    return $size;
138
  }
139
140
  private function decompressHeader( $fileHandle ): string {
141
    $data = '';
142
    $inflateContext = inflate_init( ZLIB_ENCODING_DEFLATE );
143
144
    while( !feof( $fileHandle ) ) {
145
      $chunk = fread( $fileHandle, self::CHUNK_SIZE );
146
      $inflated = @inflate_add( $inflateContext, $chunk, ZLIB_NO_FLUSH );
147
148
      if( $inflated === false ) {
149
        break;
150
      }
151
152
      $data .= $inflated;
153
154
      if( strpos( $data, "\0" ) !== false ) {
155
        break;
156
      }
157
    }
158
159
    return $data;
160
  }
161
162
  private function getPackedObjectSize( string $sha ): int {
163
    $info = $this->getPackOffset( $sha );
164
165
    $size = ($info['offset'] !== -1)
166
      ? $this->extractPackedSize( $info )
167
      : 0;
168
169
    return $size;
170
  }
171
172
  private function extractPackedSize( array $info ): int {
173
    $this->enter( __METHOD__ );
174
    $targetSize = 0;
175
    $packPath = $info['file'];
176
177
    if( !isset( $this->fileHandles[$packPath] ) ) {
178
      $this->fileHandles[$packPath] = @fopen( $packPath, 'rb' );
179
    }
180
181
    $packFile = $this->fileHandles[$packPath];
182
183
    if( $packFile ) {
184
      fseek( $packFile, $info['offset'] );
185
      $buffer = fread( $packFile, 64 );
186
      $pos = 0;
187
188
      $byte = ord( $buffer[$pos++] );
189
      $type = ($byte >> 4) & 7;
190
      $targetSize = $byte & 15;
191
      $shift = 4;
192
193
      while( $byte & 128 ) {
194
        $byte = ord( $buffer[$pos++] );
195
        $targetSize |= (($byte & 127) << $shift);
196
        $shift += 7;
197
      }
198
199
      if( $type === 6 || $type === 7 ) {
200
        $targetSize = $this->readDeltaTargetSize( $packFile, $type, $buffer, $pos );
201
      }
202
    }
203
204
    $this->leave( __METHOD__ );
205
    return $targetSize;
206
  }
207
208
  private function readVarInt( $fileHandle ): array {
209
    $byte = ord( fread( $fileHandle, 1 ) );
210
    $value = $byte & 15;
211
    $shift = 4;
212
    $firstByte = $byte;
213
214
    while( $byte & 128 ) {
215
      $byte = ord( fread( $fileHandle, 1 ) );
216
      $value |= (($byte & 127) << $shift);
217
      $shift += 7;
218
    }
219
220
    return ['value' => $value, 'byte' => $firstByte];
221
  }
222
223
  private function readDeltaTargetSize( $fileHandle, int $type, string $buffer, int $pos ): int {
224
    $this->enter( __METHOD__ );
225
    if( $type === 6 ) {
226
      $byte = ord( $buffer[$pos++] );
227
      while( $byte & 128 ) {
228
        $byte = ord( $buffer[$pos++] );
229
      }
230
    } else {
231
      $pos += 20;
232
    }
233
234
    $inflateContext = inflate_init( ZLIB_ENCODING_DEFLATE );
235
    $headerData = '';
236
237
    if( $pos < strlen( $buffer ) ) {
238
      $chunk = substr( $buffer, $pos );
239
      $inflated = @inflate_add( $inflateContext, $chunk, ZLIB_NO_FLUSH );
240
      if( $inflated !== false ) {
241
        $headerData .= $inflated;
242
      }
243
    }
244
245
    while( !feof( $fileHandle ) && strlen( $headerData ) < 32 ) {
246
      if( inflate_get_status( $inflateContext ) === ZLIB_STREAM_END ) {
247
        break;
248
      }
249
250
      $inflated = @inflate_add(
251
        $inflateContext,
252
        fread( $fileHandle, 512 ),
253
        ZLIB_NO_FLUSH
254
      );
255
256
      if( $inflated !== false ) {
257
        $headerData .= $inflated;
258
      }
259
    }
260
261
    $result = 0;
262
    $position = 0;
263
264
    if( strlen( $headerData ) > 0 ) {
265
      $this->skipSize( $headerData, $position );
266
      $result = $this->readSize( $headerData, $position );
267
    }
268
269
    $this->leave( __METHOD__ );
270
    return $result;
271
  }
272
273
  public function getMainBranch(): array {
274
    $result = ['name' => '', 'hash' => ''];
275
    $branches = [];
276
    $this->eachBranch( function( $name, $sha ) use( &$branches ) {
277
      $branches[$name] = $sha;
278
    } );
279
280
    foreach( ['main', 'master', 'trunk', 'develop'] as $branch ) {
281
      if( isset( $branches[$branch] ) ) {
282
        $result = ['name' => $branch, 'hash' => $branches[$branch]];
283
        break;
284
      }
285
    }
286
287
    if( $result['name'] === '' ) {
288
      $firstKey = array_key_first( $branches );
289
290
      if( $firstKey !== null ) {
291
        $result = ['name' => $firstKey, 'hash' => $branches[$firstKey]];
292
      }
293
    }
294
295
    return $result;
296
  }
297
298
  public function eachBranch( callable $callback ): void {
299
    $this->scanRefs( 'refs/heads', $callback );
300
  }
301
302
  public function eachTag( callable $callback ): void {
303
    $this->scanRefs( 'refs/tags', $callback );
304
  }
305
306
  public function walk( string $refOrSha, callable $callback ): void {
307
    $sha = $this->resolve( $refOrSha );
308
    $data = ($sha !== '') ? $this->read( $sha ) : '';
309
310
    if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) {
311
      $data = $this->read( $matches[1] );
312
    }
313
314
    if( $this->isTreeData( $data ) ) {
315
      $this->processTree( $data, $callback );
316
    }
317
  }
318
319
  private function processTree( string $data, callable $callback ): void {
320
    $this->enter( __METHOD__ );
321
    $position = 0;
322
323
    while( $position < strlen( $data ) ) {
324
      $spacePos = strpos( $data, ' ', $position );
325
      $nullPos = strpos( $data, "\0", $spacePos );
326
327
      if( $spacePos === false || $nullPos === false ) {
328
        break;
329
      }
330
331
      $mode = substr( $data, $position, $spacePos - $position );
332
      $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
333
      $entrySha = bin2hex( substr( $data, $nullPos + 1, 20 ) );
334
335
      $isDir = ($mode === self::MODE_TREE || $mode === self::MODE_TREE_A);
336
337
      // Recursive call tracked
338
      $size = $isDir ? 0 : $this->getObjectSize( $entrySha );
339
340
      $callback( new File( $name, $entrySha, $mode, 0, $size ) );
341
      $position = $nullPos + 21;
342
    }
343
    $this->leave( __METHOD__ );
344
  }
345
346
  private function isTreeData( string $data ): bool {
347
    $result = false;
348
    $pattern = '/^(40000|100644|100755|120000|160000) /';
349
350
    if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) {
351
      $nullPos = strpos( $data, "\0" );
352
      $result = ($nullPos !== false && ($nullPos + 21 <= strlen( $data )));
353
    }
354
355
    return $result;
356
  }
357
358
  public function history( string $ref, int $limit, callable $cb ): void {
359
    $currentSha = $this->resolve( $ref );
360
    $count = 0;
361
362
    while( $currentSha !== '' && $count < $limit ) {
363
      $data = $this->read( $currentSha );
364
365
      if( $data === '' ) {
366
        break;
367
      }
368
369
      $pos = strpos( $data, "\n\n" );
370
      $message = ($pos !== false) ? substr( $data, $pos + 2 ) : '';
371
      preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $m );
372
373
      $cb( (object)[
374
        'sha'     => $currentSha,
375
        'message' => trim( $message ),
376
        'author'  => $m[1] ?? 'Unknown',
377
        'email'   => $m[2] ?? '',
378
        'date'    => (int)($m[3] ?? 0)
379
      ] );
380
381
      $currentSha = preg_match( '/^parent ([0-9a-f]{40})$/m', $data, $ms )
382
        ? $ms[1] : '';
383
      $count++;
384
    }
385
  }
386
387
  public function stream( string $sha, callable $callback ): void {
388
    $data = $this->read( $sha );
389
390
    if( $data !== '' ) {
391
      $callback( $data );
392
    }
393
  }
394
395
  public function resolve( string $input ): string {
396
    $this->enter( __METHOD__ );
397
    $result = '';
398
399
    if( preg_match( '/^[0-9a-f]{40}$/', $input ) ) {
400
      $result = $input;
401
    } elseif( $input === 'HEAD' &&
402
              file_exists( $headFile = "{$this->path}/HEAD" ) ) {
403
      $head = trim( file_get_contents( $headFile ) );
404
      $result = (strpos( $head, 'ref: ' ) === 0)
405
        ? $this->resolve( substr( $head, 5 ) ) : $head;
406
    } else {
407
      $result = $this->resolveRef( $input );
408
    }
409
410
    $this->leave( __METHOD__ );
411
    return $result;
412
  }
413
414
  private function resolveRef( string $input ): string {
415
    $found = '';
416
    $refPaths = [$input, "refs/heads/$input", "refs/tags/$input"];
417
418
    foreach( $refPaths as $path ) {
419
      if( file_exists( $filePath = "{$this->path}/$path" ) ) {
420
        $found = trim( file_get_contents( $filePath ) );
421
        break;
422
      }
423
    }
424
425
    if( $found === '' &&
426
        file_exists( $packed = "{$this->path}/packed-refs" ) ) {
427
      $found = $this->findInPackedRefs( $packed, $input );
428
    }
429
430
    return $found;
431
  }
432
433
  private function findInPackedRefs( string $path, string $input ): string {
434
    $result = '';
435
    $targets = [$input, "refs/heads/$input", "refs/tags/$input"];
436
437
    foreach( file( $path ) as $line ) {
438
      if( $line[0] === '#' || $line[0] === '^' ) {
439
        continue;
440
      }
441
442
      $parts = explode( ' ', trim( $line ) );
443
444
      if( count( $parts ) >= 2 && in_array( $parts[1], $targets ) ) {
445
        $result = $parts[0];
446
        break;
447
      }
448
    }
449
450
    return $result;
451
  }
452
453
  public function read( string $sha ): string {
454
    $this->enter( __METHOD__ );
455
    $result = '';
456
    $prefix = substr( $sha, 0, 2 );
457
    $suffix = substr( $sha, 2 );
458
    $loose = "{$this->objPath}/{$prefix}/{$suffix}";
459
460
    if( file_exists( $loose ) ) {
461
      $raw = file_get_contents( $loose );
462
      $inflated = $raw ? @gzuncompress( $raw ) : false;
463
      $result = $inflated ? explode( "\0", $inflated, 2 )[1] : '';
464
    } else {
465
      $result = $this->fromPack( $sha );
466
    }
467
468
    $this->leave( __METHOD__ );
469
    return $result;
470
  }
471
472
  private function fromPack( string $sha ): string {
473
    $info = $this->getPackOffset( $sha );
474
    $result = '';
475
476
    if( $info['offset'] !== -1 ) {
477
      $packPath = $info['file'];
478
479
      if( !isset( $this->fileHandles[$packPath] ) ) {
480
        $this->fileHandles[$packPath] = @fopen( $packPath, 'rb' );
481
      }
482
483
      $packFile = $this->fileHandles[$packPath];
484
485
      if( $packFile ) {
486
        $result = $this->readPackEntry( $packFile, $info['offset'] );
487
      }
488
    }
489
490
    return $result;
491
  }
492
493
  private function getPackOffset( string $sha ): array {
494
    $this->enter( __METHOD__ );
495
    $result = ['file' => '', 'offset' => -1];
496
497
    if( strlen( $sha ) === 40 && ctype_xdigit( $sha ) ) {
498
      $binSha = hex2bin( $sha );
499
500
      if( $this->lastPack ) {
501
        $offset = $this->findInPack( $this->lastPack, $binSha );
502
        if( $offset !== -1 ) {
503
          $this->leave( __METHOD__ );
504
          return [
505
            'file'   => str_replace( '.idx', '.pack', $this->lastPack ),
506
            'offset' => $offset
507
          ];
508
        }
509
      }
510
511
      foreach( $this->packFiles as $idxFile ) {
512
        if( $idxFile === $this->lastPack ) {
513
          continue;
514
        }
515
516
        $offset = $this->findInPack( $idxFile, $binSha );
517
518
        if( $offset !== -1 ) {
519
          $this->lastPack = $idxFile;
520
          $result = [
521
            'file'   => str_replace( '.idx', '.pack', $idxFile ),
522
            'offset' => $offset
523
          ];
524
          break;
525
        }
526
      }
527
    }
528
529
    $this->leave( __METHOD__ );
530
    return $result;
531
  }
532
533
  private function findInPack( string $idxFile, string $binSha ): int {
534
    $this->enter( __METHOD__ );
535
536
    if( !isset( $this->fileHandles[$idxFile] ) ) {
537
      $handle = @fopen( $idxFile, 'rb' );
538
      if( !$handle ) {
539
        $this->leave( __METHOD__ );
540
        return -1;
541
      }
542
543
      $this->fileHandles[$idxFile] = $handle;
544
      fseek( $handle, 0 );
545
546
      if( fread( $handle, 8 ) === "\377tOc\0\0\0\2" ) {
547
        $this->fanoutCache[$idxFile] = array_values( unpack( 'N*', fread( $handle, 1024 ) ) );
548
      } else {
549
        $this->fanoutCache[$idxFile] = null;
550
      }
551
    }
552
553
    $handle = $this->fileHandles[$idxFile];
554
    $fanout = $this->fanoutCache[$idxFile] ?? null;
555
556
    if( !$handle || !$fanout ) {
557
      $this->leave( __METHOD__ );
558
      return -1;
559
    }
560
561
    $firstByte = ord( $binSha[0] );
562
    $start = ($firstByte === 0) ? 0 : $fanout[$firstByte - 1];
563
    $end = $fanout[$firstByte];
564
565
    if( $end <= $start ) {
566
      $this->leave( __METHOD__ );
567
      return -1;
568
    }
569
570
    $cacheKey = "$idxFile:$firstByte";
571
572
    if( isset( $this->shaBucketCache[$cacheKey] ) ) {
573
      $shaBlock = $this->shaBucketCache[$cacheKey];
574
    } else {
575
      $count = $end - $start;
576
      fseek( $handle, 1032 + ($start * 20) );
577
      $shaBlock = fread( $handle, $count * 20 );
578
      $this->shaBucketCache[$cacheKey] = $shaBlock;
579
580
      $total = $fanout[255];
581
      $layer4Start = 1032 + ($total * 24);
582
583
      fseek( $handle, $layer4Start + ($start * 4) );
584
      $this->offsetBucketCache[$cacheKey] = fread( $handle, $count * 4 );
585
    }
586
587
    $count = strlen( $shaBlock ) / 20;
588
    $foundIdx = $this->searchShaBlock( $shaBlock, $count, $binSha );
589
590
    if( $foundIdx === -1 ) {
591
      $this->leave( __METHOD__ );
592
      return -1;
593
    }
594
595
    $offsetData = substr( $this->offsetBucketCache[$cacheKey], $foundIdx * 4, 4 );
596
    $offset = unpack( 'N', $offsetData )[1];
597
598
    if( $offset & 0x80000000 ) {
599
      $total = $fanout[255];
600
      $layer5Start = 1032 + ($total * 24) + ($total * 4);
601
      fseek( $handle, $layer5Start + (($offset & 0x7FFFFFFF) * 8) );
602
      $data64 = fread( $handle, 8 );
603
      $offset = $data64 ? unpack( 'J', $data64 )[1] : 0;
604
    }
605
606
    $this->leave( __METHOD__ );
607
    return (int)$offset;
608
  }
609
610
  private function searchShaBlock(
611
    string $shaBlock,
612
    int $count,
613
    string $binSha
614
  ): int {
615
    $low = 0;
616
    $high = $count - 1;
617
618
    while( $low <= $high ) {
619
      $mid = ($low + $high) >> 1;
620
      $currentSha = substr( $shaBlock, $mid * 20, 20 );
621
622
      if( $currentSha < $binSha ) {
623
        $low = $mid + 1;
624
      } elseif( $currentSha > $binSha ) {
625
        $high = $mid - 1;
626
      } else {
627
        return $mid;
628
      }
629
    }
630
631
    return -1;
632
  }
633
634
  private function readPackEntry( $fileHandle, int $offset ): string {
635
    $this->enter( __METHOD__ );
636
    fseek( $fileHandle, $offset );
637
    $header = $this->readVarInt( $fileHandle );
638
    $type = ($header['byte'] >> 4) & 7;
639
640
    if( $type === 6 ) {
641
      $res = $this->handleOfsDelta( $fileHandle, $offset );
642
      $this->leave( __METHOD__ );
643
      return $res;
644
    }
645
    if( $type === 7 ) {
646
      $res = $this->handleRefDelta( $fileHandle );
647
      $this->leave( __METHOD__ );
648
      return $res;
649
    }
650
651
    $inf = inflate_init( ZLIB_ENCODING_DEFLATE );
652
    $res = '';
653
654
    while( !feof( $fileHandle ) ) {
655
      $chunk = fread( $fileHandle, 8192 );
656
      $data = @inflate_add( $inf, $chunk );
657
658
      if( $data !== false ) $res .= $data;
659
      if( $data === false || ($inf && inflate_get_status( $inf ) === ZLIB_STREAM_END) ) break;
660
    }
661
662
    $this->leave( __METHOD__ );
663
    return $res;
664
  }
665
666
  private function deltaCopy(
667
    string $base, string $delta, int &$position, int $opcode
668
  ): string {
669
    $offset = 0;
670
    $length = 0;
671
672
    if( $opcode & 0x01 ) $offset |= ord( $delta[$position++] );
673
    if( $opcode & 0x02 ) $offset |= ord( $delta[$position++] ) << 8;
674
    if( $opcode & 0x04 ) $offset |= ord( $delta[$position++] ) << 16;
675
    if( $opcode & 0x08 ) $offset |= ord( $delta[$position++] ) << 24;
676
677
    if( $opcode & 0x10 ) $length |= ord( $delta[$position++] );
678
    if( $opcode & 0x20 ) $length |= ord( $delta[$position++] ) << 8;
679
    if( $opcode & 0x40 ) $length |= ord( $delta[$position++] ) << 16;
680
681
    if( $length === 0 ) $length = 0x10000;
682
683
    return substr( $base, $offset, $length );
684
  }
685
686
  private function handleOfsDelta( $fileHandle, int $offset ): string {
687
    $byte = ord( fread( $fileHandle, 1 ) );
688
    $negOffset = $byte & 127;
689
690
    while( $byte & 128 ) {
691
      $byte = ord( fread( $fileHandle, 1 ) );
692
      $negOffset = (($negOffset + 1) << 7) | ($byte & 127);
693
    }
694
695
    $currentPos = ftell( $fileHandle );
696
    $base = $this->readPackEntry( $fileHandle, $offset - $negOffset );
697
    fseek( $fileHandle, $currentPos );
698
699
    $delta = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: '';
700
701
    return $this->applyDelta( $base, $delta );
702
  }
703
704
  private function handleRefDelta( $fileHandle ): string {
705
    $base = $this->read( bin2hex( fread( $fileHandle, 20 ) ) );
706
    $delta = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: '';
707
708
    return $this->applyDelta( $base, $delta );
709
  }
710
711
  private function applyDelta( string $base, string $delta ): string {
712
    $this->enter( __METHOD__ );
713
    $out = '';
714
715
    if( $base !== '' && $delta !== '' ) {
716
      $position = 0;
717
      $this->skipSize( $delta, $position );
718
      $this->skipSize( $delta, $position );
719
720
      while( $position < strlen( $delta ) ) {
721
        $opcode = ord( $delta[$position++] );
722
723
        if( $opcode & 128 ) {
724
          $out .= $this->deltaCopy( $base, $delta, $position, $opcode );
725
        } else {
726
          $len = $opcode & 127;
727
          $out .= substr( $delta, $position, $len );
728
          $position += $len;
729
        }
730
      }
731
    }
732
733
    $this->leave( __METHOD__ );
734
    return $out;
735
  }
736
737
  private function skipSize( string $data, int &$position ): void {
738
    while( ord( $data[$position++] ) & 128 ) {
739
    }
740
  }
741
742
  private function readSize( string $data, int &$position ): int {
743
    $byte = ord( $data[$position++] );
744
    $value = $byte & 127;
745
    $shift = 7;
746
747
    while( $byte & 128 ) {
748
      $byte = ord( $data[$position++] );
749
      $value |= (($byte & 127) << $shift);
750
      $shift += 7;
751
    }
752
753
    return $value;
754
  }
755
756
  private function skipOffsetDelta( $fileHandle ): void {
757
    $byte = ord( fread( $fileHandle, 1 ) );
758
759
    while( $byte & 128 ) {
760
      $byte = ord( fread( $fileHandle, 1 ) );
761
    }
762
  }
763
764
  private function scanRefs( string $prefix, callable $callback ): void {
765
    $directory = "{$this->path}/$prefix";
766
767
    if( is_dir( $directory ) ) {
768
      foreach( array_diff( scandir( $directory ), ['.', '..'] ) as $fileName ) {
769
        $content = file_get_contents( "$directory/$fileName" );
770
        $callback( $fileName, trim( $content ) );
771
      }
772
    }
3
require_once 'GitRefs.php';
4
require_once 'GitPacks.php';
5
6
class Git {
7
  private const CHUNK_SIZE = 128;
8
9
  private string $repoPath;
10
  private string $objectsPath;
11
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->objectsPath = $this->repoPath . '/objects';
22
23
    $this->refs  = new GitRefs( $this->repoPath );
24
    $this->packs = new GitPacks( $this->objectsPath );
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', $callback );
41
  }
42
43
  public function getObjectSize( string $sha ): int {
44
    $size = $this->packs->getSize( $sha );
45
46
    if( $size !== null ) {
47
      return $size;
48
    }
49
50
    return $this->getLooseObjectSize( $sha );
51
  }
52
53
  public function read( string $sha ): string {
54
    $loosePath = $this->getLoosePath( $sha );
55
56
    if( file_exists( $loosePath ) ) {
57
      $rawContent = file_get_contents( $loosePath );
58
      $inflated   = $rawContent ? @gzuncompress( $rawContent ) : false;
59
60
      return $inflated ? explode( "\0", $inflated, 2 )[1] : '';
61
    }
62
63
    return $this->packs->read( $sha ) ?? '';
64
  }
65
66
  public function stream( string $sha, callable $callback ): void {
67
    $data = $this->read( $sha );
68
69
    if( $data !== '' ) {
70
      $callback( $data );
71
    }
72
  }
73
74
  public function history( string $ref, int $limit, callable $callback ): void {
75
    $currentSha = $this->resolve( $ref );
76
    $count      = 0;
77
78
    while( $currentSha !== '' && $count < $limit ) {
79
      $data = $this->read( $currentSha );
80
81
      if( $data === '' ) {
82
        break;
83
      }
84
85
      $position = strpos( $data, "\n\n" );
86
      $message  = $position !== false ? substr( $data, $position + 2 ) : '';
87
      preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $matches );
88
89
      $callback( (object)[
90
        'sha'     => $currentSha,
91
        'message' => trim( $message ),
92
        'author'  => $matches[1] ?? 'Unknown',
93
        'email'   => $matches[2] ?? '',
94
        'date'    => (int)( $matches[3] ?? 0 )
95
      ] );
96
97
      $currentSha = preg_match(
98
        '/^parent ([0-9a-f]{40})$/m',
99
        $data,
100
        $parentMatches
101
      ) ? $parentMatches[1] : '';
102
103
      $count++;
104
    }
105
  }
106
107
  public function walk( string $refOrSha, callable $callback ): void {
108
    $sha  = $this->resolve( $refOrSha );
109
    $data = $sha !== '' ? $this->read( $sha ) : '';
110
111
    if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) {
112
      $data = $this->read( $matches[1] );
113
    }
114
115
    if( $this->isTreeData( $data ) ) {
116
      $this->processTree( $data, $callback );
117
    }
118
  }
119
120
  private function processTree( string $data, callable $callback ): void {
121
    $position = 0;
122
    $length   = strlen( $data );
123
124
    while( $position < $length ) {
125
      $spacePos = strpos( $data, ' ', $position );
126
      $nullPos  = strpos( $data, "\0", $spacePos );
127
128
      if( $spacePos === false || $nullPos === false ) {
129
        break;
130
      }
131
132
      $mode = substr( $data, $position, $spacePos - $position );
133
      $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
134
      $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );
135
136
      $isDirectory = $mode === '40000' || $mode === '040000';
137
      $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
138
139
      $callback( new File( $name, $sha, $mode, 0, $size ) );
140
141
      $position = $nullPos + 21;
142
    }
143
  }
144
145
  private function isTreeData( string $data ): bool {
146
    $pattern = '/^(40000|100644|100755|120000|160000) /';
147
148
    if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) {
149
      $nullPos = strpos( $data, "\0" );
150
151
      return $nullPos !== false && ($nullPos + 21 <= strlen( $data ));
152
    }
153
154
    return false;
155
  }
156
157
  private function getLoosePath( string $sha ): string {
158
    return "{$this->objectsPath}/" . substr( $sha, 0, 2 ) . "/" .
159
           substr( $sha, 2 );
160
  }
161
162
  private function getLooseObjectSize( string $sha ): int {
163
    $path = $this->getLoosePath( $sha );
164
165
    if( !file_exists( $path ) ) {
166
      return 0;
167
    }
168
169
    $fileHandle = @fopen( $path, 'rb' );
170
171
    if( !$fileHandle ) {
172
      return 0;
173
    }
174
175
    $data     = '';
176
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
177
178
    while( !feof( $fileHandle ) ) {
179
      $chunk  = fread( $fileHandle, self::CHUNK_SIZE );
180
      $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH );
181
182
      if( $output === false ) {
183
        break;
184
      }
185
186
      $data .= $output;
187
188
      if( strpos( $data, "\0" ) !== false ) {
189
        break;
190
      }
191
    }
192
193
    fclose( $fileHandle );
194
195
    $header = explode( "\0", $data, 2 )[0];
196
    $parts  = explode( ' ', $header );
197
198
    return isset( $parts[1] ) ? (int)$parts[1] : 0;
773 199
  }
774 200
}
A GitDiff.php
1
<?php
2
require_once 'File.php';
3
4
class GitDiff {
5
  private $git;
6
7
  public function __construct(Git $git) {
8
    $this->git = $git;
9
  }
10
11
  public function compare(string $commitHash) {
12
    $commitData = $this->git->read($commitHash);
13
    $parentHash = '';
14
15
    if (preg_match('/^parent ([0-9a-f]{40})/m', $commitData, $matches)) {
16
      $parentHash = $matches[1];
17
    }
18
19
    $newTree = $this->getTreeHash($commitHash);
20
    $oldTree = $parentHash ? $this->getTreeHash($parentHash) : null;
21
22
    return $this->diffTrees($oldTree, $newTree);
23
  }
24
25
  private function getTreeHash($commitSha) {
26
    $data = $this->git->read($commitSha);
27
    if (preg_match('/^tree ([0-9a-f]{40})/m', $data, $matches)) {
28
      return $matches[1];
29
    }
30
    return null;
31
  }
32
33
  private function diffTrees($oldTreeSha, $newTreeSha, $path = '') {
34
    $changes = [];
35
36
    if ($oldTreeSha === $newTreeSha) return [];
37
38
    $oldEntries = $oldTreeSha ? $this->parseTree($oldTreeSha) : [];
39
    $newEntries = $newTreeSha ? $this->parseTree($newTreeSha) : [];
40
41
    $allNames = array_unique(array_merge(array_keys($oldEntries), array_keys($newEntries)));
42
    sort($allNames);
43
44
    foreach ($allNames as $name) {
45
      $old = $oldEntries[$name] ?? null;
46
      $new = $newEntries[$name] ?? null;
47
      $currentPath = $path ? "$path/$name" : $name;
48
49
      if (!$old) {
50
        if ($new['is_dir']) {
51
           $changes = array_merge($changes, $this->diffTrees(null, $new['sha'], $currentPath));
52
        } else {
53
           $changes[] = $this->createChange('A', $currentPath, null, $new['sha']);
54
        }
55
      } elseif (!$new) {
56
        if ($old['is_dir']) {
57
           $changes = array_merge($changes, $this->diffTrees($old['sha'], null, $currentPath));
58
        } else {
59
           $changes[] = $this->createChange('D', $currentPath, $old['sha'], null);
60
        }
61
      } elseif ($old['sha'] !== $new['sha']) {
62
        if ($old['is_dir'] && $new['is_dir']) {
63
          $changes = array_merge($changes, $this->diffTrees($old['sha'], $new['sha'], $currentPath));
64
        } elseif (!$old['is_dir'] && !$new['is_dir']) {
65
          $changes[] = $this->createChange('M', $currentPath, $old['sha'], $new['sha']);
66
        }
67
      }
68
    }
69
70
    return $changes;
71
  }
72
73
  private function parseTree($sha) {
74
    $data = $this->git->read($sha);
75
    $entries = [];
76
    $len = strlen($data);
77
    $pos = 0;
78
79
    while ($pos < $len) {
80
      $space = strpos($data, ' ', $pos);
81
      $null = strpos($data, "\0", $space);
82
83
      if ($space === false || $null === false) break;
84
85
      $mode = substr($data, $pos, $space - $pos);
86
      $name = substr($data, $space + 1, $null - $space - 1);
87
      $hash = bin2hex(substr($data, $null + 1, 20));
88
89
      $entries[$name] = [
90
        'mode' => $mode,
91
        'sha' => $hash,
92
        'is_dir' => ($mode === '40000' || $mode === '040000')
93
      ];
94
95
      $pos = $null + 21;
96
    }
97
    return $entries;
98
  }
99
100
  private function createChange($type, $path, $oldSha, $newSha) {
101
    $oldContent = $oldSha ? $this->git->read($oldSha) : '';
102
    $newContent = $newSha ? $this->git->read($newSha) : '';
103
104
    $isBinary = false;
105
106
    if ($newSha) {
107
        $f = new VirtualDiffFile($path, $newContent);
108
        if ($f->isBinary()) $isBinary = true;
109
    }
110
    if (!$isBinary && $oldSha) {
111
        $f = new VirtualDiffFile($path, $oldContent);
112
        if ($f->isBinary()) $isBinary = true;
113
    }
114
115
    $diff = null;
116
    if (!$isBinary) {
117
      $diff = $this->calculateDiff($oldContent, $newContent);
118
    }
119
120
    return [
121
      'type' => $type,
122
      'path' => $path,
123
      'is_binary' => $isBinary,
124
      'hunks' => $diff
125
    ];
126
  }
127
128
  private function calculateDiff($old, $new) {
129
    // Normalize line endings
130
    $old = str_replace("\r\n", "\n", $old);
131
    $new = str_replace("\r\n", "\n", $new);
132
133
    $oldLines = explode("\n", $old);
134
    $newLines = explode("\n", $new);
135
136
    $m = count($oldLines);
137
    $n = count($newLines);
138
139
    // LCS Algorithm
140
    $start = 0;
141
    while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) {
142
      $start++;
143
    }
144
145
    $end = 0;
146
    while ($m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]) {
147
      $end++;
148
    }
149
150
    $oldSlice = array_slice($oldLines, $start, $m - $start - $end);
151
    $newSlice = array_slice($newLines, $start, $n - $start - $end);
152
153
    $ops = $this->computeLCS($oldSlice, $newSlice);
154
155
    // Grouping Optimization: Reorder interleaved +/- to be - then +
156
    $groupedOps = [];
157
    $bufferDel = [];
158
    $bufferAdd = [];
159
160
    foreach ($ops as $op) {
161
        if ($op['t'] === ' ') {
162
            foreach ($bufferDel as $o) $groupedOps[] = $o;
163
            foreach ($bufferAdd as $o) $groupedOps[] = $o;
164
            $bufferDel = [];
165
            $bufferAdd = [];
166
            $groupedOps[] = $op;
167
        } elseif ($op['t'] === '-') {
168
            $bufferDel[] = $op;
169
        } elseif ($op['t'] === '+') {
170
            $bufferAdd[] = $op;
171
        }
172
    }
173
    foreach ($bufferDel as $o) $groupedOps[] = $o;
174
    foreach ($bufferAdd as $o) $groupedOps[] = $o;
175
    $ops = $groupedOps;
176
177
    // Generate Stream with Context
178
    $stream = [];
179
180
    // Prefix context
181
    for ($i = 0; $i < $start; $i++) {
182
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1];
183
    }
184
185
    $currO = $start + 1;
186
    $currN = $start + 1;
187
188
    foreach ($ops as $op) {
189
        if ($op['t'] === ' ') {
190
            $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++];
191
        } elseif ($op['t'] === '-') {
192
            $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null];
193
        } elseif ($op['t'] === '+') {
194
            $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++];
195
        }
196
    }
197
198
    // Suffix context
199
    for ($i = $m - $end; $i < $m; $i++) {
200
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++];
201
    }
202
203
    // Filter to Hunks
204
    $finalLines = [];
205
    $lastVisibleIndex = -1;
206
    $streamLen = count($stream);
207
    $contextLines = 3;
208
209
    for ($i = 0; $i < $streamLen; $i++) {
210
        $show = false;
211
212
        if ($stream[$i]['t'] !== ' ') {
213
            $show = true;
214
        } else {
215
            // Check ahead
216
            for ($j = 1; $j <= $contextLines; $j++) {
217
                if (($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ') {
218
                    $show = true;
219
                    break;
220
                }
221
            }
222
            // Check behind
223
            if (!$show) {
224
                for ($j = 1; $j <= $contextLines; $j++) {
225
                    if (($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ') {
226
                        $show = true;
227
                        break;
228
                    }
229
                }
230
            }
231
        }
232
233
        if ($show) {
234
            if ($lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1) {
235
                $finalLines[] = ['t' => 'gap'];
236
            }
237
            $finalLines[] = $stream[$i];
238
            $lastVisibleIndex = $i;
239
        }
240
    }
241
242
    return $finalLines;
243
  }
244
245
  private function computeLCS($old, $new) {
246
    $m = count($old);
247
    $n = count($new);
248
    $c = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
249
250
    for ($i = 1; $i <= $m; $i++) {
251
      for ($j = 1; $j <= $n; $j++) {
252
        if ($old[$i-1] === $new[$j-1]) {
253
          $c[$i][$j] = $c[$i-1][$j-1] + 1;
254
        } else {
255
          $c[$i][$j] = max($c[$i][$j-1], $c[$i-1][$j]);
256
        }
257
      }
258
    }
259
260
    $diff = [];
261
    $i = $m; $j = $n;
262
    while ($i > 0 || $j > 0) {
263
      if ($i > 0 && $j > 0 && $old[$i-1] === $new[$j-1]) {
264
        array_unshift($diff, ['t' => ' ', 'l' => $old[$i-1]]);
265
        $i--; $j--;
266
      } elseif ($j > 0 && ($i === 0 || $c[$i][$j-1] >= $c[$i-1][$j])) {
267
        array_unshift($diff, ['t' => '+', 'l' => $new[$j-1]]);
268
        $j--;
269
      } elseif ($i > 0 && ($j === 0 || $c[$i][$j-1] < $c[$i-1][$j])) {
270
        array_unshift($diff, ['t' => '-', 'l' => $old[$i-1]]);
271
        $i--;
272
      }
273
    }
274
    return $diff;
275
  }
276
}
277
278
class VirtualDiffFile extends File {
279
  private $content;
280
  private $vName;
281
282
  public function __construct($name, $content) {
283
    parent::__construct($name, '', '100644', 0, strlen($content));
284
    $this->vName = $name;
285
    $this->content = $content;
286
  }
287
288
  public function isBinary(): bool {
289
    $buffer = substr($this->content, 0, 12);
290
    return MediaTypeSniffer::isBinary($buffer, $this->vName);
291
  }
292
}
1 293
A GitPacks.php
1
<?php
2
class GitPacks {
3
  private const MAX_READ = 16777216;
4
5
  private string $objectsPath;
6
  private array $packFiles;
7
  private ?string $lastPack = null;
8
9
  private array $fileHandles       = [];
10
  private array $fanoutCache       = [];
11
  private array $shaBucketCache    = [];
12
  private array $offsetBucketCache = [];
13
14
  public function __construct( string $objectsPath ) {
15
    $this->objectsPath = $objectsPath;
16
    $this->packFiles   = glob( "{$this->objectsPath}/pack/*.idx" ) ?: [];
17
  }
18
19
  public function __destruct() {
20
    foreach( $this->fileHandles as $handle ) {
21
      if( is_resource( $handle ) ) {
22
        fclose( $handle );
23
      }
24
    }
25
  }
26
27
  public function read( string $sha ): ?string {
28
    $info = $this->findPackInfo( $sha );
29
30
    if( $info['offset'] === -1 ) {
31
      return null;
32
    }
33
34
    $handle = $this->getHandle( $info['file'] );
35
36
    return $handle
37
      ? $this->readPackEntry( $handle, $info['offset'] )
38
      : null;
39
  }
40
41
  public function getSize( string $sha ): ?int {
42
    $info = $this->findPackInfo( $sha );
43
44
    if( $info['offset'] === -1 ) {
45
      return null;
46
    }
47
48
    return $this->extractPackedSize( $info['file'], $info['offset'] );
49
  }
50
51
  private function findPackInfo( string $sha ): array {
52
    if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) {
53
      return ['offset' => -1];
54
    }
55
56
    $binarySha = hex2bin( $sha );
57
58
    if( $this->lastPack ) {
59
      $offset = $this->findInIdx( $this->lastPack, $binarySha );
60
61
      if( $offset !== -1 ) {
62
        return $this->makeResult( $this->lastPack, $offset );
63
      }
64
    }
65
66
    foreach( $this->packFiles as $indexFile ) {
67
      if( $indexFile === $this->lastPack ) {
68
        continue;
69
      }
70
71
      $offset = $this->findInIdx( $indexFile, $binarySha );
72
73
      if( $offset !== -1 ) {
74
        $this->lastPack = $indexFile;
75
76
        return $this->makeResult( $indexFile, $offset );
77
      }
78
    }
79
80
    return ['offset' => -1];
81
  }
82
83
  private function makeResult( string $indexPath, int $offset ): array {
84
    return [
85
      'file'   => str_replace( '.idx', '.pack', $indexPath ),
86
      'offset' => $offset
87
    ];
88
  }
89
90
  private function findInIdx( string $indexFile, string $binarySha ): int {
91
    $fileHandle = $this->getHandle( $indexFile );
92
93
    if( !$fileHandle ) {
94
      return -1;
95
    }
96
97
    if( !isset( $this->fanoutCache[$indexFile] ) ) {
98
      fseek( $fileHandle, 0 );
99
100
      if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) {
101
        $this->fanoutCache[$indexFile] = array_values(
102
          unpack( 'N*', fread( $fileHandle, 1024 ) )
103
        );
104
      } else {
105
        return -1;
106
      }
107
    }
108
109
    $fanout = $this->fanoutCache[$indexFile];
110
111
    $firstByte = ord( $binarySha[0] );
112
    $start     = $firstByte === 0 ? 0 : $fanout[$firstByte - 1];
113
    $end       = $fanout[$firstByte];
114
115
    if( $end <= $start ) {
116
      return -1;
117
    }
118
119
    $cacheKey = "$indexFile:$firstByte";
120
121
    if( !isset( $this->shaBucketCache[$cacheKey] ) ) {
122
      $count = $end - $start;
123
      fseek( $fileHandle, 1032 + ($start * 20) );
124
      $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 );
125
126
      fseek(
127
        $fileHandle,
128
        1032 + ($fanout[255] * 24) + ($start * 4)
129
      );
130
      $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 );
131
    }
132
133
    $shaBlock  = $this->shaBucketCache[$cacheKey];
134
    $count     = strlen( $shaBlock ) / 20;
135
    $low       = 0;
136
    $high      = $count - 1;
137
    $foundIdx  = -1;
138
139
    while( $low <= $high ) {
140
      $mid     = ($low + $high) >> 1;
141
      $compare = substr( $shaBlock, $mid * 20, 20 );
142
143
      if( $compare < $binarySha ) {
144
        $low = $mid + 1;
145
      } elseif( $compare > $binarySha ) {
146
        $high = $mid - 1;
147
      } else {
148
        $foundIdx = $mid;
149
        break;
150
      }
151
    }
152
153
    if( $foundIdx === -1 ) {
154
      return -1;
155
    }
156
157
    $offsetData = substr(
158
      $this->offsetBucketCache[$cacheKey],
159
      $foundIdx * 4,
160
      4
161
    );
162
    $offset = unpack( 'N', $offsetData )[1];
163
164
    if( $offset & 0x80000000 ) {
165
      $packTotal = $fanout[255];
166
      $pos64     = 1032 + ($packTotal * 28) +
167
                   (($offset & 0x7FFFFFFF) * 8);
168
      fseek( $fileHandle, $pos64 );
169
      $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1];
170
    }
171
172
    return (int)$offset;
173
  }
174
175
  // $fileHandle is resource, no type hint used for compatibility
176
  private function readPackEntry( $fileHandle, int $offset ): string {
177
    fseek( $fileHandle, $offset );
178
179
    $header = $this->readVarInt( $fileHandle );
180
    $type   = ($header['byte'] >> 4) & 7;
181
182
    if( $type === 6 ) {
183
      return $this->handleOfsDelta( $fileHandle, $offset );
184
    }
185
186
    if( $type === 7 ) {
187
      return $this->handleRefDelta( $fileHandle );
188
    }
189
190
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
191
    $result   = '';
192
193
    while( !feof( $fileHandle ) ) {
194
      $chunk = fread( $fileHandle, 8192 );
195
      $data  = @inflate_add( $inflator, $chunk );
196
197
      if( $data !== false ) {
198
        $result .= $data;
199
      }
200
201
      if(
202
        $data === false ||
203
        inflate_get_status( $inflator ) === ZLIB_STREAM_END
204
      ) {
205
        break;
206
      }
207
    }
208
209
    return $result;
210
  }
211
212
  private function extractPackedSize( string $packPath, int $offset ): int {
213
    $fileHandle = $this->getHandle( $packPath );
214
215
    if( !$fileHandle ) {
216
      return 0;
217
    }
218
219
    fseek( $fileHandle, $offset );
220
221
    $header = $this->readVarInt( $fileHandle );
222
    $size   = $header['value'];
223
    $type   = ($header['byte'] >> 4) & 7;
224
225
    if( $type === 6 || $type === 7 ) {
226
      return $this->readDeltaTargetSize( $fileHandle, $type );
227
    }
228
229
    return $size;
230
  }
231
232
  private function handleOfsDelta( $fileHandle, int $offset ): string {
233
    $byte     = ord( fread( $fileHandle, 1 ) );
234
    $negative = $byte & 127;
235
236
    while( $byte & 128 ) {
237
      $byte     = ord( fread( $fileHandle, 1 ) );
238
      $negative = (($negative + 1) << 7) | ($byte & 127);
239
    }
240
241
    $currentPos = ftell( $fileHandle );
242
    $base       = $this->readPackEntry( $fileHandle, $offset - $negative );
243
244
    fseek( $fileHandle, $currentPos );
245
246
    $delta = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: '';
247
248
    return $this->applyDelta( $base, $delta );
249
  }
250
251
  private function handleRefDelta( $fileHandle ): string {
252
    $baseSha = bin2hex( fread( $fileHandle, 20 ) );
253
    $base    = $this->read( $baseSha ) ?? '';
254
    $delta   = @gzuncompress( fread( $fileHandle, self::MAX_READ ) ) ?: '';
255
256
    return $this->applyDelta( $base, $delta );
257
  }
258
259
  private function applyDelta( string $base, string $delta ): string {
260
    $position = 0;
261
    $this->skipSize( $delta, $position );
262
    $this->skipSize( $delta, $position );
263
264
    $output       = '';
265
    $deltaLength  = strlen( $delta );
266
267
    while( $position < $deltaLength ) {
268
      $opcode = ord( $delta[$position++] );
269
270
      if( $opcode & 128 ) {
271
        $offset = 0;
272
        $length = 0;
273
274
        if( $opcode & 0x01 ) {
275
          $offset |= ord( $delta[$position++] );
276
        }
277
        if( $opcode & 0x02 ) {
278
          $offset |= ord( $delta[$position++] ) << 8;
279
        }
280
        if( $opcode & 0x04 ) {
281
          $offset |= ord( $delta[$position++] ) << 16;
282
        }
283
        if( $opcode & 0x08 ) {
284
          $offset |= ord( $delta[$position++] ) << 24;
285
        }
286
287
        if( $opcode & 0x10 ) {
288
          $length |= ord( $delta[$position++] );
289
        }
290
        if( $opcode & 0x20 ) {
291
          $length |= ord( $delta[$position++] ) << 8;
292
        }
293
        if( $opcode & 0x40 ) {
294
          $length |= ord( $delta[$position++] ) << 16;
295
        }
296
297
        if( $length === 0 ) {
298
          $length = 0x10000;
299
        }
300
301
        $output .= substr( $base, $offset, $length );
302
      } else {
303
        $length = $opcode & 127;
304
        $output .= substr( $delta, $position, $length );
305
        $position += $length;
306
      }
307
    }
308
309
    return $output;
310
  }
311
312
  private function readVarInt( $fileHandle ): array {
313
    $byte  = ord( fread( $fileHandle, 1 ) );
314
    $value = $byte & 15;
315
    $shift = 4;
316
    $first = $byte;
317
318
    while( $byte & 128 ) {
319
      $byte = ord( fread( $fileHandle, 1 ) );
320
      $value |= (($byte & 127) << $shift);
321
      $shift += 7;
322
    }
323
324
    return ['value' => $value, 'byte' => $first];
325
  }
326
327
  private function readDeltaTargetSize( $fileHandle, int $type ): int {
328
    if( $type === 6 ) {
329
      $byte = ord( fread( $fileHandle, 1 ) );
330
331
      while( $byte & 128 ) {
332
        $byte = ord( fread( $fileHandle, 1 ) );
333
      }
334
    } else {
335
      fseek( $fileHandle, 20, SEEK_CUR );
336
    }
337
338
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
339
    $header   = '';
340
341
    while( !feof( $fileHandle ) && strlen( $header ) < 32 ) {
342
      $chunk  = fread( $fileHandle, 512 );
343
      $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH );
344
345
      if( $output !== false ) {
346
        $header .= $output;
347
      }
348
349
      if( inflate_get_status( $inflator ) === ZLIB_STREAM_END ) {
350
        break;
351
      }
352
    }
353
354
    $position = 0;
355
356
    if( strlen( $header ) > 0 ) {
357
      $this->skipSize( $header, $position );
358
359
      return $this->readSize( $header, $position );
360
    }
361
362
    return 0;
363
  }
364
365
  private function skipSize( string $data, int &$position ): void {
366
    while( ord( $data[$position++] ) & 128 ) {
367
      // Empty loop body
368
    }
369
  }
370
371
  private function readSize( string $data, int &$position ): int {
372
    $byte  = ord( $data[$position++] );
373
    $value = $byte & 127;
374
    $shift = 7;
375
376
    while( $byte & 128 ) {
377
      $byte = ord( $data[$position++] );
378
      $value |= (($byte & 127) << $shift);
379
      $shift += 7;
380
    }
381
382
    return $value;
383
  }
384
385
  private function getHandle( string $path ) {
386
    if( !isset( $this->fileHandles[$path] ) ) {
387
      $this->fileHandles[$path] = @fopen( $path, 'rb' );
388
    }
389
390
    return $this->fileHandles[$path];
391
  }
392
}
1 393
A GitRefs.php
1
<?php
2
class GitRefs {
3
  private string $repoPath;
4
5
  public function __construct( string $repoPath ) {
6
    $this->repoPath = $repoPath;
7
  }
8
9
  public function resolve( string $input ): string {
10
    if( preg_match( '/^[0-9a-f]{40}$/', $input ) ) {
11
      return $input;
12
    }
13
14
    $headFile = "{$this->repoPath}/HEAD";
15
16
    if( $input === 'HEAD' && file_exists( $headFile ) ) {
17
      $head = trim( file_get_contents( $headFile ) );
18
19
      return strpos( $head, 'ref: ' ) === 0
20
        ? $this->resolve( substr( $head, 5 ) )
21
        : $head;
22
    }
23
24
    return $this->resolveRef( $input );
25
  }
26
27
  public function getMainBranch(): array {
28
    $branches = [];
29
30
    $this->scanRefs(
31
      'refs/heads',
32
      function( string $name, string $sha ) use ( &$branches ) {
33
        $branches[$name] = $sha;
34
      }
35
    );
36
37
    foreach( ['main', 'master', 'trunk', 'develop'] as $try ) {
38
      if( isset( $branches[$try] ) ) {
39
        return ['name' => $try, 'hash' => $branches[$try]];
40
      }
41
    }
42
43
    $firstKey = array_key_first( $branches );
44
45
    return $firstKey
46
      ? ['name' => $firstKey, 'hash' => $branches[$firstKey]]
47
      : ['name' => '', 'hash' => ''];
48
  }
49
50
  public function scanRefs( string $prefix, callable $callback ): void {
51
    $dir = "{$this->repoPath}/$prefix";
52
53
    if( is_dir( $dir ) ) {
54
      $files = array_diff( scandir( $dir ), ['.', '..'] );
55
56
      foreach( $files as $file ) {
57
        $callback( $file, trim( file_get_contents( "$dir/$file" ) ) );
58
      }
59
    }
60
  }
61
62
  private function resolveRef( string $input ): string {
63
    $paths = [$input, "refs/heads/$input", "refs/tags/$input"];
64
65
    foreach( $paths as $ref ) {
66
      $path = "{$this->repoPath}/$ref";
67
68
      if( file_exists( $path ) ) {
69
        return trim( file_get_contents( $path ) );
70
      }
71
    }
72
73
    $packedPath = "{$this->repoPath}/packed-refs";
74
75
    return file_exists( $packedPath )
76
      ? $this->findInPackedRefs( $packedPath, $input )
77
      : '';
78
  }
79
80
  private function findInPackedRefs( string $path, string $input ): string {
81
    $targets = [$input, "refs/heads/$input", "refs/tags/$input"];
82
83
    foreach( file( $path ) as $line ) {
84
      if( $line[0] === '#' || $line[0] === '^' ) {
85
        continue;
86
      }
87
88
      $parts = explode( ' ', trim( $line ) );
89
90
      if( count( $parts ) >= 2 && in_array( $parts[1], $targets ) ) {
91
        return $parts[0];
92
      }
93
    }
94
95
    return '';
96
  }
97
}
1 98
M Router.php
3 3
require_once 'RepositoryList.php';
4 4
require_once 'Git.php';
5
require_once 'GitDiff.php';
6
require_once 'DiffPage.php';
5 7
6 8
class Router {
...
40 42
    if ($action === 'raw') {
41 43
      return new RawPage($this->git, $hash);
44
    }
45
46
    if ($action === 'commit') {
47
      return new DiffPage($this->repositories, $currentRepo, $this->git, $hash);
42 48
    }
43 49
D order.txt
1
keenwrite.git
2
keenquotes.git
3
kmcaster.git
4
keenwrite.com.git
5
treetrek.git
6
autonoma.ca.git
7
impacts.to.git
8
delibero.git
9
indispensable.git
10
mandelbrot.git
11
notanexus.git
12
scripted-selenium.git
13
yamlp.git
14
jigo.git
15
jexpect.git
16
hierarchy.git
17
rxm.git
18
miller-columns.git
19
palette.git
20
recipe-books.git
21
recipe-fiddle.git
22
segmenter.git
23
tally-time.git
24
sales.git
25
-autónoma.git
26
27 1
M repo.css
180 180
    padding: 12px 16px;
181 181
    margin-bottom: 20px;
182
    color: #8b949e; /* Color for the / separators */
183
}
184
185
.breadcrumb a {
186
    color: #58a6ff;
187
    text-decoration: none;
188
}
189
190
.breadcrumb a:hover {
191
    text-decoration: underline;
192
}
193
194
.blob-content {
195
    background: #161b22;
196
    border: 1px solid #30363d;
197
    border-radius: 6px;
198
    overflow: hidden;
199
}
200
201
.blob-header {
202
    background: #21262d;
203
    padding: 12px 16px;
204
    border-bottom: 1px solid #30363d;
205
    font-size: 0.875rem;
206
    color: #8b949e;
207
}
208
209
.blob-code {
210
    padding: 16px;
211
    overflow-x: auto;
212
    font-family: 'SFMono-Regular', Consolas, monospace;
213
    font-size: 0.875rem;
214
    line-height: 1.6;
215
    white-space: pre;
216
}
217
218
.refs-list {
219
    display: grid;
220
    gap: 10px;
221
}
222
223
.ref-item {
224
    background: #161b22;
225
    border: 1px solid #30363d;
226
    border-radius: 6px;
227
    padding: 12px 16px;
228
    display: flex;
229
    align-items: center;
230
    gap: 12px;
231
}
232
233
.ref-type {
234
    background: #238636;
235
    color: white;
236
    padding: 2px 8px;
237
    border-radius: 12px;
238
    font-size: 0.75rem;
239
    font-weight: 600;
240
    text-transform: uppercase;
241
}
242
243
.ref-type.tag {
244
    background: #8957e5;
245
}
246
247
.ref-name {
248
    font-weight: 600;
249
    color: #f0f6fc;
250
}
251
252
.empty-state {
253
    text-align: center;
254
    padding: 60px 20px;
255
    color: #8b949e;
256
}
257
258
.commit-details {
259
    background: #161b22;
260
    border: 1px solid #30363d;
261
    border-radius: 6px;
262
    padding: 20px;
263
    margin-bottom: 20px;
264
}
265
266
.commit-header {
267
    margin-bottom: 20px;
268
}
269
270
.commit-title {
271
    font-size: 1.25rem;
272
    color: #f0f6fc;
273
    margin-bottom: 10px;
274
}
275
276
.commit-info {
277
    display: grid;
278
    gap: 8px;
279
    font-size: 0.875rem;
280
}
281
282
.commit-info-row {
283
    display: flex;
284
    gap: 10px;
285
}
286
287
.commit-info-label {
288
    color: #8b949e;
289
    width: 80px;
290
    flex-shrink: 0;
291
}
292
293
.commit-info-value {
294
    color: #c9d1d9;
295
    font-family: monospace;
296
}
297
298
.parent-link {
299
    color: #58a6ff;
300
    text-decoration: none;
301
}
302
303
.parent-link:hover {
304
    text-decoration: underline;
305
}
306
307
.repo-grid {
308
    display: grid;
309
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
310
    gap: 16px;
311
    margin-top: 20px;
312
}
313
314
.repo-card {
315
    background: #161b22;
316
    border: 1px solid #30363d;
317
    border-radius: 8px;
318
    padding: 20px;
319
    text-decoration: none;
320
    color: inherit;
321
    transition: border-color 0.2s, transform 0.1s;
322
}
323
324
.repo-card:hover {
325
    border-color: #58a6ff;
326
    transform: translateY(-2px);
327
}
328
329
.repo-card h3 {
330
    color: #58a6ff;
331
    margin-bottom: 8px;
332
    font-size: 1.1rem;
333
}
334
335
.repo-card p {
336
    color: #8b949e;
337
    font-size: 0.875rem;
338
    margin: 0;
339
}
340
341
.current-repo {
342
    background: #21262d;
343
    border: 1px solid #58a6ff;
344
    padding: 8px 16px;
345
    border-radius: 6px;
346
    font-size: 0.875rem;
347
    color: #f0f6fc;
348
}
349
350
.current-repo strong {
351
    color: #58a6ff;
352
}
353
354
.branch-badge {
355
    background: #238636;
356
    color: white;
357
    padding: 2px 8px;
358
    border-radius: 12px;
359
    font-size: 0.75rem;
360
    font-weight: 600;
361
    margin-left: 10px;
362
}
363
364
.commit-row {
365
    display: flex;
366
    padding: 10px 0;
367
    border-bottom: 1px solid #30363d;
368
    gap: 15px;
369
    align-items: baseline;
370
}
371
372
.commit-row:last-child {
373
    border-bottom: none;
374
}
375
376
.commit-row .sha {
377
    font-family: monospace;
378
    color: #58a6ff;
379
    text-decoration: none;
380
}
381
382
.commit-row .message {
383
    flex: 1;
384
    font-weight: 500;
385
}
386
387
.commit-row .meta {
388
    font-size: 0.85em;
389
    color: #8b949e;
390
    white-space: nowrap;
391
}
392
393
.blob-content-image {
394
    text-align: center;
395
    padding: 20px;
396
    background: #0d1117;
397
}
398
399
.blob-content-image img {
400
    max-width: 100%;
401
    border: 1px solid #30363d;
402
}
403
404
.blob-content-video {
405
    text-align: center;
406
    padding: 20px;
407
    background: #000;
408
}
409
410
.blob-content-video video {
411
    max-width: 100%;
412
    max-height: 80vh;
413
}
414
415
.blob-content-audio {
416
    text-align: center;
417
    padding: 40px;
418
    background: #161b22;
419
}
420
421
.blob-content-audio audio {
422
    width: 100%;
423
    max-width: 600px;
424
}
425
426
.download-state {
427
    text-align: center;
428
    padding: 40px;
429
    border: 1px solid #30363d;
430
    border-radius: 6px;
431
    margin-top: 10px;
432
}
433
434
.download-state p {
435
    margin-bottom: 20px;
436
    color: #8b949e;
437
}
438
439
.btn-download {
440
    display: inline-block;
441
    padding: 6px 16px;
442
    background: #238636;
443
    color: white;
444
    text-decoration: none;
445
    border-radius: 6px;
446
    font-weight: 600;
447
}
448
449
.repo-info-banner {
450
    margin-top: 15px;
451
}
452
453
.file-icon-container {
454
    width: 20px;
455
    text-align: center;
456
    margin-right: 5px;
457
    color: #8b949e;
458
}
459
460
.file-size {
461
    color: #8b949e;
462
    font-size: 0.8em;
463
    margin-left: 10px;
464
}
465
466
.file-date {
467
    color: #8b949e;
468
    font-size: 0.8em;
469
    margin-left: auto;
470
}
471
472
.repo-card-time {
473
    margin-top: 8px;
474
    color: #58a6ff;
475
}
182
    color: #8b949e;
183
}
184
185
.breadcrumb a {
186
    color: #58a6ff;
187
    text-decoration: none;
188
}
189
190
.breadcrumb a:hover {
191
    text-decoration: underline;
192
}
193
194
.blob-content {
195
    background: #161b22;
196
    border: 1px solid #30363d;
197
    border-radius: 6px;
198
    overflow: hidden;
199
}
200
201
.blob-header {
202
    background: #21262d;
203
    padding: 12px 16px;
204
    border-bottom: 1px solid #30363d;
205
    font-size: 0.875rem;
206
    color: #8b949e;
207
}
208
209
.blob-code {
210
    padding: 16px;
211
    overflow-x: auto;
212
    font-family: 'SFMono-Regular', Consolas, monospace;
213
    font-size: 0.875rem;
214
    line-height: 1.6;
215
    white-space: pre;
216
}
217
218
.refs-list {
219
    display: grid;
220
    gap: 10px;
221
}
222
223
.ref-item {
224
    background: #161b22;
225
    border: 1px solid #30363d;
226
    border-radius: 6px;
227
    padding: 12px 16px;
228
    display: flex;
229
    align-items: center;
230
    gap: 12px;
231
}
232
233
.ref-type {
234
    background: #238636;
235
    color: white;
236
    padding: 2px 8px;
237
    border-radius: 12px;
238
    font-size: 0.75rem;
239
    font-weight: 600;
240
    text-transform: uppercase;
241
}
242
243
.ref-type.tag {
244
    background: #8957e5;
245
}
246
247
.ref-name {
248
    font-weight: 600;
249
    color: #f0f6fc;
250
}
251
252
.empty-state {
253
    text-align: center;
254
    padding: 60px 20px;
255
    color: #8b949e;
256
}
257
258
.commit-details {
259
    background: #161b22;
260
    border: 1px solid #30363d;
261
    border-radius: 6px;
262
    padding: 20px;
263
    margin-bottom: 20px;
264
}
265
266
.commit-header {
267
    margin-bottom: 20px;
268
}
269
270
.commit-title {
271
    font-size: 1.25rem;
272
    color: #f0f6fc;
273
    margin-bottom: 10px;
274
}
275
276
.commit-info {
277
    display: grid;
278
    gap: 8px;
279
    font-size: 0.875rem;
280
}
281
282
.commit-info-row {
283
    display: flex;
284
    gap: 10px;
285
}
286
287
.commit-info-label {
288
    color: #8b949e;
289
    width: 80px;
290
    flex-shrink: 0;
291
}
292
293
.commit-info-value {
294
    color: #c9d1d9;
295
    font-family: monospace;
296
}
297
298
.parent-link {
299
    color: #58a6ff;
300
    text-decoration: none;
301
}
302
303
.parent-link:hover {
304
    text-decoration: underline;
305
}
306
307
.repo-grid {
308
    display: grid;
309
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
310
    gap: 16px;
311
    margin-top: 20px;
312
}
313
314
.repo-card {
315
    background: #161b22;
316
    border: 1px solid #30363d;
317
    border-radius: 8px;
318
    padding: 20px;
319
    text-decoration: none;
320
    color: inherit;
321
    transition: border-color 0.2s, transform 0.1s;
322
}
323
324
.repo-card:hover {
325
    border-color: #58a6ff;
326
    transform: translateY(-2px);
327
}
328
329
.repo-card h3 {
330
    color: #58a6ff;
331
    margin-bottom: 8px;
332
    font-size: 1.1rem;
333
}
334
335
.repo-card p {
336
    color: #8b949e;
337
    font-size: 0.875rem;
338
    margin: 0;
339
}
340
341
.current-repo {
342
    background: #21262d;
343
    border: 1px solid #58a6ff;
344
    padding: 8px 16px;
345
    border-radius: 6px;
346
    font-size: 0.875rem;
347
    color: #f0f6fc;
348
}
349
350
.current-repo strong {
351
    color: #58a6ff;
352
}
353
354
.branch-badge {
355
    background: #238636;
356
    color: white;
357
    padding: 2px 8px;
358
    border-radius: 12px;
359
    font-size: 0.75rem;
360
    font-weight: 600;
361
    margin-left: 10px;
362
}
363
364
.commit-row {
365
    display: flex;
366
    padding: 10px 0;
367
    border-bottom: 1px solid #30363d;
368
    gap: 15px;
369
    align-items: baseline;
370
}
371
372
.commit-row:last-child {
373
    border-bottom: none;
374
}
375
376
.commit-row .sha {
377
    font-family: monospace;
378
    color: #58a6ff;
379
    text-decoration: none;
380
}
381
382
.commit-row .message {
383
    flex: 1;
384
    font-weight: 500;
385
}
386
387
.commit-row .meta {
388
    font-size: 0.85em;
389
    color: #8b949e;
390
    white-space: nowrap;
391
}
392
393
.blob-content-image {
394
    text-align: center;
395
    padding: 20px;
396
    background: #0d1117;
397
}
398
399
.blob-content-image img {
400
    max-width: 100%;
401
    border: 1px solid #30363d;
402
}
403
404
.blob-content-video {
405
    text-align: center;
406
    padding: 20px;
407
    background: #000;
408
}
409
410
.blob-content-video video {
411
    max-width: 100%;
412
    max-height: 80vh;
413
}
414
415
.blob-content-audio {
416
    text-align: center;
417
    padding: 40px;
418
    background: #161b22;
419
}
420
421
.blob-content-audio audio {
422
    width: 100%;
423
    max-width: 600px;
424
}
425
426
.download-state {
427
    text-align: center;
428
    padding: 40px;
429
    border: 1px solid #30363d;
430
    border-radius: 6px;
431
    margin-top: 10px;
432
}
433
434
.download-state p {
435
    margin-bottom: 20px;
436
    color: #8b949e;
437
}
438
439
.btn-download {
440
    display: inline-block;
441
    padding: 6px 16px;
442
    background: #238636;
443
    color: white;
444
    text-decoration: none;
445
    border-radius: 6px;
446
    font-weight: 600;
447
}
448
449
.repo-info-banner {
450
    margin-top: 15px;
451
}
452
453
.file-icon-container {
454
    width: 20px;
455
    text-align: center;
456
    margin-right: 5px;
457
    color: #8b949e;
458
}
459
460
.file-size {
461
    color: #8b949e;
462
    font-size: 0.8em;
463
    margin-left: 10px;
464
}
465
466
.file-date {
467
    color: #8b949e;
468
    font-size: 0.8em;
469
    margin-left: auto;
470
}
471
472
.repo-card-time {
473
    margin-top: 8px;
474
    color: #58a6ff;
475
}
476
477
478
/* --- GIT DIFF STYLES (Protanopia Dark) --- */
479
480
.diff-container {
481
    display: flex;
482
    flex-direction: column;
483
    gap: 20px;
484
}
485
486
.diff-file {
487
    background: #161b22;
488
    border: 1px solid #30363d;
489
    border-radius: 6px;
490
    overflow: hidden;
491
}
492
493
.diff-header {
494
    background: #21262d;
495
    padding: 10px 16px;
496
    border-bottom: 1px solid #30363d;
497
    display: flex;
498
    align-items: center;
499
    gap: 10px;
500
}
501
502
.diff-path {
503
    font-family: monospace;
504
    font-size: 0.9rem;
505
    color: #f0f6fc;
506
}
507
508
.diff-binary {
509
    padding: 20px;
510
    text-align: center;
511
    color: #8b949e;
512
    font-style: italic;
513
}
514
515
.diff-content {
516
    overflow-x: auto;
517
}
518
519
.diff-content table {
520
    width: 100%;
521
    border-collapse: collapse;
522
    font-family: 'SFMono-Regular', Consolas, monospace;
523
    font-size: 12px;
524
}
525
526
.diff-content td {
527
    padding: 2px 0;
528
    line-height: 20px;
529
}
530
531
.diff-num {
532
    width: 1%;
533
    min-width: 40px;
534
    text-align: right;
535
    padding-right: 10px;
536
    color: #6e7681;
537
    user-select: none;
538
    background: #0d1117;
539
    border-right: 1px solid #30363d;
540
}
541
542
.diff-num::before {
543
    content: attr(data-num);
544
}
545
546
.diff-code {
547
    padding-left: 10px;
548
    white-space: pre-wrap;
549
    word-break: break-all;
550
    color: #c9d1d9;
551
}
552
553
.diff-marker {
554
    display: inline-block;
555
    width: 15px;
556
    user-select: none;
557
    color: #8b949e;
558
}
559
560
/* Protanopia Safe Colors: Blue (Add) and Yellow (Del) */
561
.diff-add {
562
    background-color: rgba(2, 59, 149, 0.25);
563
}
564
.diff-add .diff-code {
565
    color: #79c0ff;
566
}
567
.diff-add .diff-marker {
568
    color: #79c0ff;
569
}
570
571
.diff-del {
572
    background-color: rgba(148, 99, 0, 0.25);
573
}
574
.diff-del .diff-code {
575
    color: #d29922;
576
}
577
.diff-del .diff-marker {
578
    color: #d29922;
579
}
580
581
.diff-gap {
582
    background: #0d1117;
583
    color: #484f58;
584
    text-align: center;
585
    font-size: 0.8em;
586
    height: 20px;
587
}
588
.diff-gap td {
589
    padding: 0;
590
    line-height: 20px;
591
    background: rgba(110, 118, 129, 0.1);
592
}
593
594
.status-add { color: #58a6ff; }
595
.status-del { color: #d29922; }
596
.status-mod { color: #a371f7; }
476 597
477 598