Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M .gitignore
1 1
.htaccess
2 2
order.txt
3
favicon.ico
3 4
D BasePage.php
1
<?php
2
require_once 'File.php';
3
require_once 'FileRenderer.php';
4
5
abstract class BasePage implements Page {
6
  protected $repositories;
7
  protected $title;
8
9
  public function __construct(array $repositories) {
10
    $this->repositories = $repositories;
11
  }
12
13
  protected function renderLayout($contentCallback, $currentRepo = null) {
14
    ?>
15
    <!DOCTYPE html>
16
    <html lang="en">
17
    <head>
18
      <meta charset="UTF-8">
19
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
20
      <title><?php echo Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title>
21
      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
22
      <link rel="stylesheet" href="repo.css">
23
    </head>
24
    <body>
25
    <div class="container">
26
      <header>
27
        <h1><?php echo Config::SITE_TITLE; ?></h1>
28
        <nav class="nav">
29
          <a href="?">Home</a>
30
          <?php if ($currentRepo):
31
            $safeName = urlencode($currentRepo['safe_name']); ?>
32
            <a href="?repo=<?php echo $safeName; ?>">Files</a>
33
            <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a>
34
            <a href="?action=refs&repo=<?php echo $safeName; ?>">Branches</a>
35
            <a href="?action=tags&repo=<?php echo $safeName; ?>">Tags</a>
36
          <?php endif; ?>
37
38
          <?php if ($currentRepo): ?>
39
          <div class="repo-selector">
40
            <label>Repository:</label>
41
            <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)">
42
              <?php foreach ($this->repositories as $r): ?>
43
              <option value="<?php echo htmlspecialchars($r['safe_name']); ?>"
44
                <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>>
45
                <?php echo htmlspecialchars($r['name']); ?>
46
              </option>
47
              <?php endforeach; ?>
48
            </select>
49
          </div>
50
          <?php endif; ?>
51
        </nav>
52
53
        <?php if ($currentRepo): ?>
54
          <div class="repo-info-banner">
55
            <span class="current-repo">Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong></span>
56
          </div>
57
        <?php endif; ?>
58
      </header>
59
60
      <?php call_user_func($contentCallback); ?>
61
62
    </div>
63
    </body>
64
    </html>
65
    <?php
66
  }
67
}
68 1
D CommitsPage.php
1
<?php
2
class CommitsPage extends BasePage {
3
  private $currentRepo;
4
  private $git;
5
  private $hash;
6
7
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) {
8
    parent::__construct($repositories);
9
    $this->currentRepo = $currentRepo;
10
    $this->git = $git;
11
    $this->hash = $hash;
12
    $this->title = $currentRepo['name'];
13
  }
14
15
  public function render() {
16
    $this->renderLayout(function() {
17
      // Use local private $git
18
      $main = $this->git->getMainBranch();
19
20
      if (!$main) {
21
        echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>';
22
        return;
23
      }
24
25
      $this->renderBreadcrumbs();
26
      echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>';
27
      echo '<div class="commit-list">';
28
29
      $start = $this->hash ?: $main['hash'];
30
      $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']);
31
32
      $this->git->history($start, 100, function($commit) use ($repoParam) {
33
        $msg = htmlspecialchars(explode("\n", $commit->message)[0]);
34
        echo '<div class="commit-row">';
35
        echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>';
36
        echo '<span class="message">' . $msg . '</span>';
37
        echo '<span class="meta">' . htmlspecialchars($commit->author) . ' &bull; ' . date('Y-m-d', $commit->date) . '</span>';
38
        echo '</div>';
39
      });
40
      echo '</div>';
41
    }, $this->currentRepo);
42
  }
43
44
  private function renderBreadcrumbs() {
45
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
46
47
    $crumbs = [
48
      '<a href="?">Repositories</a>',
49
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
50
      'Commits'
51
    ];
52
53
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
54
  }
55
}
56 1
M Config.php
3 3
  const SITE_TITLE = "Dave Jarvis' Repositories";
4 4
5
  /**
6
   * Determine the home directory for repository discovery.
7
   */
8 5
  private static function getHomeDirectory() {
9 6
    if (!empty($_SERVER['HOME'])) {
...
22 19
  }
23 20
24
  /**
25
   * Returns the full path where repositories are stored.
26
   */
27 21
  public static function getReposPath() {
28 22
    return self::getHomeDirectory() . '/repos';
29 23
  }
30 24
31
  /**
32
   * Initialize runtime settings (error logging, etc).
33
   */
34 25
  public static function init() {
35 26
    ini_set('display_errors', 0);
D 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
      // Fix 1: Redact email address
39
      $author = $headers['author'] ?? 'Unknown';
40
      $author = preg_replace('/<[^>]+>/', '<email>', $author);
41
42
      echo '<div class="commit-details">';
43
      echo '<div class="commit-header">';
44
      echo '<h1 class="commit-title">' . htmlspecialchars(trim($msg)) . '</h1>';
45
      echo '<div class="commit-info">';
46
      echo '<div class="commit-info-row"><span class="commit-info-label">Author</span><span class="commit-author">' . htmlspecialchars($author) . '</span></div>';
47
      echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span><span class="commit-info-value">' . $this->hash . '</span></div>';
48
49
      if (isset($headers['parent'])) {
50
          // Fix 2: Use '&' instead of '?' because parameters (action & hash) already exist
51
          $repoUrl = '&repo=' . urlencode($this->currentRepo['safe_name']);
52
          echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span><span class="commit-info-value">';
53
          echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl . '" class="parent-link">' . substr($headers['parent'], 0, 7) . '</a>';
54
          echo '</span></div>';
55
      }
56
      echo '</div></div></div>';
57
58
      echo '<div class="diff-container">';
59
      foreach ($changes as $change) {
60
        $this->renderFileDiff($change);
61
      }
62
      if (empty($changes)) {
63
          echo '<div class="empty-state"><p>No changes detected.</p></div>';
64
      }
65
      echo '</div>';
66
67
    }, $this->currentRepo);
68
  }
69
70
  private function renderFileDiff($change) {
71
    $statusIcon = 'fa-file';
72
    $statusClass = '';
73
74
    if ($change['type'] === 'A') { $statusIcon = 'fa-plus-circle'; $statusClass = 'status-add'; }
75
    if ($change['type'] === 'D') { $statusIcon = 'fa-minus-circle'; $statusClass = 'status-del'; }
76
    if ($change['type'] === 'M') { $statusIcon = 'fa-pencil-alt'; $statusClass = 'status-mod'; }
77
78
    echo '<div class="diff-file">';
79
    echo '<div class="diff-header">';
80
    echo '<span class="diff-status ' . $statusClass . '"><i class="fa ' . $statusIcon . '"></i></span>';
81
    echo '<span class="diff-path">' . htmlspecialchars($change['path']) . '</span>';
82
    echo '</div>';
83
84
    if ($change['is_binary']) {
85
        echo '<div class="diff-binary">Binary files differ</div>';
86
    } else {
87
      echo '<div class="diff-content">';
88
      echo '<table><tbody>';
89
90
      foreach ($change['hunks'] as $line) {
91
          if (isset($line['t']) && $line['t'] === 'gap') {
92
              echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
93
              continue;
94
          }
95
96
          $class = 'diff-ctx';
97
          $char = ' ';
98
          if ($line['t'] === '+') { $class = 'diff-add'; $char = '+'; }
99
          if ($line['t'] === '-') { $class = 'diff-del'; $char = '-'; }
100
101
          echo '<tr class="' . $class . '">';
102
          echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
103
          echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
104
          echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . htmlspecialchars($line['l']) . '</td>';
105
          echo '</tr>';
106
      }
107
108
      echo '</tbody></table>';
109
      echo '</div>';
110
    }
111
    echo '</div>';
112
  }
113
114
  private function renderBreadcrumbs() {
115
    $safeName = urlencode($this->currentRepo['safe_name']);
116
117
    $crumbs = [
118
      '<a href="?">Repositories</a>',
119
      '<a href="?repo=' . $safeName . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
120
      // Fix 3: Use '&' separator for the repo parameter
121
      '<a href="?action=commits&repo=' . $safeName . '">Commits</a>',
122
      substr($this->hash, 0, 7)
123
    ];
124
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
125
  }
126
}
127 1
M File.php
1 1
<?php
2
require_once 'MediaTypeSniffer.php';
3
require_once 'FileRenderer.php';
2
require_once __DIR__ . '/render/FileRenderer.php';
4 3
5 4
class File {
5
  private const CAT_IMAGE = 'image';
6
  private const CAT_VIDEO = 'video';
7
  private const CAT_AUDIO = 'audio';
8
  private const CAT_TEXT = 'text';
9
  private const CAT_ARCHIVE = 'archive';
10
  private const CAT_BINARY = 'binary';
11
12
  private const ARCHIVE_EXTENSIONS = [
13
    'zip', 'tar', 'gz', '7z', 'rar', 'jar', 'lha', 'bz', 'tgz', 'cab',
14
    'iso', 'dmg', 'xz', 'z', 'ar', 'war', 'ear', 'pak', 'hqx', 'arj',
15
    'zoo', 'rpm', 'deb', 'apk'
16
  ];
17
6 18
  private string $name;
7 19
  private string $sha;
8 20
  private string $mode;
9 21
  private int $timestamp;
10 22
  private int $size;
11 23
  private bool $isDir;
24
  private string $icon;
12 25
13
  public function __construct(string $name, string $sha, string $mode, int $timestamp = 0, int $size = 0) {
14
    $this->name = $name;
15
    $this->sha = $sha;
16
    $this->mode = $mode;
17
    $this->timestamp = $timestamp;
18
    $this->size = $size;
19
    $this->isDir = ($mode === '40000' || $mode === '040000');
26
  private string $mediaType;
27
  private string $category;
28
  private bool $binary;
29
30
  public function __construct(
31
    string $name,
32
    string $sha,
33
    string $mode,
34
    int $timestamp,
35
    int $size,
36
    string $contents = ''
37
  ) {
38
    $this->name        = $name;
39
    $this->sha         = $sha;
40
    $this->mode        = $mode;
41
    $this->timestamp   = $timestamp;
42
    $this->size        = $size;
43
    $this->isDir       = $mode === '40000' || $mode === '040000';
44
45
    $buffer = $this->isDir ? '' : $contents;
46
47
    $this->mediaType = $this->detectMediaType($buffer);
48
    $this->category  = $this->detectCategory($name);
49
    $this->binary    = $this->detectBinary();
50
    $this->icon      = $this->resolveIcon();
20 51
  }
21 52
22 53
  public function compare(File $other): int {
23
    if ($this->isDir !== $other->isDir) {
24
      return $this->isDir ? -1 : 1;
25
    }
26
27
    return strcasecmp($this->name, $other->name);
54
    return $this->isDir !== $other->isDir
55
      ? ($this->isDir ? -1 : 1)
56
      : strcasecmp($this->name, $other->name);
28 57
  }
29 58
30 59
  public function render(FileRenderer $renderer): void {
31
    $renderer->renderFileItem(
60
    $renderer->renderFile(
32 61
      $this->name,
33 62
      $this->sha,
34 63
      $this->mode,
35
      $this->getIconClass(),
64
      $this->icon,
36 65
      $this->timestamp,
37
      $this->isDir ? '' : $this->getFormattedSize()
66
      $this->size
38 67
    );
39 68
  }
40 69
41
  private function getIconClass(): string {
42
    if ($this->isDir) return 'fa-folder';
70
  public function renderSize(FileRenderer $renderer): void {
71
    $renderer->renderSize($this->size);
72
  }
43 73
44
    return match (true) {
45
      $this->isType('application/pdf') => 'fa-file-pdf',
46
      $this->isCategory(MediaTypeSniffer::CAT_ARCHIVE) => 'fa-file-archive',
47
      $this->isCategory(MediaTypeSniffer::CAT_IMAGE)   => 'fa-file-image',
48
      $this->isCategory(MediaTypeSniffer::CAT_AUDIO)   => 'fa-file-audio',
49
      $this->isCategory(MediaTypeSniffer::CAT_VIDEO)   => 'fa-file-video',
50
      $this->isCategory(MediaTypeSniffer::CAT_TEXT)    => 'fa-file-code',
51
      default => 'fa-file',
52
    };
74
  public function renderMedia(string $url): bool {
75
    $rendered = false;
76
77
    if ($this->isImage()) {
78
      echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>';
79
      $rendered = true;
80
    } elseif ($this->isVideo()) {
81
      echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $this->mediaType . '"></video></div>';
82
      $rendered = true;
83
    } elseif ($this->isAudio()) {
84
      echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $this->mediaType . '"></audio></div>';
85
      $rendered = true;
86
    }
87
88
    return $rendered;
53 89
  }
54 90
55
  private function getFormattedSize(): string {
56
    if ($this->size <= 0) return '0 B';
57
    $units = ['B', 'KB', 'MB', 'GB'];
58
    $i = (int)floor(log($this->size, 1024));
59
    return round($this->size / pow(1024, $i), 1) . ' ' . $units[$i];
91
  public function emitRawHeaders(): void {
92
    header("Content-Type: " . $this->mediaType);
93
    header("Content-Length: " . $this->size);
94
    header("Content-Disposition: attachment; filename=\"" . addslashes(basename($this->name)) . "\"");
60 95
  }
61 96
62
  public function isType(string $type): bool {
63
    return str_contains(MediaTypeSniffer::isMediaType($this->getSniffBuffer(), $this->name), $type);
97
  public function isImage(): bool {
98
    return $this->category === self::CAT_IMAGE;
64 99
  }
65 100
66
  public function isCategory(string $category): bool {
67
    return MediaTypeSniffer::isCategory($this->getSniffBuffer(), $this->name) === $category;
101
  public function isVideo(): bool {
102
    return $this->category === self::CAT_VIDEO;
103
  }
104
105
  public function isAudio(): bool {
106
    return $this->category === self::CAT_AUDIO;
107
  }
108
109
  public function isText(): bool {
110
    return $this->category === self::CAT_TEXT;
68 111
  }
69 112
70 113
  public function isBinary(): bool {
71
    return MediaTypeSniffer::isBinary($this->getSniffBuffer(), $this->name);
114
    return $this->binary;
72 115
  }
73 116
74
  private function getSniffBuffer(): string {
75
    if ($this->isDir || !file_exists($this->name)) return '';
76
    $handle = @fopen($this->name, 'rb');
77
    if (!$handle) return '';
78
    $read = fread($handle, 12);
79
    fclose($handle);
80
    return ($read !== false) ? $read : '';
117
  private function resolveIcon(): string {
118
    return $this->isDir
119
      ? 'fa-folder'
120
      : (str_contains($this->mediaType, 'application/pdf')
121
        ? 'fa-file-pdf'
122
        : match ($this->category) {
123
          self::CAT_ARCHIVE => 'fa-file-archive',
124
          self::CAT_IMAGE   => 'fa-file-image',
125
          self::CAT_AUDIO   => 'fa-file-audio',
126
          self::CAT_VIDEO   => 'fa-file-video',
127
          self::CAT_TEXT    => 'fa-file-code',
128
          default           => 'fa-file',
129
        });
130
  }
131
132
  private function detectMediaType(string $buffer): string {
133
    $finfo = new finfo(FILEINFO_MIME_TYPE);
134
    $mediaType = $finfo->buffer($buffer);
135
    return $mediaType ?: 'application/octet-stream';
136
  }
137
138
  private function detectCategory(string $filename = ''): string {
139
    $parts = explode('/', $this->mediaType);
140
141
    return match(true) {
142
      $parts[0] === 'image' => self::CAT_IMAGE,
143
      $parts[0] === 'video' => self::CAT_VIDEO,
144
      $parts[0] === 'audio' => self::CAT_AUDIO,
145
      $parts[0] === 'text' => self::CAT_TEXT,
146
      $this->isArchiveFile($filename) => self::CAT_ARCHIVE,
147
      str_contains($this->mediaType, 'compressed') => self::CAT_ARCHIVE,
148
      default => self::CAT_BINARY,
149
    };
150
  }
151
152
  private function detectBinary(): bool {
153
    return !str_starts_with($this->mediaType, 'text/');
154
  }
155
156
  private function isArchiveFile(string $filename): bool {
157
    return in_array(
158
      strtolower(pathinfo($filename, PATHINFO_EXTENSION)),
159
      self::ARCHIVE_EXTENSIONS,
160
      true
161
    );
81 162
  }
82 163
}
D FilePage.php
1
<?php
2
class FilePage extends BasePage {
3
  private $currentRepo;
4
  private $git;
5
  private $hash;
6
7
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash = '') {
8
    parent::__construct($repositories);
9
10
    $this->currentRepo = $currentRepo;
11
    $this->git         = $git;
12
    $this->hash        = $hash;
13
    $this->title       = $currentRepo['name'];
14
  }
15
16
  public function render() {
17
    $this->renderLayout( function() {
18
      // Use the injected private Git instance
19
      $main = $this->git->getMainBranch();
20
21
      if( !$main ) {
22
        echo '<div class="empty-state"><h3>No branches</h3></div>';
23
        return;
24
      }
25
26
      $target  = $this->hash ?: $main['hash'];
27
      $entries = [];
28
29
      // Use the injected private Git instance
30
      $this->git->walk( $target, function( $file ) use ( &$entries ) {
31
        $entries[] = $file;
32
      } );
33
34
      if( !empty( $entries ) ) {
35
        $this->renderTree( $main, $target, $entries );
36
      } else {
37
        $this->renderBlob( $target );
38
      }
39
    }, $this->currentRepo );
40
  }
41
42
  private function renderTree( $main, $targetHash, $entries ) {
43
    $path = $_GET['name'] ?? '';
44
45
    $this->renderBreadcrumbs( $targetHash, 'Tree' );
46
47
    echo '<h2>' . htmlspecialchars( $this->currentRepo['name'] ) .
48
         ' <span class="branch-badge">' .
49
         htmlspecialchars( $main['name'] ) . '</span></h2>';
50
51
    usort( $entries, function( $a, $b ) {
52
      return $a->compare( $b );
53
    } );
54
55
    echo '<div class="file-list">';
56
    $renderer = new HtmlFileRenderer( $this->currentRepo['safe_name'], $path );
57
58
    foreach($entries as $file) {
59
      $file->render( $renderer );
60
    }
61
62
    echo '</div>';
63
  }
64
65
  private function renderBlob( $targetHash ) {
66
    $repoParam = '&repo=' . urlencode( $this->currentRepo['safe_name'] );
67
68
    // Use the injected private Git instance
69
    $size      = $this->git->getObjectSize( $targetHash );
70
    $buffer    = '';
71
72
    // Use the injected private Git instance
73
    $this->git->stream( $targetHash, function( $d ) use ( &$buffer ) {
74
      if( strlen( $buffer ) < 12 ) $buffer .= $d;
75
    } );
76
77
    $filename  = $_GET['name'] ?? '';
78
    $category  = MediaTypeSniffer::isCategory( $buffer, $filename );
79
    $mediaType = MediaTypeSniffer::isMediaType( $buffer, $filename );
80
81
    $this->renderBreadcrumbs( $targetHash, 'File' );
82
83
    if( $size === 0 ) {
84
      $this->renderDownloadState( $targetHash, "This file is empty." );
85
      return;
86
    }
87
88
    $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode( $filename );
89
90
    if( $category === MediaTypeSniffer::CAT_IMAGE ) {
91
      echo '<div class="blob-content blob-content-image"><img src="' . $rawUrl . '"></div>';
92
    } elseif( $category === MediaTypeSniffer::CAT_VIDEO ) {
93
      echo '<div class="blob-content blob-content-video"><video controls><source src="' . $rawUrl . '" type="' . $mediaType . '"></video></div>';
94
    } elseif( $category === MediaTypeSniffer::CAT_AUDIO ) {
95
      echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $rawUrl . '" type="' . $mediaType . '"></audio></div>';
96
    } elseif( $category === MediaTypeSniffer::CAT_TEXT ) {
97
      if( $size > 524288 ) {
98
        $this->renderDownloadState( $targetHash, "File is too large to display (" . $this->formatSize( $size ) . ")." );
99
      } else {
100
        $content = '';
101
        // Use the injected private Git instance
102
        $this->git->stream( $targetHash, function( $d ) use ( &$content ) { $content .= $d; } );
103
        echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars( $content ) . '</pre></div>';
104
      }
105
    } else {
106
      $this->renderDownloadState( $targetHash, "This is a binary file." );
107
    }
108
  }
109
110
  private function renderDownloadState( $hash, $reason ) {
111
    $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode( $this->currentRepo['safe_name'] );
112
113
    echo '<div class="empty-state download-state">';
114
    echo   '<p>' . htmlspecialchars( $reason ) . '</p>';
115
    echo   '<a href="' . $url . '" class="btn-download">Download Raw File</a>';
116
    echo '</div>';
117
  }
118
119
  private function formatSize( $size ) {
120
    if( $size <= 0 ) return '0 B';
121
122
    $units = ['B', 'KB', 'MB', 'GB'];
123
    $i     = (int)floor( log( $size, 1024 ) );
124
125
    return round( $size / pow( 1024, $i ), 1 ) . ' ' . $units[$i];
126
  }
127
128
  private function renderBreadcrumbs( $hash, $type ) {
129
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
130
    $path    = $_GET['name'] ?? '';
131
132
    $crumbs = [
133
      '<a href="?">Repositories</a>',
134
      '<a href="' . $repoUrl . '">' . htmlspecialchars( $this->currentRepo['name'] ) . '</a>'
135
    ];
136
137
    if ( $path ) {
138
      $parts = explode( '/', trim( $path, '/' ) );
139
      $acc   = '';
140
      foreach ( $parts as $idx => $part ) {
141
        $acc .= ( $idx === 0 ? '' : '/' ) . $part;
142
143
        // The last segment isn't a link
144
        if ( $idx === count( $parts ) - 1 ) {
145
          $crumbs[] = htmlspecialchars( $part );
146
        } else {
147
          $crumbs[] = '<a href="' . $repoUrl . '&name=' . urlencode( $acc ) . '">' .
148
                      htmlspecialchars( $part ) . '</a>';
149
        }
150
      }
151
    } elseif ( $this->hash ) {
152
      $crumbs[] = $type . ' ' . substr( $hash, 0, 7 );
153
    }
154
155
    echo '<div class="breadcrumb">' . implode( ' / ', $crumbs ) . '</div>';
156
  }
157
}
158 1
D FileRenderer.php
1
<?php
2
interface FileRenderer {
3
  public function renderFileItem(
4
    string $name,
5
    string $sha,
6
    string $mode,
7
    string $iconClass,
8
    int $timestamp,
9
    string $size = ''
10
  ): void;
11
12
  public function renderTime( int $timestamp ): void;
13
}
14
15
class HtmlFileRenderer implements FileRenderer {
16
  private string $repoSafeName;
17
  private string $currentPath;
18
19
  public function __construct( string $repoSafeName, string $currentPath = '' ) {
20
    $this->repoSafeName = $repoSafeName;
21
    $this->currentPath  = trim( $currentPath, '/' );
22
  }
23
24
  public function renderFileItem(
25
    string $name,
26
    string $sha,
27
    string $mode,
28
    string $iconClass,
29
    int $timestamp,
30
    string $size = ''
31
  ): void {
32
    $fullPath = ($this->currentPath===''?'':$this->currentPath.'/') . $name;
33
    $url      = '?repo=' . urlencode( $this->repoSafeName ) . '&hash=' . $sha . '&name=' . urlencode( $fullPath );
34
35
    echo '<a href="' . $url . '" class="file-item">';
36
    echo   '<span class="file-mode">' . $mode . '</span>';
37
    echo   '<span class="file-name">';
38
    echo     '<i class="fas ' . $iconClass . ' file-icon-container"></i>';
39
    echo     htmlspecialchars( $name );
40
    echo   '</span>';
41
42
    if( $size ) {
43
      echo '<span class="file-size">' . $size . '</span>';
44
    }
45
46
    if( $timestamp > 0 ) {
47
      echo '<span class="file-date">';
48
      $this->renderTime( $timestamp );
49
      echo '</span>';
50
    }
51
52
    echo '</a>';
53
  }
54
55
  public function renderTime( int $timestamp ): void {
56
    if( !$timestamp ) {
57
      echo 'never';
58
59
      return;
60
    }
61
62
    $diff = time() - $timestamp;
63
64
    if( $diff < 5 ) {
65
      echo 'just now';
66
67
      return;
68
    }
69
70
    $tokens = [
71
      31536000 => 'year',
72
      2592000  => 'month',
73
      604800   => 'week',
74
      86400    => 'day',
75
      3600     => 'hour',
76
      60       => 'minute',
77
      1        => 'second'
78
    ];
79
80
    foreach($tokens as $unit => $text) {
81
82
      if( $diff < $unit ) continue;
83
84
      $num = floor( $diff / $unit );
85
      echo $num . ' ' . $text . (($num > 1)? 's': '') . ' ago';
86
87
      return;
88
    }
89
90
    echo 'just now';
91
  }
92
}
93 1
D Git.php
1
<?php
2
require_once 'File.php';
3
require_once 'Tag.php';
4
require_once 'GitRefs.php';
5
require_once 'GitPacks.php';
6
7
class Git {
8
  private const CHUNK_SIZE = 128;
9
  private const MAX_READ_SIZE = 1048576;
10
11
  private string $repoPath;
12
  private string $objectsPath;
13
14
  private GitRefs $refs;
15
  private GitPacks $packs;
16
17
  public function __construct( string $repoPath ) {
18
    $this->setRepository( $repoPath );
19
  }
20
21
  public function setRepository( string $repoPath ): void {
22
    $this->repoPath    = rtrim( $repoPath, '/' );
23
    $this->objectsPath = $this->repoPath . '/objects';
24
25
    $this->refs  = new GitRefs( $this->repoPath );
26
    $this->packs = new GitPacks( $this->objectsPath );
27
  }
28
29
  public function resolve( string $reference ): string {
30
    return $this->refs->resolve( $reference );
31
  }
32
33
  public function getMainBranch(): array {
34
    return $this->refs->getMainBranch();
35
  }
36
37
  public function eachBranch( callable $callback ): void {
38
    $this->refs->scanRefs( 'refs/heads', $callback );
39
  }
40
41
  public function eachTag( callable $callback ): void {
42
    $this->refs->scanRefs( 'refs/tags', function($name, $sha) use ($callback) {
43
      $data = $this->read($sha);
44
45
      $targetSha = $sha;
46
      $timestamp = 0;
47
      $message   = '';
48
      $author    = '';
49
50
      // Determine if Annotated Tag or Lightweight Tag
51
      if (strncmp($data, 'object ', 7) === 0) {
52
        // Annotated Tag
53
        if (preg_match('/^object ([0-9a-f]{40})$/m', $data, $m)) {
54
            $targetSha = $m[1];
55
        }
56
        if (preg_match('/^tagger (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m)) {
57
            $author = trim($m[1]);
58
            $timestamp = (int)$m[2];
59
        }
60
61
        $pos = strpos($data, "\n\n");
62
        if ($pos !== false) {
63
            $message = trim(substr($data, $pos + 2));
64
        }
65
      } else {
66
        // Lightweight Tag (points directly to commit)
67
        // We parse the commit data to get date/author
68
        if (preg_match('/^author (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m)) {
69
            $author = trim($m[1]);
70
            $timestamp = (int)$m[2];
71
        }
72
73
        $pos = strpos($data, "\n\n");
74
        if ($pos !== false) {
75
            $message = trim(substr($data, $pos + 2));
76
        }
77
      }
78
79
      $callback(new Tag(
80
        $name,
81
        $sha,
82
        $targetSha,
83
        $timestamp,
84
        $message,
85
        $author
86
      ));
87
    });
88
  }
89
90
  public function getObjectSize( string $sha ): int {
91
    $size = $this->packs->getSize( $sha );
92
93
    if( $size !== null ) {
94
      return $size;
95
    }
96
97
    return $this->getLooseObjectSize( $sha );
98
  }
99
100
  public function read( string $sha ): string {
101
    $size = $this->getObjectSize( $sha );
102
103
    if( $size > self::MAX_READ_SIZE ) {
104
      return '';
105
    }
106
107
    $content = '';
108
109
    $this->slurp( $sha, function( $chunk ) use ( &$content ) {
110
      $content .= $chunk;
111
    } );
112
113
    return $content;
114
  }
115
116
  public function stream( string $sha, callable $callback ): void {
117
    $this->slurp( $sha, $callback );
118
  }
119
120
  private function slurp( string $sha, callable $callback ): void {
121
    $loosePath = $this->getLoosePath( $sha );
122
123
    if( file_exists( $loosePath ) ) {
124
      $fileHandle = @fopen( $loosePath, 'rb' );
125
126
      if( !$fileHandle ) return;
127
128
      $inflator    = inflate_init( ZLIB_ENCODING_DEFLATE );
129
      $buffer      = '';
130
      $headerFound = false;
131
132
      while( !feof( $fileHandle ) ) {
133
        $chunk         = fread( $fileHandle, 16384 );
134
        $inflatedChunk = @inflate_add( $inflator, $chunk );
135
136
        if( $inflatedChunk === false ) break;
137
138
        if( !$headerFound ) {
139
          $buffer .= $inflatedChunk;
140
          $nullPos = strpos( $buffer, "\0" );
141
142
          if( $nullPos !== false ) {
143
            $body = substr( $buffer, $nullPos + 1 );
144
145
            if( $body !== '' ) {
146
              $callback( $body );
147
            }
148
149
            $headerFound = true;
150
            $buffer      = '';
151
          }
152
        } else {
153
          $callback( $inflatedChunk );
154
        }
155
      }
156
157
      fclose( $fileHandle );
158
      return;
159
    }
160
161
    $data = $this->packs->read( $sha );
162
163
    if( $data !== null && $data !== '' ) {
164
      $callback( $data );
165
    }
166
  }
167
168
  public function history( string $ref, int $limit, callable $callback ): void {
169
    $currentSha = $this->resolve( $ref );
170
    $count      = 0;
171
172
    while( $currentSha !== '' && $count < $limit ) {
173
      $data = $this->read( $currentSha );
174
175
      if( $data === '' ) {
176
        break;
177
      }
178
179
      $position = strpos( $data, "\n\n" );
180
      $message  = $position !== false ? substr( $data, $position + 2 ) : '';
181
      preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $matches );
182
183
      $callback( (object)[
184
        'sha'     => $currentSha,
185
        'message' => trim( $message ),
186
        'author'  => $matches[1] ?? 'Unknown',
187
        'email'   => $matches[2] ?? '',
188
        'date'    => (int)( $matches[3] ?? 0 )
189
      ] );
190
191
      $currentSha = preg_match(
192
        '/^parent ([0-9a-f]{40})$/m',
193
        $data,
194
        $parentMatches
195
      ) ? $parentMatches[1] : '';
196
197
      $count++;
198
    }
199
  }
200
201
  public function walk( string $refOrSha, callable $callback ): void {
202
    $sha  = $this->resolve( $refOrSha );
203
    $data = $sha !== '' ? $this->read( $sha ) : '';
204
205
    if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) {
206
      $data = $this->read( $matches[1] );
207
    }
208
209
    if( $this->isTreeData( $data ) ) {
210
      $this->processTree( $data, $callback );
211
    }
212
  }
213
214
  private function processTree( string $data, callable $callback ): void {
215
    $position = 0;
216
    $length   = strlen( $data );
217
218
    while( $position < $length ) {
219
      $spacePos = strpos( $data, ' ', $position );
220
      $nullPos  = strpos( $data, "\0", $spacePos );
221
222
      if( $spacePos === false || $nullPos === false ) {
223
        break;
224
      }
225
226
      $mode = substr( $data, $position, $spacePos - $position );
227
      $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
228
      $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );
229
230
      $isDirectory = $mode === '40000' || $mode === '040000';
231
      $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
232
233
      $callback( new File( $name, $sha, $mode, 0, $size ) );
234
235
      $position = $nullPos + 21;
236
    }
237
  }
238
239
  private function isTreeData( string $data ): bool {
240
    $pattern = '/^(40000|100644|100755|120000|160000) /';
241
242
    if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) {
243
      $nullPos = strpos( $data, "\0" );
244
245
      return $nullPos !== false && ($nullPos + 21 <= strlen( $data ));
246
    }
247
248
    return false;
249
  }
250
251
  private function getLoosePath( string $sha ): string {
252
    return "{$this->objectsPath}/" . substr( $sha, 0, 2 ) . "/" .
253
           substr( $sha, 2 );
254
  }
255
256
  private function getLooseObjectSize( string $sha ): int {
257
    $path = $this->getLoosePath( $sha );
258
259
    if( !file_exists( $path ) ) {
260
      return 0;
261
    }
262
263
    $fileHandle = @fopen( $path, 'rb' );
264
265
    if( !$fileHandle ) {
266
      return 0;
267
    }
268
269
    $data     = '';
270
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
271
272
    while( !feof( $fileHandle ) ) {
273
      $chunk  = fread( $fileHandle, self::CHUNK_SIZE );
274
      $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH );
275
276
      if( $output === false ) {
277
        break;
278
      }
279
280
      $data .= $output;
281
282
      if( strpos( $data, "\0" ) !== false ) {
283
        break;
284
      }
285
    }
286
287
    fclose( $fileHandle );
288
289
    $header = explode( "\0", $data, 2 )[0];
290
    $parts  = explode( ' ', $header );
291
292
    return isset( $parts[1] ) ? (int)$parts[1] : 0;
293
  }
294
}
295 1
D GitDiff.php
1
<?php
2
require_once 'File.php';
3
4
class GitDiff {
5
  private $git;
6
  private const MAX_DIFF_SIZE = 1048576;
7
8
  public function __construct(Git $git) {
9
    $this->git = $git;
10
  }
11
12
  public function compare(string $commitHash) {
13
    $commitData = $this->git->read($commitHash);
14
    $parentHash = '';
15
16
    if (preg_match('/^parent ([0-9a-f]{40})/m', $commitData, $matches)) {
17
      $parentHash = $matches[1];
18
    }
19
20
    $newTree = $this->getTreeHash($commitHash);
21
    $oldTree = $parentHash ? $this->getTreeHash($parentHash) : null;
22
23
    return $this->diffTrees($oldTree, $newTree);
24
  }
25
26
  private function getTreeHash($commitSha) {
27
    $data = $this->git->read($commitSha);
28
    if (preg_match('/^tree ([0-9a-f]{40})/m', $data, $matches)) {
29
      return $matches[1];
30
    }
31
    return null;
32
  }
33
34
  private function diffTrees($oldTreeSha, $newTreeSha, $path = '') {
35
    $changes = [];
36
37
    if ($oldTreeSha === $newTreeSha) return [];
38
39
    $oldEntries = $oldTreeSha ? $this->parseTree($oldTreeSha) : [];
40
    $newEntries = $newTreeSha ? $this->parseTree($newTreeSha) : [];
41
42
    $allNames = array_unique(array_merge(array_keys($oldEntries), array_keys($newEntries)));
43
    sort($allNames);
44
45
    foreach ($allNames as $name) {
46
      $old = $oldEntries[$name] ?? null;
47
      $new = $newEntries[$name] ?? null;
48
      $currentPath = $path ? "$path/$name" : $name;
49
50
      if (!$old) {
51
        if ($new['is_dir']) {
52
           $changes = array_merge($changes, $this->diffTrees(null, $new['sha'], $currentPath));
53
        } else {
54
           $changes[] = $this->createChange('A', $currentPath, null, $new['sha']);
55
        }
56
      } elseif (!$new) {
57
        if ($old['is_dir']) {
58
           $changes = array_merge($changes, $this->diffTrees($old['sha'], null, $currentPath));
59
        } else {
60
           $changes[] = $this->createChange('D', $currentPath, $old['sha'], null);
61
        }
62
      } elseif ($old['sha'] !== $new['sha']) {
63
        if ($old['is_dir'] && $new['is_dir']) {
64
          $changes = array_merge($changes, $this->diffTrees($old['sha'], $new['sha'], $currentPath));
65
        } elseif (!$old['is_dir'] && !$new['is_dir']) {
66
          $changes[] = $this->createChange('M', $currentPath, $old['sha'], $new['sha']);
67
        }
68
      }
69
    }
70
71
    return $changes;
72
  }
73
74
  private function parseTree($sha) {
75
    $data = $this->git->read($sha);
76
    $entries = [];
77
    $len = strlen($data);
78
    $pos = 0;
79
80
    while ($pos < $len) {
81
      $space = strpos($data, ' ', $pos);
82
      $null = strpos($data, "\0", $space);
83
84
      if ($space === false || $null === false) break;
85
86
      $mode = substr($data, $pos, $space - $pos);
87
      $name = substr($data, $space + 1, $null - $space - 1);
88
      $hash = bin2hex(substr($data, $null + 1, 20));
89
90
      $entries[$name] = [
91
        'mode' => $mode,
92
        'sha' => $hash,
93
        'is_dir' => ($mode === '40000' || $mode === '040000')
94
      ];
95
96
      $pos = $null + 21;
97
    }
98
    return $entries;
99
  }
100
101
  private function createChange($type, $path, $oldSha, $newSha) {
102
    // Check file sizes before reading content to prevent OOM
103
    $oldSize = $oldSha ? $this->git->getObjectSize($oldSha) : 0;
104
    $newSize = $newSha ? $this->git->getObjectSize($newSha) : 0;
105
106
    // If file is too large, skip diffing and treat as binary
107
    if ($oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE) {
108
      return [
109
        'type' => $type,
110
        'path' => $path,
111
        'is_binary' => true,
112
        'hunks' => []
113
      ];
114
    }
115
116
    $oldContent = $oldSha ? $this->git->read($oldSha) : '';
117
    $newContent = $newSha ? $this->git->read($newSha) : '';
118
119
    $isBinary = false;
120
121
    if ($newSha) {
122
        $f = new VirtualDiffFile($path, $newContent);
123
        if ($f->isBinary()) $isBinary = true;
124
    }
125
    if (!$isBinary && $oldSha) {
126
        $f = new VirtualDiffFile($path, $oldContent);
127
        if ($f->isBinary()) $isBinary = true;
128
    }
129
130
    $diff = null;
131
    if (!$isBinary) {
132
      $diff = $this->calculateDiff($oldContent, $newContent);
133
    }
134
135
    return [
136
      'type' => $type,
137
      'path' => $path,
138
      'is_binary' => $isBinary,
139
      'hunks' => $diff
140
    ];
141
  }
142
143
  private function calculateDiff($old, $new) {
144
    // Normalize line endings
145
    $old = str_replace("\r\n", "\n", $old);
146
    $new = str_replace("\r\n", "\n", $new);
147
148
    $oldLines = explode("\n", $old);
149
    $newLines = explode("\n", $new);
150
151
    $m = count($oldLines);
152
    $n = count($newLines);
153
154
    // LCS Algorithm Optimization: Trim matching start/end
155
    $start = 0;
156
    while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) {
157
      $start++;
158
    }
159
160
    $end = 0;
161
    while ($m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]) {
162
      $end++;
163
    }
164
165
    $oldSlice = array_slice($oldLines, $start, $m - $start - $end);
166
    $newSlice = array_slice($newLines, $start, $n - $start - $end);
167
168
    $cntOld = count($oldSlice);
169
    $cntNew = count($newSlice);
170
171
    if (($cntOld * $cntNew) > 500000) {
172
        return [['t' => 'gap']];
173
    }
174
175
    $ops = $this->computeLCS($oldSlice, $newSlice);
176
177
    $groupedOps = [];
178
    $bufferDel = [];
179
    $bufferAdd = [];
180
181
    foreach ($ops as $op) {
182
        if ($op['t'] === ' ') {
183
            foreach ($bufferDel as $o) $groupedOps[] = $o;
184
            foreach ($bufferAdd as $o) $groupedOps[] = $o;
185
            $bufferDel = [];
186
            $bufferAdd = [];
187
            $groupedOps[] = $op;
188
        } elseif ($op['t'] === '-') {
189
            $bufferDel[] = $op;
190
        } elseif ($op['t'] === '+') {
191
            $bufferAdd[] = $op;
192
        }
193
    }
194
    foreach ($bufferDel as $o) $groupedOps[] = $o;
195
    foreach ($bufferAdd as $o) $groupedOps[] = $o;
196
    $ops = $groupedOps;
197
198
    // Generate Stream with Context
199
    $stream = [];
200
201
    // Prefix context
202
    for ($i = 0; $i < $start; $i++) {
203
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1];
204
    }
205
206
    $currO = $start + 1;
207
    $currN = $start + 1;
208
209
    foreach ($ops as $op) {
210
        if ($op['t'] === ' ') {
211
            $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++];
212
        } elseif ($op['t'] === '-') {
213
            $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null];
214
        } elseif ($op['t'] === '+') {
215
            $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++];
216
        }
217
    }
218
219
    // Suffix context
220
    for ($i = $m - $end; $i < $m; $i++) {
221
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++];
222
    }
223
224
    // Filter to Hunks
225
    $finalLines = [];
226
    $lastVisibleIndex = -1;
227
    $streamLen = count($stream);
228
    $contextLines = 3;
229
230
    for ($i = 0; $i < $streamLen; $i++) {
231
        $show = false;
232
233
        if ($stream[$i]['t'] !== ' ') {
234
            $show = true;
235
        } else {
236
            // Check ahead
237
            for ($j = 1; $j <= $contextLines; $j++) {
238
                if (($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ') {
239
                    $show = true;
240
                    break;
241
                }
242
            }
243
            // Check behind
244
            if (!$show) {
245
                for ($j = 1; $j <= $contextLines; $j++) {
246
                    if (($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ') {
247
                        $show = true;
248
                        break;
249
                    }
250
                }
251
            }
252
        }
253
254
        if ($show) {
255
            if ($lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1) {
256
                $finalLines[] = ['t' => 'gap'];
257
            }
258
            $finalLines[] = $stream[$i];
259
            $lastVisibleIndex = $i;
260
        }
261
    }
262
263
    return $finalLines;
264
  }
265
266
  private function computeLCS($old, $new) {
267
    $m = count($old);
268
    $n = count($new);
269
    $c = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
270
271
    for ($i = 1; $i <= $m; $i++) {
272
      for ($j = 1; $j <= $n; $j++) {
273
        if ($old[$i-1] === $new[$j-1]) {
274
          $c[$i][$j] = $c[$i-1][$j-1] + 1;
275
        } else {
276
          $c[$i][$j] = max($c[$i][$j-1], $c[$i-1][$j]);
277
        }
278
      }
279
    }
280
281
    $diff = [];
282
    $i = $m; $j = $n;
283
    while ($i > 0 || $j > 0) {
284
      if ($i > 0 && $j > 0 && $old[$i-1] === $new[$j-1]) {
285
        array_unshift($diff, ['t' => ' ', 'l' => $old[$i-1]]);
286
        $i--; $j--;
287
      } elseif ($j > 0 && ($i === 0 || $c[$i][$j-1] >= $c[$i-1][$j])) {
288
        array_unshift($diff, ['t' => '+', 'l' => $new[$j-1]]);
289
        $j--;
290
      } elseif ($i > 0 && ($j === 0 || $c[$i][$j-1] < $c[$i-1][$j])) {
291
        array_unshift($diff, ['t' => '-', 'l' => $old[$i-1]]);
292
        $i--;
293
      }
294
    }
295
    return $diff;
296
  }
297
}
298
299
class VirtualDiffFile extends File {
300
  private $content;
301
  private $vName;
302
303
  public function __construct($name, $content) {
304
    parent::__construct($name, '', '100644', 0, strlen($content));
305
    $this->vName = $name;
306
    $this->content = $content;
307
  }
308
309
  public function isBinary(): bool {
310
    $buffer = substr($this->content, 0, 12);
311
    return MediaTypeSniffer::isBinary($buffer, $this->vName);
312
  }
313
}
314 1
D 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
    $length = strlen( $data );
367
368
    while( $position < $length && (ord( $data[$position++] ) & 128) ) {
369
      // Loop continues while MSB is 1
370
    }
371
  }
372
373
  private function readSize( string $data, int &$position ): int {
374
    $byte  = ord( $data[$position++] );
375
    $value = $byte & 127;
376
    $shift = 7;
377
378
    while( $byte & 128 ) {
379
      $byte = ord( $data[$position++] );
380
      $value |= (($byte & 127) << $shift);
381
      $shift += 7;
382
    }
383
384
    return $value;
385
  }
386
387
  private function getHandle( string $path ) {
388
    if( !isset( $this->fileHandles[$path] ) ) {
389
      $this->fileHandles[$path] = @fopen( $path, 'rb' );
390
    }
391
392
    return $this->fileHandles[$path];
393
  }
394
}
395 1
D 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
}
98 1
D HomePage.php
1
<?php
2
class HomePage extends BasePage {
3
  private $git;
4
5
  public function __construct(array $repositories, Git $git) {
6
    parent::__construct($repositories);
7
    $this->git = $git;
8
  }
9
10
  public function render() {
11
    $this->renderLayout(function() {
12
      echo '<h2>Repositories</h2>';
13
      if (empty($this->repositories)) {
14
        echo '<div class="empty-state">No repositories found.</div>';
15
        return;
16
      }
17
      echo '<div class="repo-grid">';
18
      foreach ($this->repositories as $repo) {
19
        $this->renderRepoCard($repo);
20
      }
21
      echo '</div>';
22
    });
23
  }
24
25
  private function renderRepoCard($repo) {
26
    $this->git->setRepository($repo['path']);
27
28
    $main = $this->git->getMainBranch();
29
30
    $stats = ['branches' => 0, 'tags' => 0];
31
    $this->git->eachBranch(function() use (&$stats) { $stats['branches']++; });
32
    $this->git->eachTag(function() use (&$stats) { $stats['tags']++; });
33
34
    echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">';
35
    echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>';
36
37
    echo '<p class="repo-meta">';
38
39
    $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches';
40
    $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags';
41
42
    echo $stats['branches'] . ' ' . $branchLabel . ', ' . $stats['tags'] . ' ' . $tagLabel;
43
44
    if ($main) {
45
      echo ', ';
46
      $this->git->history('HEAD', 1, function($c) use ($repo) {
47
        $renderer = new HtmlFileRenderer($repo['safe_name']);
48
        $renderer->renderTime($c->date);
49
      });
50
    }
51
    echo '</p>';
52
53
    $descPath = $repo['path'] . '/description';
54
    if (file_exists($descPath)) {
55
      $description = trim(file_get_contents($descPath));
56
      if ($description !== '') {
57
        echo '<p style="margin-top: 1.5em;">' . htmlspecialchars($description) . '</p>';
58
      }
59
    }
60
61
    echo '</a>';
62
  }
63
}
64 1
D MediaTypeSniffer.php
1
<?php
2
class MediaTypeSniffer {
3
  private const BUFFER = 12;
4
  private const ANY = -1;
5
6
  public const CAT_IMAGE   = 'image';
7
  public const CAT_VIDEO   = 'video';
8
  public const CAT_AUDIO   = 'audio';
9
  public const CAT_TEXT    = 'text';
10
  public const CAT_ARCHIVE = 'archive';
11
  public const CAT_APP     = 'application';
12
  public const CAT_BINARY  = 'binary';
13
14
  private const FORMATS = [
15
    [self::CAT_IMAGE, [0x3C, 0x73, 0x76, 0x67, 0x20], 'image/svg+xml'],
16
    [self::CAT_IMAGE, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'image/png'],
17
    [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xE0], 'image/jpeg'],
18
    [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xEE], 'image/jpeg'],
19
    [self::CAT_IMAGE, [0xFF, 0xD8, 0xFF, 0xE1, self::ANY, self::ANY, 0x45, 0x78, 0x69, 0x66, 0x00], 'image/jpeg'],
20
    [self::CAT_IMAGE, [0x47, 0x49, 0x46, 0x38], 'image/gif'],
21
    [self::CAT_IMAGE, [0x42, 0x4D], 'image/bmp'],
22
    [self::CAT_IMAGE, [0x49, 0x49, 0x2A, 0x00], 'image/tiff'],
23
    [self::CAT_IMAGE, [0x4D, 0x4D, 0x00, 0x2A], 'image/tiff'],
24
    [self::CAT_IMAGE, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x57, 0x45, 0x42, 0x50], 'image/webp'],
25
    [self::CAT_IMAGE, [0x38, 0x42, 0x50, 0x53, 0x00, 0x01], 'image/vnd.adobe.photoshop'],
26
    [self::CAT_IMAGE, [0x23, 0x64, 0x65, 0x66], 'image/x-xbitmap'],
27
    [self::CAT_IMAGE, [0x21, 0x20, 0x58, 0x50, 0x4D, 0x32], 'image/x-xpixmap'],
28
    [self::CAT_VIDEO, [0x8A, 0x4D, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], 'video/x-mng'],
29
    [self::CAT_VIDEO, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x41, 0x56, 0x49, 0x20], 'video/x-msvideo'],
30
    [self::CAT_VIDEO, [self::ANY, self::ANY, self::ANY, self::ANY, 0x66, 0x74, 0x79, 0x70], 'video/mp4'],
31
    [self::CAT_VIDEO, [0x1A, 0x45, 0xDF, 0xA3], 'video/x-matroska'],
32
    [self::CAT_VIDEO, [0x00, 0x00, 0x01, 0xBA], 'video/mpeg'],
33
    [self::CAT_VIDEO, [0x46, 0x4C, 0x56, 0x01], 'video/x-flv'],
34
    [self::CAT_TEXT,  [0x3C, 0x21], 'text/html'],
35
    [self::CAT_TEXT,  [0x3C, 0x68, 0x74, 0x6D, 0x6C], 'text/html'],
36
    [self::CAT_TEXT,  [0x3C, 0x68, 0x65, 0x61, 0x64], 'text/html'],
37
    [self::CAT_TEXT,  [0x3C, 0x62, 0x6F, 0x64, 0x79], 'text/html'],
38
    [self::CAT_TEXT,  [0x3C, 0x3F, 0x78, 0x6D, 0x6C, 0x20], 'text/xml'],
39
    [self::CAT_TEXT,  [0x25, 0x50, 0x44, 0x46, 0x2D], 'application/pdf'],
40
    [self::CAT_TEXT,  [0xEF, 0xBB, 0xBF], 'text/plain'],
41
    [self::CAT_TEXT,  [0xFE, 0xFF], 'text/plain'],
42
    [self::CAT_TEXT,  [0xFF, 0xFE], 'text/plain'],
43
    [self::CAT_TEXT,  [0x00, 0x00, 0xFE, 0xFF], 'text/plain'],
44
    [self::CAT_TEXT,  [0xFF, 0xFE, 0x00, 0x00], 'text/plain'],
45
    [self::CAT_AUDIO, [0xFF, 0xFB, self::ANY], 'audio/mpeg'],
46
    [self::CAT_AUDIO, [0x49, 0x44, 0x33], 'audio/mpeg'],
47
    [self::CAT_AUDIO, [0x52, 0x49, 0x46, 0x46, self::ANY, self::ANY, self::ANY, self::ANY, 0x57, 0x41, 0x56, 0x45], 'audio/wav'],
48
    [self::CAT_AUDIO, [0x4F, 0x67, 0x67, 0x53], 'audio/ogg'],
49
    [self::CAT_ARCHIVE, [0x50, 0x4B, 0x03, 0x04], 'application/zip'],
50
    [self::CAT_ARCHIVE, [0x1F, 0x8B, 0x08], 'application/gzip'],
51
    [self::CAT_APP,   [0x7F, 0x45, 0x4C, 0x46], 'application/x-elf']
52
  ];
53
54
  private const EXTENSION_MAP = [
55
    // Documentation / markup
56
    'md'        => [self::CAT_TEXT, 'text/markdown'],
57
    'rmd'       => [self::CAT_TEXT, 'text/r-markdown'],
58
    'txt'       => [self::CAT_TEXT, 'text/plain'],
59
    'tex'       => [self::CAT_TEXT, 'application/x-tex'],
60
    'lyx'       => [self::CAT_TEXT, 'application/x-lyx'],
61
    'rst'       => [self::CAT_TEXT, 'text/x-rst'],
62
    'asciidoc'  => [self::CAT_TEXT, 'text/asciidoc'],
63
    'adoc'      => [self::CAT_TEXT, 'text/asciidoc'],
64
    'org'       => [self::CAT_TEXT, 'text/org'],
65
    'latex'     => [self::CAT_TEXT, 'application/x-tex'],
66
    'csv'       => [self::CAT_TEXT, 'text/csv'],
67
    'tsv'       => [self::CAT_TEXT, 'text/tab-separated-values'],
68
    'psv'       => [self::CAT_TEXT, 'text/plain'],
69
70
    'json'      => [self::CAT_TEXT, 'application/json'],
71
    'xml'       => [self::CAT_TEXT, 'application/xml'],
72
    'gitignore' => [self::CAT_TEXT, 'text/plain'],
73
    'ts'        => [self::CAT_TEXT, 'application/typescript'],
74
    'log'       => [self::CAT_TEXT, 'text/plain'],
75
    'ndjson'    => [self::CAT_TEXT, 'application/x-ndjson'],
76
    'conf'      => [self::CAT_TEXT, 'text/plain'],
77
    'ini'       => [self::CAT_TEXT, 'text/plain'],
78
    'yaml'      => [self::CAT_TEXT, 'text/yaml'],
79
    'yml'       => [self::CAT_TEXT, 'text/yaml'],
80
    'toml'      => [self::CAT_TEXT, 'application/toml'],
81
    'env'       => [self::CAT_TEXT, 'text/plain'],
82
    'cfg'       => [self::CAT_TEXT, 'text/plain'],
83
    'properties'=> [self::CAT_TEXT, 'text/plain'],
84
    'dotenv'    => [self::CAT_TEXT, 'text/plain'],
85
86
    // Programming languages
87
    'gradle'    => [self::CAT_TEXT, 'text/plain'],
88
    'php'       => [self::CAT_TEXT, 'application/x-php'],
89
    'sql'       => [self::CAT_TEXT, 'application/sql'],
90
    'html'      => [self::CAT_TEXT, 'text/html'],
91
    'xhtml'     => [self::CAT_TEXT, 'text/xhtml'],
92
    'css'       => [self::CAT_TEXT, 'text/css'],
93
    'js'        => [self::CAT_TEXT, 'application/javascript'],
94
    'py'        => [self::CAT_TEXT, 'text/x-python'],
95
    'rb'        => [self::CAT_TEXT, 'text/x-ruby'],
96
    'java'      => [self::CAT_TEXT, 'text/x-java-source'],
97
    'c'         => [self::CAT_TEXT, 'text/x-csrc'],
98
    'cpp'       => [self::CAT_TEXT, 'text/x-c++src'],
99
    'h'         => [self::CAT_TEXT, 'text/x-chdr'],
100
    'cs'        => [self::CAT_TEXT, 'text/x-csharp'],
101
    'go'        => [self::CAT_TEXT, 'text/x-go'],
102
    'rs'        => [self::CAT_TEXT, 'text/x-rust'],
103
    'swift'     => [self::CAT_TEXT, 'text/x-swift'],
104
    'kt'        => [self::CAT_TEXT, 'text/x-kotlin'],
105
    'kts'       => [self::CAT_TEXT, 'text/x-kotlin'],
106
    'scala'     => [self::CAT_TEXT, 'text/x-scala'],
107
    'dart'      => [self::CAT_TEXT, 'text/x-dart'],
108
    'lua'       => [self::CAT_TEXT, 'text/x-lua'],
109
    'pl'        => [self::CAT_TEXT, 'text/x-perl'],
110
    'pm'        => [self::CAT_TEXT, 'text/x-perl'],
111
    'r'         => [self::CAT_TEXT, 'text/x-r'],
112
    'm'         => [self::CAT_TEXT, 'text/x-matlab'],
113
    'jl'        => [self::CAT_TEXT, 'text/x-julia'],
114
115
    // Shell / scripting
116
    'sh'        => [self::CAT_TEXT, 'application/x-sh'],
117
    'bash'      => [self::CAT_TEXT, 'application/x-sh'],
118
    'zsh'       => [self::CAT_TEXT, 'application/x-sh'],
119
    'fish'      => [self::CAT_TEXT, 'text/plain'],
120
    'bat'       => [self::CAT_TEXT, 'application/x-msdos-program'],
121
    'ps1'       => [self::CAT_TEXT, 'application/x-powershell']
122
  ];
123
124
  private static function getTypeInfo( string $data, string $filePath ): array {
125
    $info = [];
126
    $ext = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) );
127
128
    if( $ext === 'svg' ){
129
      $info = [self::CAT_IMAGE, 'image/svg+xml'];
130
    }
131
132
    if( empty( $info ) ){
133
      $info = self::sniff( $data );
134
    }
135
136
    if( empty( $info ) && !empty( $filePath ) ){
137
      $info = self::getInfoByExtension( $filePath );
138
    }
139
140
    if( empty( $info ) ){
141
      $info = [self::CAT_BINARY, 'application/octet-stream'];
142
    }
143
144
    return $info;
145
  }
146
147
  private static function sniff( string $data ): array {
148
    $found = [];
149
    $dataLength = strlen( $data );
150
    $maxScan = min( $dataLength, self::BUFFER );
151
    $sourceBytes = [];
152
153
    for( $i = 0; $i < $maxScan; $i++ ){
154
      $sourceBytes[$i] = ord( $data[$i] ) & 0xFF;
155
    }
156
157
    foreach( self::FORMATS as [$category, $pattern, $type] ){
158
      $patternLength = count( $pattern );
159
160
      if( $patternLength > $dataLength ){
161
        continue;
162
      }
163
164
      $matches = true;
165
166
      for( $i = 0; $i < $patternLength; $i++ ){
167
        if( $pattern[$i] !== self::ANY && $pattern[$i] !== $sourceBytes[$i] ){
168
          $matches = false;
169
          break;
170
        }
171
      }
172
173
      if( $matches ){
174
        $found = [$category, $type];
175
        break;
176
      }
177
    }
178
179
    return $found;
180
  }
181
182
  private static function getInfoByExtension( string $filePath ): array {
183
    $ext = strtolower( pathinfo( $filePath, PATHINFO_EXTENSION ) );
184
    $info = self::EXTENSION_MAP[$ext] ?? [self::CAT_BINARY, 'application/octet-stream'];
185
186
    return $info;
187
  }
188
189
  public static function isMediaType( string $data, string $filePath = '' ): string {
190
    $info = self::getTypeInfo( $data, $filePath );
191
192
    return $info[1];
193
  }
194
195
  public static function isCategory( string $data, string $filePath = '' ): string {
196
    $info = self::getTypeInfo( $data, $filePath );
197
198
    return $info[0];
199
  }
200
201
  public static function isBinary( string $data, string $filePath = '' ): bool {
202
    $info = self::getTypeInfo( $data, $filePath );
203
    $category = $info[0];
204
    $type = $info[1];
205
206
    return !(
207
      $category === self::CAT_TEXT ||
208
      str_starts_with( $type, 'text/' ) ||
209
      $type === 'image/svg+xml'
210
    );
211
  }
212
}
213
?>
214 1
D Page.php
1
<?php
2
interface Page {
3
    public function render();
4
}
5 1
D RawPage.php
1
<?php
2
class RawPage implements Page {
3
  private $git;
4
  private $hash;
5
6
  public function __construct( $git, $hash ) {
7
    $this->git = $git;
8
    $this->hash = $hash;
9
  }
10
11
  public function render() {
12
    $filename = $_GET['name'] ?? 'file';
13
    $buffer = '';
14
15
    $size = $this->git->getObjectSize( $this->hash );
16
17
    $this->git->stream( $this->hash, function( $d ) use ( &$buffer ) {
18
      if( strlen( $buffer ) < 12 ) {
19
        $buffer .= $d;
20
      }
21
    } );
22
23
    $mediaType = MediaTypeSniffer::isMediaType( $buffer, $filename );
24
25
    while( ob_get_level() ) {
26
      ob_end_clean();
27
    }
28
29
    header( "Content-Type: " . $mediaType );
30
    header( "Content-Length: " . $size );
31
    header( "Content-Disposition: inline; filename=\"" . addslashes( $filename ) . "\"" );
32
33
    $this->git->stream( $this->hash, function( $d ) {
34
      echo $d;
35
    } );
36
37
    exit;
38
  }
39
}
40 1
M Router.php
1 1
<?php
2
require_once 'Views.php';
3
require_once 'RepositoryList.php';
4
require_once 'Git.php';
5
require_once 'GitDiff.php';
6
require_once 'DiffPage.php';
7
require_once 'TagsPage.php';
2
require_once __DIR__ . '/RepositoryList.php';
3
require_once __DIR__ . '/git/Git.php';
4
5
require_once __DIR__ . '/pages/CommitsPage.php';
6
require_once __DIR__ . '/pages/DiffPage.php';
7
require_once __DIR__ . '/pages/HomePage.php';
8
require_once __DIR__ . '/pages/FilePage.php';
9
require_once __DIR__ . '/pages/RawPage.php';
10
require_once __DIR__ . '/pages/TagsPage.php';
11
require_once __DIR__ . '/pages/ClonePage.php';
8 12
9 13
class Router {
10
  private $repositories = [];
14
  private $repos = [];
11 15
  private $git;
12 16
13
  public function __construct(string $reposPath) {
14
    $this->git = new Git($reposPath);
17
  public function __construct( string $reposPath ) {
18
    $this->git = new Git( $reposPath );
15 19
16
    $list = new RepositoryList($reposPath);
17
    $list->eachRepository(function($repo) {
18
      $this->repositories[] = $repo;
19
    });
20
    $list = new RepositoryList( $reposPath );
21
22
    $list->eachRepository( function( $repo ) {
23
      $this->repos[] = $repo;
24
    } );
20 25
  }
21 26
22 27
  public function route(): Page {
23 28
    $reqRepo = $_GET['repo'] ?? '';
24
    $action = $_GET['action'] ?? 'home';
25
    $hash = $this->sanitizePath($_GET['hash'] ?? '');
29
    $action  = $_GET['action'] ?? 'file';
30
    $hash    = $this->sanitize( $_GET['hash'] ?? '' );
31
    $subPath = '';
26 32
27
    $currentRepo = null;
28
    $decoded = urldecode($reqRepo);
33
    $uri = parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH );
34
    $scriptName = $_SERVER['SCRIPT_NAME'];
29 35
30
    foreach ($this->repositories as $repo) {
31
      if ($repo['safe_name'] === $reqRepo || $repo['name'] === $decoded) {
32
        $currentRepo = $repo;
33
        break;
34
      }
36
    if ( strpos( $uri, $scriptName ) === 0 ) {
37
      $uri = substr( $uri, strlen( $scriptName ) );
35 38
    }
36 39
37
    if (!$currentRepo) {
38
      return new HomePage($this->repositories, $this->git);
40
    if( preg_match( '#^/([^/]+)\.git(?:/(.*))?$#', $uri, $matches ) ) {
41
      $reqRepo = urldecode( $matches[1] );
42
      $subPath = isset( $matches[2] ) ? ltrim( $matches[2], '/' ) : '';
43
      $action  = 'clone';
39 44
    }
40 45
41
    $this->git->setRepository($currentRepo['path']);
46
    $currRepo = null;
47
    $decoded  = urldecode( $reqRepo );
42 48
43
    if ($action === 'raw') {
44
      return new RawPage($this->git, $hash);
45
    }
49
    foreach( $this->repos as $repo ) {
50
      if( $repo['safe_name'] === $reqRepo || $repo['name'] === $decoded ) {
51
        $currRepo = $repo;
52
        break;
53
      }
46 54
47
    if ($action === 'commit') {
48
      return new DiffPage($this->repositories, $currentRepo, $this->git, $hash);
49
    }
55
      $prefix = $repo['safe_name'] . '/';
50 56
51
    if ($action === 'commits') {
52
      return new CommitsPage($this->repositories, $currentRepo, $this->git, $hash);
57
      if( strpos( $reqRepo, $prefix ) === 0 ) {
58
        $currRepo = $repo;
59
        $subPath  = substr( $reqRepo, strlen( $prefix ) );
60
        $action   = 'clone';
61
        break;
62
      }
53 63
    }
54 64
55
    if ($action === 'tags') {
56
      return new TagsPage($this->repositories, $currentRepo, $this->git);
65
    if( $currRepo ) {
66
      $this->git->setRepository( $currRepo['path'] );
57 67
    }
58 68
59
    return new FilePage($this->repositories, $currentRepo, $this->git, $hash);
69
    $routes = [
70
      'home'    => fn() => new HomePage( $this->repos, $this->git ),
71
      'file'    => fn() => new FilePage( $this->repos, $currRepo, $this->git, $hash ),
72
      'raw'     => fn() => new RawPage( $this->git, $hash ),
73
      'commit'  => fn() => new DiffPage( $this->repos, $currRepo, $this->git, $hash ),
74
      'commits' => fn() => new CommitsPage( $this->repos, $currRepo, $this->git, $hash ),
75
      'tags'    => fn() => new TagsPage( $this->repos, $currRepo, $this->git ),
76
      'clone'   => fn() => new ClonePage( $this->git, $subPath ),
77
    ];
78
79
    $action = !$currRepo ? 'home' : $action;
80
81
    return ($routes[$action] ?? $routes['file'])();
60 82
  }
61 83
62
  private function sanitizePath($path) {
63
    $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path);
64
    return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path);
84
  private function sanitize( $path ) {
85
    $path = str_replace( [ '..', '\\', "\0" ], [ '', '/', '' ], $path );
86
87
    return preg_replace( '/[^a-zA-Z0-9_\-\.\/]/', '', $path );
65 88
  }
66 89
}
M Tag.php
1 1
<?php
2
require_once 'TagRenderer.php';
2
require_once __DIR__ . '/render/TagRenderer.php';
3 3
4 4
class Tag {
D TagRenderer.php
1
<?php
2
interface TagRenderer {
3
  public function renderTagItem(
4
    string $name,
5
    string $sha,
6
    string $targetSha,
7
    int $timestamp,
8
    string $message,
9
    string $author
10
  ): void;
11
12
  public function renderTime(int $timestamp): void;
13
}
14
15
class HtmlTagRenderer implements TagRenderer {
16
  private string $repoSafeName;
17
18
  public function __construct(string $repoSafeName) {
19
    $this->repoSafeName = $repoSafeName;
20
  }
21
22
  public function renderTagItem(
23
    string $name,
24
    string $sha,
25
    string $targetSha,
26
    int $timestamp,
27
    string $message,
28
    string $author
29
  ): void {
30
    $repoParam = '&repo=' . urlencode($this->repoSafeName);
31
    $filesUrl  = '?hash=' . $targetSha . $repoParam;
32
    $commitUrl = '?action=commits&hash=' . $targetSha . $repoParam;
33
34
    echo '<tr>';
35
36
    // 1. Name
37
    echo '<td class="tag-name">';
38
    echo   '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' . htmlspecialchars($name) . '</a>';
39
    echo '</td>';
40
41
    // 2. Message
42
    echo '<td class="tag-message">';
43
    echo ($message !== '') ? htmlspecialchars(strtok($message, "\n")) : '<span style="color: #484f58; font-style: italic;">No description</span>';
44
    echo '</td>';
45
46
    // 3. Author
47
    echo '<td class="tag-author">' . htmlspecialchars($author) . '</td>';
48
49
    // 4. Timestamp
50
    echo '<td class="tag-time">';
51
    $this->renderTime($timestamp);
52
    echo '</td>';
53
54
    // 5. Hash
55
    echo '<td class="tag-hash">';
56
    echo   '<a href="' . $commitUrl . '" class="commit-hash">' . substr($sha, 0, 7) . '</a>';
57
    echo '</td>';
58
59
    echo '</tr>';
60
  }
61
62
  public function renderTime(int $timestamp): void {
63
    if (!$timestamp) { echo 'never'; return; }
64
    $diff = time() - $timestamp;
65
    if ($diff < 5) { echo 'just now'; return; }
66
67
    $tokens = [
68
      31536000 => 'year',
69
      2592000  => 'month',
70
      604800   => 'week',
71
      86400    => 'day',
72
      3600     => 'hour',
73
      60       => 'minute',
74
      1        => 'second'
75
    ];
76
77
    foreach ($tokens as $unit => $text) {
78
      if ($diff < $unit) continue;
79
      $num = floor($diff / $unit);
80
      echo $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago';
81
      return;
82
    }
83
  }
84
}
85 1
D TagsPage.php
1
<?php
2
require_once 'TagRenderer.php';
3
4
class TagsPage extends BasePage {
5
  private $currentRepo;
6
  private $git;
7
8
  public function __construct(array $repositories, array $currentRepo, Git $git) {
9
    parent::__construct($repositories);
10
    $this->currentRepo = $currentRepo;
11
    $this->git = $git;
12
    $this->title = $currentRepo['name'] . ' - Tags';
13
  }
14
15
  public function render() {
16
    $this->renderLayout(function() {
17
      $this->renderBreadcrumbs();
18
19
      echo '<h2>Tags</h2>';
20
      echo '<table class="tag-table">';
21
      echo '<thead>';
22
      echo '<tr>';
23
      echo '<th>Name</th>';
24
      echo '<th>Message</th>';
25
      echo '<th>Author</th>';
26
      echo '<th>Age</th>';
27
      echo '<th style="text-align: right;">Commit</th>';
28
      echo '</tr>';
29
      echo '</thead>';
30
      echo '<tbody>';
31
32
      $tags = [];
33
      $this->git->eachTag(function(Tag $tag) use (&$tags) {
34
        $tags[] = $tag;
35
      });
36
37
      usort($tags, function(Tag $a, Tag $b) {
38
          return $a->compare($b);
39
      });
40
41
      $renderer = new HtmlTagRenderer($this->currentRepo['safe_name']);
42
43
      if (empty($tags)) {
44
        echo '<tr><td colspan="5"><div class="empty-state"><p>No tags found.</p></div></td></tr>';
45
      } else {
46
        foreach ($tags as $tag) {
47
          $tag->render($renderer);
48
        }
49
      }
50
51
      echo '</tbody>';
52
      echo '</table>';
53
    }, $this->currentRepo);
54
  }
55
56
  private function renderBreadcrumbs() {
57
    $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']);
58
59
    $crumbs = [
60
      '<a href="?">Repositories</a>',
61
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
62
      'Tags'
63
    ];
64
65
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
66
  }
67
}
68 1
D Views.php
1
<?php
2
require_once 'File.php';
3
require_once 'FileRenderer.php';
4
require_once 'RepositoryList.php';
5
6
require_once 'BasePage.php';
7
require_once 'HomePage.php';
8
require_once 'CommitsPage.php';
9
require_once 'FilePage.php';
10
require_once 'RawPage.php';
11 1
A git/Git.php
1
<?php
2
require_once __DIR__ . '/../File.php';
3
require_once __DIR__ . '/../Tag.php';
4
require_once __DIR__ . '/GitRefs.php';
5
require_once __DIR__ . '/GitPacks.php';
6
7
class Git {
8
  private const CHUNK_SIZE    = 128;
9
  private const MAX_READ_SIZE = 1048576;
10
11
  private string $repoPath;
12
  private string $objectsPath;
13
14
  private GitRefs $refs;
15
  private GitPacks $packs;
16
17
  public function __construct( string $repoPath ) {
18
    $this->setRepository( $repoPath );
19
  }
20
21
  public function setRepository( string $repoPath ): void {
22
    $this->repoPath    = rtrim( $repoPath, '/' );
23
    $this->objectsPath = $this->repoPath . '/objects';
24
25
    $this->refs  = new GitRefs( $this->repoPath );
26
    $this->packs = new GitPacks( $this->objectsPath );
27
  }
28
29
  public function resolve( string $reference ): string {
30
    return $this->refs->resolve( $reference );
31
  }
32
33
  public function getMainBranch(): array {
34
    return $this->refs->getMainBranch();
35
  }
36
37
  public function eachBranch( callable $callback ): void {
38
    $this->refs->scanRefs( 'refs/heads', $callback );
39
  }
40
41
  public function eachTag( callable $callback ): void {
42
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) {
43
      $data = $this->read( $sha );
44
45
      $targetSha = $sha;
46
      $timestamp = 0;
47
      $message   = '';
48
      $author    = '';
49
50
      if( strncmp( $data, 'object ', 7 ) === 0 ) {
51
        if( preg_match( '/^object ([0-9a-f]{40})$/m', $data, $m ) ) {
52
          $targetSha = $m[1];
53
        }
54
        if( preg_match( '/^tagger (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) {
55
          $author    = trim( $m[1] );
56
          $timestamp = (int)$m[2];
57
        }
58
59
        $pos = strpos( $data, "\n\n" );
60
        if( $pos !== false ) {
61
          $message = trim( substr( $data, $pos + 2 ) );
62
        }
63
      } else {
64
        if( preg_match( '/^author (.*) <.*> (\d+) [+\-]\d{4}$/m', $data, $m ) ) {
65
          $author    = trim( $m[1] );
66
          $timestamp = (int)$m[2];
67
        }
68
69
        $pos = strpos( $data, "\n\n" );
70
        if( $pos !== false ) {
71
          $message = trim( substr( $data, $pos + 2 ) );
72
        }
73
      }
74
75
      $callback( new Tag(
76
        $name,
77
        $sha,
78
        $targetSha,
79
        $timestamp,
80
        $message,
81
        $author
82
      ) );
83
    } );
84
  }
85
86
  public function getObjectSize( string $sha ): int {
87
    $size = $this->packs->getSize( $sha );
88
89
    if( $size !== null ) {
90
      return $size;
91
    }
92
93
    return $this->getLooseObjectSize( $sha );
94
  }
95
96
  public function peek( string $sha, int $length = 255 ): string {
97
    $size = $this->packs->getSize( $sha );
98
99
    if( $size === null ) {
100
      return $this->peekLooseObject( $sha, $length );
101
    }
102
103
    return $this->packs->peek( $sha, $length ) ?? '';
104
  }
105
106
  public function read( string $sha ): string {
107
    $size = $this->getObjectSize( $sha );
108
109
    if( $size > self::MAX_READ_SIZE ) {
110
      return '';
111
    }
112
113
    $content = '';
114
115
    $this->slurp( $sha, function( $chunk ) use ( &$content ) {
116
      $content .= $chunk;
117
    } );
118
119
    return $content;
120
  }
121
122
  public function readFile( string $hash, string $name ) {
123
    return new File(
124
      $name,
125
      $hash,
126
      '100644',
127
      0,
128
      $this->getObjectSize( $hash ),
129
      $this->peek( $hash )
130
    );
131
  }
132
133
  public function stream( string $sha, callable $callback ): void {
134
    $this->slurp( $sha, $callback );
135
  }
136
137
  private function slurp( string $sha, callable $callback ): void {
138
    $loosePath = $this->getLoosePath( $sha );
139
140
    if( is_file( $loosePath ) ) {
141
      $fileHandle = @fopen( $loosePath, 'rb' );
142
143
      if( !$fileHandle ) return;
144
145
      $inflator    = inflate_init( ZLIB_ENCODING_DEFLATE );
146
      $buffer      = '';
147
      $headerFound = false;
148
149
      while( !feof( $fileHandle ) ) {
150
        $chunk         = fread( $fileHandle, 16384 );
151
        $inflatedChunk = @inflate_add( $inflator, $chunk );
152
153
        if( $inflatedChunk === false ) break;
154
155
        if( !$headerFound ) {
156
          $buffer .= $inflatedChunk;
157
          $nullPos = strpos( $buffer, "\0" );
158
159
          if( $nullPos !== false ) {
160
            $body = substr( $buffer, $nullPos + 1 );
161
162
            if( $body !== '' ) {
163
              $callback( $body );
164
            }
165
166
            $headerFound = true;
167
            $buffer      = '';
168
          }
169
        } else {
170
          $callback( $inflatedChunk );
171
        }
172
      }
173
174
      fclose( $fileHandle );
175
      return;
176
    }
177
178
    if( method_exists( $this->packs, 'stream' ) ) {
179
      $streamed = $this->packs->stream( $sha, $callback );
180
181
      if( $streamed ) {
182
        return;
183
      }
184
    }
185
186
    $data = $this->packs->read( $sha );
187
188
    if( $data !== null && $data !== '' ) {
189
      $callback( $data );
190
    }
191
  }
192
193
  private function peekLooseObject( string $sha, int $length ): string {
194
    $path = $this->getLoosePath( $sha );
195
196
    if( !is_file( $path ) ) {
197
      return '';
198
    }
199
200
    $fileHandle = @fopen( $path, 'rb' );
201
202
    if( !$fileHandle ) {
203
      return '';
204
    }
205
206
    $inflator    = inflate_init( ZLIB_ENCODING_DEFLATE );
207
    $headerFound = false;
208
    $buffer      = '';
209
210
    while( !feof( $fileHandle ) && strlen( $buffer ) < $length ) {
211
      $chunk    = fread( $fileHandle, 128 );
212
      $inflated = @inflate_add( $inflator, $chunk );
213
214
      if( !$headerFound ) {
215
        $raw     = $inflated;
216
        $nullPos = strpos( $raw, "\0" );
217
218
        if( $nullPos !== false ) {
219
          $headerFound = true;
220
          $buffer .= substr( $raw, $nullPos + 1 );
221
        }
222
      } else {
223
        $buffer .= $inflated;
224
      }
225
    }
226
227
    fclose( $fileHandle );
228
229
    return substr( $buffer, 0, $length );
230
  }
231
232
  public function history( string $ref, int $limit, callable $callback ): void {
233
    $currentSha = $this->resolve( $ref );
234
    $count      = 0;
235
236
    while( $currentSha !== '' && $count < $limit ) {
237
      $data = $this->read( $currentSha );
238
239
      if( $data === '' ) {
240
        break;
241
      }
242
243
      $position = strpos( $data, "\n\n" );
244
      $message  = $position !== false ? substr( $data, $position + 2 ) : '';
245
      preg_match( '/^author (.*) <(.*)> (\d+)/m', $data, $matches );
246
247
      $callback( (object)[
248
        'sha'     => $currentSha,
249
        'message' => trim( $message ),
250
        'author'  => $matches[1] ?? 'Unknown',
251
        'email'   => $matches[2] ?? '',
252
        'date'    => (int)( $matches[3] ?? 0 )
253
      ] );
254
255
      $currentSha = preg_match(
256
        '/^parent ([0-9a-f]{40})$/m',
257
        $data,
258
        $parentMatches
259
      ) ? $parentMatches[1] : '';
260
261
      $count++;
262
    }
263
  }
264
265
  public function walk( string $refOrSha, callable $callback ): void {
266
    $sha  = $this->resolve( $refOrSha );
267
    $data = $sha !== '' ? $this->read( $sha ) : '';
268
269
    if( preg_match( '/^tree ([0-9a-f]{40})$/m', $data, $matches ) ) {
270
      $data = $this->read( $matches[1] );
271
    }
272
273
    if( $this->isTreeData( $data ) ) {
274
      $this->processTree( $data, $callback );
275
    }
276
  }
277
278
  private function processTree( string $data, callable $callback ): void {
279
    $position = 0;
280
    $length   = strlen( $data );
281
282
    while( $position < $length ) {
283
      $spacePos = strpos( $data, ' ', $position );
284
      $nullPos  = strpos( $data, "\0", $spacePos );
285
286
      if( $spacePos === false || $nullPos === false ) {
287
        break;
288
      }
289
290
      $mode = substr( $data, $position, $spacePos - $position );
291
      $name = substr( $data, $spacePos + 1, $nullPos - $spacePos - 1 );
292
      $sha  = bin2hex( substr( $data, $nullPos + 1, 20 ) );
293
294
      $isDirectory = $mode === '40000' || $mode === '040000';
295
      $size        = $isDirectory ? 0 : $this->getObjectSize( $sha );
296
      $contents    = $isDirectory ? '' : $this->peek( $sha );
297
298
      $callback( new File( $name, $sha, $mode, 0, $size, $contents ) );
299
300
      $position = $nullPos + 21;
301
    }
302
  }
303
304
  private function isTreeData( string $data ): bool {
305
    $pattern = '/^(40000|100644|100755|120000|160000) /';
306
307
    if( strlen( $data ) >= 25 && preg_match( $pattern, $data ) ) {
308
      $nullPos = strpos( $data, "\0" );
309
310
      return $nullPos !== false && ( $nullPos + 21 <= strlen( $data ) );
311
    }
312
313
    return false;
314
  }
315
316
  private function getLoosePath( string $sha ): string {
317
    return "{$this->objectsPath}/" . substr( $sha, 0, 2 ) . "/" .
318
           substr( $sha, 2 );
319
  }
320
321
  private function getLooseObjectSize( string $sha ): int {
322
    $path = $this->getLoosePath( $sha );
323
324
    if( !is_file( $path ) ) {
325
      return 0;
326
    }
327
328
    $fileHandle = @fopen( $path, 'rb' );
329
330
    if( !$fileHandle ) {
331
      return 0;
332
    }
333
334
    $data     = '';
335
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
336
337
    while( !feof( $fileHandle ) ) {
338
      $chunk  = fread( $fileHandle, self::CHUNK_SIZE );
339
      $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH );
340
341
      if( $output === false ) {
342
        break;
343
      }
344
345
      $data .= $output;
346
347
      if( strpos( $data, "\0" ) !== false ) {
348
        break;
349
      }
350
    }
351
352
    fclose( $fileHandle );
353
354
    $header = explode( "\0", $data, 2 )[0];
355
    $parts  = explode( ' ', $header );
356
357
    return isset( $parts[1] ) ? (int)$parts[1] : 0;
358
  }
359
360
  public function streamRaw( string $subPath ): bool {
361
    if( strpos( $subPath, '..' ) !== false ) {
362
      return false;
363
    }
364
365
    $fullPath = "{$this->repoPath}/$subPath";
366
367
    if( !file_exists( $fullPath ) ) {
368
      return false;
369
    }
370
371
    $realPath = realpath( $fullPath );
372
    $repoReal = realpath( $this->repoPath );
373
374
    if( !$realPath || strpos( $realPath, $repoReal ) !== 0 ) {
375
      return false;
376
    }
377
378
    readfile( $fullPath );
379
    return true;
380
  }
381
382
  public function eachRef( callable $callback ): void {
383
    $head = $this->resolve( 'HEAD' );
384
385
    if( $head !== '' ) {
386
      $callback( 'HEAD', $head );
387
    }
388
389
    $this->refs->scanRefs( 'refs/heads', function( $name, $sha ) use ( $callback ) {
390
      $callback( "refs/heads/$name", $sha );
391
    } );
392
393
    $this->refs->scanRefs( 'refs/tags', function( $name, $sha ) use ( $callback ) {
394
      $callback( "refs/tags/$name", $sha );
395
    } );
396
  }
397
}
1 398
A git/GitDiff.php
1
<?php
2
require_once __DIR__ . '/../File.php';
3
4
class GitDiff {
5
  private Git $git;
6
  private const MAX_DIFF_SIZE = 1048576;
7
8
  public function __construct(Git $git) {
9
    $this->git = $git;
10
  }
11
12
  public function compare(string $commitHash) {
13
    $commitData = $this->git->read($commitHash);
14
    $parentHash = preg_match('/^parent ([0-9a-f]{40})/m', $commitData, $matches) ? $matches[1] : '';
15
16
    $newTree = $this->getTreeHash($commitHash);
17
    $oldTree = $parentHash ? $this->getTreeHash($parentHash) : null;
18
19
    return $this->diffTrees($oldTree, $newTree);
20
  }
21
22
  private function getTreeHash($commitSha) {
23
    $data = $this->git->read($commitSha);
24
    return preg_match('/^tree ([0-9a-f]{40})/m', $data, $matches) ? $matches[1] : null;
25
  }
26
27
  private function diffTrees($oldTreeSha, $newTreeSha, $path = '') {
28
    $changes = [];
29
30
    if ($oldTreeSha !== $newTreeSha) {
31
      $oldEntries = $oldTreeSha ? $this->parseTree($oldTreeSha) : [];
32
      $newEntries = $newTreeSha ? $this->parseTree($newTreeSha) : [];
33
34
      $allNames = array_unique(array_merge(array_keys($oldEntries), array_keys($newEntries)));
35
      sort($allNames);
36
37
      foreach ($allNames as $name) {
38
        $old         = $oldEntries[$name] ?? null;
39
        $new         = $newEntries[$name] ?? null;
40
        $currentPath = $path ? "$path/$name" : $name;
41
42
        if (!$old) {
43
          $changes = $new['is_dir']
44
            ? array_merge($changes, $this->diffTrees(null, $new['sha'], $currentPath))
45
            : array_merge($changes, [$this->createChange('A', $currentPath, null, $new['sha'])]);
46
        } elseif (!$new) {
47
          $changes = $old['is_dir']
48
            ? array_merge($changes, $this->diffTrees($old['sha'], null, $currentPath))
49
            : array_merge($changes, [$this->createChange('D', $currentPath, $old['sha'], null)]);
50
        } elseif ($old['sha'] !== $new['sha']) {
51
          $changes = ($old['is_dir'] && $new['is_dir'])
52
            ? array_merge($changes, $this->diffTrees($old['sha'], $new['sha'], $currentPath))
53
            : (($old['is_dir'] || $new['is_dir'])
54
              ? $changes
55
              : array_merge($changes, [$this->createChange('M', $currentPath, $old['sha'], $new['sha'])]));
56
        }
57
      }
58
    }
59
60
    return $changes;
61
  }
62
63
  private function parseTree($sha) {
64
    $data    = $this->git->read($sha);
65
    $entries = [];
66
    $len     = strlen($data);
67
    $pos     = 0;
68
69
    while ($pos < $len) {
70
      $space = strpos($data, ' ', $pos);
71
      $null  = strpos($data, "\0", $space);
72
73
      if ($space === false || $null === false) break;
74
75
      $mode = substr($data, $pos, $space - $pos);
76
      $name = substr($data, $space + 1, $null - $space - 1);
77
      $hash = bin2hex(substr($data, $null + 1, 20));
78
79
      $entries[$name] = [
80
        'mode'   => $mode,
81
        'sha'    => $hash,
82
        'is_dir' => $mode === '40000' || $mode === '040000'
83
      ];
84
85
      $pos = $null + 21;
86
    }
87
    return $entries;
88
  }
89
90
  private function createChange($type, $path, $oldSha, $newSha) {
91
    $oldSize = $oldSha ? $this->git->getObjectSize($oldSha) : 0;
92
    $newSize = $newSha ? $this->git->getObjectSize($newSha) : 0;
93
    $result  = [];
94
95
    if ($oldSize > self::MAX_DIFF_SIZE || $newSize > self::MAX_DIFF_SIZE) {
96
      $result = [
97
        'type'      => $type,
98
        'path'      => $path,
99
        'is_binary' => true,
100
        'hunks'     => []
101
      ];
102
    } else {
103
      $oldContent = $oldSha ? $this->git->read($oldSha) : '';
104
      $newContent = $newSha ? $this->git->read($newSha) : '';
105
106
      $isBinary = ($newSha && (new VirtualDiffFile($path, $newContent))->isBinary()) ||
107
                  (!$newSha && $oldSha && (new VirtualDiffFile($path, $oldContent))->isBinary());
108
109
      $result = [
110
        'type'      => $type,
111
        'path'      => $path,
112
        'is_binary' => $isBinary,
113
        'hunks'     => $isBinary ? null : $this->calculateDiff($oldContent, $newContent)
114
      ];
115
    }
116
117
    return $result;
118
  }
119
120
  private function calculateDiff($old, $new) {
121
    $old = str_replace("\r\n", "\n", $old);
122
    $new = str_replace("\r\n", "\n", $new);
123
124
    $oldLines = explode("\n", $old);
125
    $newLines = explode("\n", $new);
126
127
    $m = count($oldLines);
128
    $n = count($newLines);
129
130
    $start = 0;
131
    while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) {
132
      $start++;
133
    }
134
135
    $end = 0;
136
    while ($m - $end > $start && $n - $end > $start && $oldLines[$m - 1 - $end] === $newLines[$n - 1 - $end]) {
137
      $end++;
138
    }
139
140
    $oldSlice = array_slice($oldLines, $start, $m - $start - $end);
141
    $newSlice = array_slice($newLines, $start, $n - $start - $end);
142
143
    $result = null;
144
145
    if ((count($oldSlice) * count($newSlice)) > 500000) {
146
      $result = [['t' => 'gap']];
147
    } else {
148
      $ops = $this->computeLCS($oldSlice, $newSlice);
149
150
      $groupedOps = [];
151
      $bufferDel  = [];
152
      $bufferAdd  = [];
153
154
      foreach ($ops as $op) {
155
        if ($op['t'] === ' ') {
156
          foreach ($bufferDel as $o) $groupedOps[] = $o;
157
          foreach ($bufferAdd as $o) $groupedOps[] = $o;
158
          $bufferDel    = [];
159
          $bufferAdd    = [];
160
          $groupedOps[] = $op;
161
        } elseif ($op['t'] === '-') {
162
          $bufferDel[] = $op;
163
        } elseif ($op['t'] === '+') {
164
          $bufferAdd[] = $op;
165
        }
166
      }
167
      foreach ($bufferDel as $o) $groupedOps[] = $o;
168
      foreach ($bufferAdd as $o) $groupedOps[] = $o;
169
      $ops = $groupedOps;
170
171
      $stream = [];
172
173
      for ($i = 0; $i < $start; $i++) {
174
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $i + 1, 'nn' => $i + 1];
175
      }
176
177
      $currO = $start + 1;
178
      $currN = $start + 1;
179
180
      foreach ($ops as $op) {
181
        if ($op['t'] === ' ') {
182
          $stream[] = ['t' => ' ', 'l' => $op['l'], 'no' => $currO++, 'nn' => $currN++];
183
        } elseif ($op['t'] === '-') {
184
          $stream[] = ['t' => '-', 'l' => $op['l'], 'no' => $currO++, 'nn' => null];
185
        } elseif ($op['t'] === '+') {
186
          $stream[] = ['t' => '+', 'l' => $op['l'], 'no' => null, 'nn' => $currN++];
187
        }
188
      }
189
190
      for ($i = $m - $end; $i < $m; $i++) {
191
        $stream[] = ['t' => ' ', 'l' => $oldLines[$i], 'no' => $currO++, 'nn' => $currN++];
192
      }
193
194
      $finalLines       = [];
195
      $lastVisibleIndex = -1;
196
      $streamLen        = count($stream);
197
      $contextLines     = 3;
198
199
      for ($i = 0; $i < $streamLen; $i++) {
200
        $show = false;
201
202
        if ($stream[$i]['t'] !== ' ') {
203
          $show = true;
204
        } else {
205
          for ($j = 1; $j <= $contextLines; $j++) {
206
            if (($i + $j) < $streamLen && $stream[$i + $j]['t'] !== ' ') {
207
              $show = true;
208
              break;
209
            }
210
          }
211
          if (!$show) {
212
            for ($j = 1; $j <= $contextLines; $j++) {
213
              if (($i - $j) >= 0 && $stream[$i - $j]['t'] !== ' ') {
214
                $show = true;
215
                break;
216
              }
217
            }
218
          }
219
        }
220
221
        if ($show) {
222
          if ($lastVisibleIndex !== -1 && $i > $lastVisibleIndex + 1) {
223
            $finalLines[] = ['t' => 'gap'];
224
          }
225
          $finalLines[]     = $stream[$i];
226
          $lastVisibleIndex = $i;
227
        }
228
      }
229
      $result = $finalLines;
230
    }
231
232
    return $result;
233
  }
234
235
  private function computeLCS($old, $new) {
236
    $m = count($old);
237
    $n = count($new);
238
    $c = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
239
240
    for ($i = 1; $i <= $m; $i++) {
241
      for ($j = 1; $j <= $n; $j++) {
242
        $c[$i][$j] = ($old[$i - 1] === $new[$j - 1])
243
          ? $c[$i - 1][$j - 1] + 1
244
          : max($c[$i][$j - 1], $c[$i - 1][$j]);
245
      }
246
    }
247
248
    $diff = [];
249
    $i    = $m;
250
    $j    = $n;
251
252
    while ($i > 0 || $j > 0) {
253
      if ($i > 0 && $j > 0 && $old[$i - 1] === $new[$j - 1]) {
254
        array_unshift($diff, ['t' => ' ', 'l' => $old[$i - 1]]);
255
        $i--;
256
        $j--;
257
      } elseif ($j > 0 && ($i === 0 || $c[$i][$j - 1] >= $c[$i - 1][$j])) {
258
        array_unshift($diff, ['t' => '+', 'l' => $new[$j - 1]]);
259
        $j--;
260
      } elseif ($i > 0 && ($j === 0 || $c[$i][$j - 1] < $c[$i - 1][$j])) {
261
        array_unshift($diff, ['t' => '-', 'l' => $old[$i - 1]]);
262
        $i--;
263
      }
264
    }
265
    return $diff;
266
  }
267
}
268
269
class VirtualDiffFile extends File {
270
  public function __construct(string $name, string $content) {
271
    parent::__construct($name, '', '100644', 0, strlen($content), $content);
272
  }
273
}
1 274
A git/GitPacks.php
1
<?php
2
class GitPacks {
3
  private const MAX_READ = 1040576;
4
  private const MAX_RAM = 1048576;
5
6
  private string $objectsPath;
7
  private array $packFiles;
8
  private ?string $lastPack = null;
9
10
  private array $fileHandles       = [];
11
  private array $fanoutCache       = [];
12
  private array $shaBucketCache    = [];
13
  private array $offsetBucketCache = [];
14
15
  public function __construct( string $objectsPath ) {
16
    $this->objectsPath = $objectsPath;
17
    $this->packFiles   = glob( "{$this->objectsPath}/pack/*.idx" ) ?: [];
18
  }
19
20
  public function __destruct() {
21
    foreach( $this->fileHandles as $handle ) {
22
      if( is_resource( $handle ) ) {
23
        fclose( $handle );
24
      }
25
    }
26
  }
27
28
  public function peek( string $sha, int $len = 12 ): ?string {
29
    $info = $this->findPackInfo( $sha );
30
31
    if( $info['offset'] === -1 ) {
32
      return null;
33
    }
34
35
    $handle = $this->getHandle( $info['file'] );
36
37
    if( !$handle ) {
38
      return null;
39
    }
40
41
    return $this->readPackEntry( $handle, $info['offset'], $len, $len );
42
  }
43
44
  public function read( string $sha ): ?string {
45
    $info = $this->findPackInfo( $sha );
46
47
    if( $info['offset'] === -1 ) {
48
      return null;
49
    }
50
51
    $size = $this->extractPackedSize( $info['file'], $info['offset'] );
52
53
    if( $size > self::MAX_RAM ) {
54
      return null;
55
    }
56
57
    $handle = $this->getHandle( $info['file'] );
58
59
    return $handle
60
      ? $this->readPackEntry( $handle, $info['offset'], $size )
61
      : null;
62
  }
63
64
  public function stream( string $sha, callable $callback ): bool {
65
    $info = $this->findPackInfo( $sha );
66
67
    if( $info['offset'] === -1 ) {
68
      return false;
69
    }
70
71
    $size   = $this->extractPackedSize( $info['file'], $info['offset'] );
72
    $handle = $this->getHandle( $info['file'] );
73
74
    if( !$handle ) {
75
      return false;
76
    }
77
78
    return $this->streamPackEntry( $handle, $info['offset'], $size, $callback );
79
  }
80
81
  public function getSize( string $sha ): ?int {
82
    $info = $this->findPackInfo( $sha );
83
84
    if( $info['offset'] === -1 ) {
85
      return null;
86
    }
87
88
    return $this->extractPackedSize( $info['file'], $info['offset'] );
89
  }
90
91
  private function findPackInfo( string $sha ): array {
92
    if( !ctype_xdigit( $sha ) || strlen( $sha ) !== 40 ) {
93
      return ['offset' => -1];
94
    }
95
96
    $binarySha = hex2bin( $sha );
97
98
    if( $this->lastPack ) {
99
      $offset = $this->findInIdx( $this->lastPack, $binarySha );
100
101
      if( $offset !== -1 ) {
102
        return $this->makeResult( $this->lastPack, $offset );
103
      }
104
    }
105
106
    foreach( $this->packFiles as $indexFile ) {
107
      if( $indexFile === $this->lastPack ) {
108
        continue;
109
      }
110
111
      $offset = $this->findInIdx( $indexFile, $binarySha );
112
113
      if( $offset !== -1 ) {
114
        $this->lastPack = $indexFile;
115
116
        return $this->makeResult( $indexFile, $offset );
117
      }
118
    }
119
120
    return ['offset' => -1];
121
  }
122
123
  private function makeResult( string $indexPath, int $offset ): array {
124
    return [
125
      'file'   => str_replace( '.idx', '.pack', $indexPath ),
126
      'offset' => $offset
127
    ];
128
  }
129
130
  private function findInIdx( string $indexFile, string $binarySha ): int {
131
    $fileHandle = $this->getHandle( $indexFile );
132
133
    if( !$fileHandle ) {
134
      return -1;
135
    }
136
137
    if( !isset( $this->fanoutCache[$indexFile] ) ) {
138
      fseek( $fileHandle, 0 );
139
140
      if( fread( $fileHandle, 8 ) === "\377tOc\0\0\0\2" ) {
141
        $this->fanoutCache[$indexFile] = array_values(
142
          unpack( 'N*', fread( $fileHandle, 1024 ) )
143
        );
144
      } else {
145
        return -1;
146
      }
147
    }
148
149
    $fanout = $this->fanoutCache[$indexFile];
150
151
    $firstByte = ord( $binarySha[0] );
152
    $start     = $firstByte === 0 ? 0 : $fanout[$firstByte - 1];
153
    $end       = $fanout[$firstByte];
154
155
    if( $end <= $start ) {
156
      return -1;
157
    }
158
159
    $cacheKey = "$indexFile:$firstByte";
160
161
    if( !isset( $this->shaBucketCache[$cacheKey] ) ) {
162
      $count = $end - $start;
163
      fseek( $fileHandle, 1032 + ($start * 20) );
164
      $this->shaBucketCache[$cacheKey] = fread( $fileHandle, $count * 20 );
165
166
      fseek(
167
        $fileHandle,
168
        1032 + ($fanout[255] * 24) + ($start * 4)
169
      );
170
      $this->offsetBucketCache[$cacheKey] = fread( $fileHandle, $count * 4 );
171
    }
172
173
    $shaBlock = $this->shaBucketCache[$cacheKey];
174
    $count    = strlen( $shaBlock ) / 20;
175
    $low      = 0;
176
    $high     = $count - 1;
177
    $foundIdx = -1;
178
179
    while( $low <= $high ) {
180
      $mid     = ($low + $high) >> 1;
181
      $compare = substr( $shaBlock, $mid * 20, 20 );
182
183
      if( $compare < $binarySha ) {
184
        $low = $mid + 1;
185
      } elseif( $compare > $binarySha ) {
186
        $high = $mid - 1;
187
      } else {
188
        $foundIdx = $mid;
189
        break;
190
      }
191
    }
192
193
    if( $foundIdx === -1 ) {
194
      return -1;
195
    }
196
197
    $offsetData = substr(
198
      $this->offsetBucketCache[$cacheKey],
199
      $foundIdx * 4,
200
      4
201
    );
202
    $offset = unpack( 'N', $offsetData )[1];
203
204
    if( $offset & 0x80000000 ) {
205
      $packTotal = $fanout[255];
206
      $pos64     = 1032 + ($packTotal * 28) +
207
                   (($offset & 0x7FFFFFFF) * 8);
208
      fseek( $fileHandle, $pos64 );
209
      $offset = unpack( 'J', fread( $fileHandle, 8 ) )[1];
210
    }
211
212
    return (int)$offset;
213
  }
214
215
  private function readPackEntry( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string {
216
    fseek( $fileHandle, $offset );
217
218
    $header = $this->readVarInt( $fileHandle );
219
    $type   = ($header['byte'] >> 4) & 7;
220
221
    if( $type === 6 ) {
222
      return $this->handleOfsDelta( $fileHandle, $offset, $expectedSize, $cap );
223
    }
224
225
    if( $type === 7 ) {
226
      return $this->handleRefDelta( $fileHandle, $expectedSize, $cap );
227
    }
228
229
    return $this->decompressToString( $fileHandle, $expectedSize, $cap );
230
  }
231
232
  private function streamPackEntry( $fileHandle, int $offset, int $expectedSize, callable $callback ): bool {
233
    fseek( $fileHandle, $offset );
234
235
    $header = $this->readVarInt( $fileHandle );
236
    $type   = ($header['byte'] >> 4) & 7;
237
238
    if( $type === 6 || $type === 7 ) {
239
      return $this->streamDeltaObject( $fileHandle, $offset, $type, $expectedSize, $callback );
240
    }
241
242
    return $this->streamDecompression( $fileHandle, $callback );
243
  }
244
245
  private function streamDeltaObject( $fileHandle, int $offset, int $type, int $expectedSize, callable $callback ): bool {
246
    fseek( $fileHandle, $offset );
247
    $this->readVarInt( $fileHandle );
248
249
    if( $type === 6 ) {
250
      $byte     = ord( fread( $fileHandle, 1 ) );
251
      $negative = $byte & 127;
252
253
      while( $byte & 128 ) {
254
        $byte     = ord( fread( $fileHandle, 1 ) );
255
        $negative = (($negative + 1) << 7) | ($byte & 127);
256
      }
257
258
      $deltaPos   = ftell( $fileHandle );
259
      $baseOffset = $offset - $negative;
260
261
      $base = '';
262
      $this->streamPackEntry( $fileHandle, $baseOffset, 0, function( $chunk ) use ( &$base ) {
263
        $base .= $chunk;
264
      } );
265
266
      fseek( $fileHandle, $deltaPos );
267
    } else {
268
      $baseSha = bin2hex( fread( $fileHandle, 20 ) );
269
270
      $base = '';
271
      $streamed = $this->stream( $baseSha, function( $chunk ) use ( &$base ) {
272
        $base .= $chunk;
273
      } );
274
275
      if( !$streamed ) {
276
        return false;
277
      }
278
    }
279
280
    $compressed = fread( $fileHandle, self::MAX_READ );
281
    $delta      = @gzuncompress( $compressed ) ?: '';
282
283
    $result = $this->applyDelta( $base, $delta );
284
285
    $chunkSize = 8192;
286
    $length    = strlen( $result );
287
    for( $i = 0; $i < $length; $i += $chunkSize ) {
288
      $callback( substr( $result, $i, $chunkSize ) );
289
    }
290
291
    return true;
292
  }
293
294
  private function streamDecompression( $fileHandle, callable $callback ): bool {
295
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
296
    if( $inflator === false ) {
297
      return false;
298
    }
299
300
    while( !feof( $fileHandle ) ) {
301
      $chunk = fread( $fileHandle, 8192 );
302
303
      if( $chunk === false || $chunk === '' ) {
304
        break;
305
      }
306
307
      $data = @inflate_add( $inflator, $chunk );
308
309
      if( $data !== false && $data !== '' ) {
310
        $callback( $data );
311
      }
312
313
      if( $data === false ||
314
          inflate_get_status( $inflator ) === ZLIB_STREAM_END ) {
315
        break;
316
      }
317
    }
318
319
    return true;
320
  }
321
322
  private function decompressToString( $fileHandle, int $maxSize, int $cap = 0 ): string {
323
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
324
    if( $inflator === false ) {
325
      return '';
326
    }
327
328
    $result = '';
329
330
    while( !feof( $fileHandle ) ) {
331
      $chunk = fread( $fileHandle, 8192 );
332
333
      if( $chunk === false || $chunk === '' ) {
334
        break;
335
      }
336
337
      $data = @inflate_add( $inflator, $chunk );
338
339
      if( $data !== false ) {
340
        $result .= $data;
341
      }
342
343
      if( $cap > 0 && strlen( $result ) >= $cap ) {
344
        return substr( $result, 0, $cap );
345
      }
346
347
      if( $data === false ||
348
          inflate_get_status( $inflator ) === ZLIB_STREAM_END ) {
349
        break;
350
      }
351
    }
352
353
    return $result;
354
  }
355
356
  private function extractPackedSize( string $packPath, int $offset ): int {
357
    $fileHandle = $this->getHandle( $packPath );
358
359
    if( !$fileHandle ) {
360
      return 0;
361
    }
362
363
    fseek( $fileHandle, $offset );
364
365
    $header = $this->readVarInt( $fileHandle );
366
    $size   = $header['value'];
367
    $type   = ($header['byte'] >> 4) & 7;
368
369
    if( $type === 6 || $type === 7 ) {
370
      return $this->readDeltaTargetSize( $fileHandle, $type );
371
    }
372
373
    return $size;
374
  }
375
376
  private function handleOfsDelta( $fileHandle, int $offset, int $expectedSize, int $cap = 0 ): string {
377
    $byte     = ord( fread( $fileHandle, 1 ) );
378
    $negative = $byte & 127;
379
380
    while( $byte & 128 ) {
381
      $byte     = ord( fread( $fileHandle, 1 ) );
382
      $negative = (($negative + 1) << 7) | ($byte & 127);
383
    }
384
385
    $currentPos = ftell( $fileHandle );
386
    $baseOffset = $offset - $negative;
387
388
    fseek( $fileHandle, $baseOffset );
389
    $baseHeader = $this->readVarInt( $fileHandle );
390
    $baseSize   = $baseHeader['value'];
391
392
    fseek( $fileHandle, $baseOffset );
393
    $base = $this->readPackEntry( $fileHandle, $baseOffset, $baseSize, $cap );
394
395
    fseek( $fileHandle, $currentPos );
396
397
    $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) );
398
    $compressed     = fread( $fileHandle, $remainingBytes );
399
    $delta          = @gzuncompress( $compressed ) ?: '';
400
401
    return $this->applyDelta( $base, $delta, $cap );
402
  }
403
404
  private function handleRefDelta( $fileHandle, int $expectedSize, int $cap = 0 ): string {
405
    $baseSha = bin2hex( fread( $fileHandle, 20 ) );
406
407
    if ( $cap > 0 ) {
408
      $base = $this->peek( $baseSha, $cap ) ?? '';
409
    } else {
410
      $base = $this->read( $baseSha ) ?? '';
411
    }
412
413
    $remainingBytes = min( self::MAX_READ, max( $expectedSize * 2, 1048576 ) );
414
    $compressed     = fread( $fileHandle, $remainingBytes );
415
    $delta          = @gzuncompress( $compressed ) ?: '';
416
417
    return $this->applyDelta( $base, $delta, $cap );
418
  }
419
420
  private function applyDelta( string $base, string $delta, int $cap = 0 ): string {
421
    $position = 0;
422
    $this->skipSize( $delta, $position );
423
    $this->skipSize( $delta, $position );
424
425
    $output      = '';
426
    $deltaLength = strlen( $delta );
427
428
    while( $position < $deltaLength ) {
429
      if( $cap > 0 && strlen( $output ) >= $cap ) {
430
        break;
431
      }
432
433
      $opcode = ord( $delta[$position++] );
434
435
      if( $opcode & 128 ) {
436
        $offset = 0;
437
        $length = 0;
438
439
        if( $opcode & 0x01 ) {
440
          $offset |= ord( $delta[$position++] );
441
        }
442
        if( $opcode & 0x02 ) {
443
          $offset |= ord( $delta[$position++] ) << 8;
444
        }
445
        if( $opcode & 0x04 ) {
446
          $offset |= ord( $delta[$position++] ) << 16;
447
        }
448
        if( $opcode & 0x08 ) {
449
          $offset |= ord( $delta[$position++] ) << 24;
450
        }
451
452
        if( $opcode & 0x10 ) {
453
          $length |= ord( $delta[$position++] );
454
        }
455
        if( $opcode & 0x20 ) {
456
          $length |= ord( $delta[$position++] ) << 8;
457
        }
458
        if( $opcode & 0x40 ) {
459
          $length |= ord( $delta[$position++] ) << 16;
460
        }
461
462
        if( $length === 0 ) {
463
          $length = 0x10000;
464
        }
465
466
        $output .= substr( $base, $offset, $length );
467
      } else {
468
        $length = $opcode & 127;
469
        $output .= substr( $delta, $position, $length );
470
        $position += $length;
471
      }
472
    }
473
474
    return $output;
475
  }
476
477
  private function readVarInt( $fileHandle ): array {
478
    $byte  = ord( fread( $fileHandle, 1 ) );
479
    $value = $byte & 15;
480
    $shift = 4;
481
    $first = $byte;
482
483
    while( $byte & 128 ) {
484
      $byte  = ord( fread( $fileHandle, 1 ) );
485
      $value |= (($byte & 127) << $shift);
486
      $shift += 7;
487
    }
488
489
    return ['value' => $value, 'byte' => $first];
490
  }
491
492
  private function readDeltaTargetSize( $fileHandle, int $type ): int {
493
    if( $type === 6 ) {
494
      $byte = ord( fread( $fileHandle, 1 ) );
495
496
      while( $byte & 128 ) {
497
        $byte = ord( fread( $fileHandle, 1 ) );
498
      }
499
    } else {
500
      fseek( $fileHandle, 20, SEEK_CUR );
501
    }
502
503
    $inflator = inflate_init( ZLIB_ENCODING_DEFLATE );
504
    if( $inflator === false ) {
505
      return 0;
506
    }
507
508
    $header      = '';
509
    $attempts    = 0;
510
    $maxAttempts = 64;
511
512
    while( !feof( $fileHandle ) && strlen( $header ) < 32 && $attempts < $maxAttempts ) {
513
      $chunk = fread( $fileHandle, 512 );
514
515
      if( $chunk === false || $chunk === '' ) {
516
        break;
517
      }
518
519
      $output = @inflate_add( $inflator, $chunk, ZLIB_NO_FLUSH );
520
521
      if( $output !== false ) {
522
        $header .= $output;
523
      }
524
525
      if( inflate_get_status( $inflator ) === ZLIB_STREAM_END ) {
526
        break;
527
      }
528
529
      $attempts++;
530
    }
531
532
    $position = 0;
533
534
    if( strlen( $header ) > 0 ) {
535
      $this->skipSize( $header, $position );
536
537
      return $this->readSize( $header, $position );
538
    }
539
540
    return 0;
541
  }
542
543
  private function skipSize( string $data, int &$position ): void {
544
    $length = strlen( $data );
545
546
    while( $position < $length && (ord( $data[$position++] ) & 128) ) {
547
    }
548
  }
549
550
  private function readSize( string $data, int &$position ): int {
551
    $byte  = ord( $data[$position++] );
552
    $value = $byte & 127;
553
    $shift = 7;
554
555
    while( $byte & 128 ) {
556
      $byte  = ord( $data[$position++] );
557
      $value |= (($byte & 127) << $shift);
558
      $shift += 7;
559
    }
560
561
    return $value;
562
  }
563
564
  private function getHandle( string $path ) {
565
    if( !isset( $this->fileHandles[$path] ) ) {
566
      $this->fileHandles[$path] = @fopen( $path, 'rb' );
567
    }
568
569
    return $this->fileHandles[$path];
570
  }
571
}
1 572
A git/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 index.php
1 1
<?php
2
require_once 'Config.php';
3
require_once 'Page.php';
4
require_once 'Router.php';
2
require_once __DIR__ . '/Config.php';
3
require_once __DIR__ . '/Router.php';
5 4
6 5
Config::init();
A pages/BasePage.php
1
<?php
2
require_once __DIR__ . '/Page.php';
3
require_once __DIR__ . '/../File.php';
4
5
abstract class BasePage implements Page {
6
  protected $repositories;
7
  protected $title;
8
9
  public function __construct(array $repositories) {
10
    $this->repositories = $repositories;
11
  }
12
13
  protected function renderLayout($contentCallback, $currentRepo = null) {
14
    ?>
15
    <!DOCTYPE html>
16
    <html lang="en">
17
    <head>
18
      <meta charset="UTF-8">
19
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
20
      <title><?php echo Config::SITE_TITLE . ($this->title ? ' - ' . htmlspecialchars($this->title) : ''); ?></title>
21
      <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css">
22
      <link rel="stylesheet" href="repo.css">
23
    </head>
24
    <body>
25
    <div class="container">
26
      <header>
27
        <h1><?php echo Config::SITE_TITLE; ?></h1>
28
        <nav class="nav">
29
          <a href="?">Home</a>
30
          <?php if ($currentRepo):
31
            $safeName = urlencode($currentRepo['safe_name']); ?>
32
            <a href="?repo=<?php echo $safeName; ?>">Files</a>
33
            <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a>
34
            <a href="?action=refs&repo=<?php echo $safeName; ?>">Branches</a>
35
            <a href="?action=tags&repo=<?php echo $safeName; ?>">Tags</a>
36
          <?php endif; ?>
37
38
          <?php if ($currentRepo): ?>
39
          <div class="repo-selector">
40
            <label>Repository:</label>
41
            <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)">
42
              <?php foreach ($this->repositories as $r): ?>
43
              <option value="<?php echo htmlspecialchars($r['safe_name']); ?>"
44
                <?php echo $r['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>>
45
                <?php echo htmlspecialchars($r['name']); ?>
46
              </option>
47
              <?php endforeach; ?>
48
            </select>
49
          </div>
50
          <?php endif; ?>
51
        </nav>
52
53
        <?php if ($currentRepo): ?>
54
          <div class="repo-info-banner">
55
            <span class="current-repo">Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong></span>
56
          </div>
57
        <?php endif; ?>
58
      </header>
59
60
      <?php call_user_func($contentCallback); ?>
61
62
    </div>
63
    </body>
64
    </html>
65
    <?php
66
  }
67
}
1 68
A pages/ClonePage.php
1
<?php
2
require_once __DIR__ . '/Page.php';
3
4
class ClonePage implements Page {
5
  private $git;
6
  private $subPath;
7
8
  public function __construct( Git $git, string $subPath ) {
9
    $this->git     = $git;
10
    $this->subPath = $subPath;
11
  }
12
13
  public function render() {
14
    if( $this->subPath === 'info/refs' ) {
15
      $this->renderInfoRefs();
16
      return;
17
    }
18
19
    if( $this->subPath === 'HEAD' ) {
20
      $this->serve( 'HEAD', 'text/plain' );
21
      return;
22
    }
23
24
    if( strpos( $this->subPath, 'objects/' ) === 0 ) {
25
      $this->serve( $this->subPath, 'application/x-git-object' );
26
      return;
27
    }
28
29
    $this->serve( $this->subPath, 'text/plain' );
30
  }
31
32
  private function renderInfoRefs(): void {
33
    header( 'Content-Type: text/plain' );
34
35
    if( $this->git->streamRaw( 'info/refs' ) ) {
36
      exit;
37
    }
38
39
    $this->git->eachRef( function( $ref, $sha ) {
40
      echo "$sha\t$ref\n";
41
    } );
42
43
    exit;
44
  }
45
46
  private function serve( string $path, string $contentType ): void {
47
    header( 'Content-Type: ' . $contentType );
48
49
    $success = $this->git->streamRaw( $path );
50
51
    if( !$success ) {
52
      http_response_code( 404 );
53
      echo "File not found: $path";
54
    }
55
56
    exit;
57
  }
58
}
1 59
A pages/CommitsPage.php
1
<?php
2
require_once __DIR__ . '/BasePage.php';
3
4
class CommitsPage 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 = $currentRepo['name'];
15
  }
16
17
  public function render() {
18
    $this->renderLayout(function() {
19
      // Use local private $git
20
      $main = $this->git->getMainBranch();
21
22
      if (!$main) {
23
        echo '<div class="empty-state"><h3>No branches</h3><p>Empty repository.</p></div>';
24
        return;
25
      }
26
27
      $this->renderBreadcrumbs();
28
      echo '<h2>Commit History <span class="branch-badge">' . htmlspecialchars($main['name']) . '</span></h2>';
29
      echo '<div class="commit-list">';
30
31
      $start = $this->hash ?: $main['hash'];
32
      $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']);
33
34
      $this->git->history($start, 100, function($commit) use ($repoParam) {
35
        $msg = htmlspecialchars(explode("\n", $commit->message)[0]);
36
        echo '<div class="commit-row">';
37
        echo '<a href="?action=commit&hash=' . $commit->sha . $repoParam . '" class="sha">' . substr($commit->sha, 0, 7) . '</a>';
38
        echo '<span class="message">' . $msg . '</span>';
39
        echo '<span class="meta">' . htmlspecialchars($commit->author) . ' &bull; ' . date('Y-m-d', $commit->date) . '</span>';
40
        echo '</div>';
41
      });
42
      echo '</div>';
43
    }, $this->currentRepo);
44
  }
45
46
  private function renderBreadcrumbs() {
47
    $repoUrl = '?repo=' . urlencode( $this->currentRepo['safe_name'] );
48
49
    $crumbs = [
50
      '<a href="?">Repositories</a>',
51
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
52
      'Commits'
53
    ];
54
55
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
56
  }
57
}
1 58
A pages/DiffPage.php
1
<?php
2
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../git/GitDiff.php';
4
5
class DiffPage extends BasePage {
6
  private $currentRepo;
7
  private $git;
8
  private $hash;
9
10
  public function __construct(array $repositories, array $currentRepo, Git $git, string $hash) {
11
    parent::__construct($repositories);
12
    $this->currentRepo = $currentRepo;
13
    $this->git = $git;
14
    $this->hash = $hash;
15
    $this->title = substr($hash, 0, 7);
16
  }
17
18
  public function render() {
19
    $this->renderLayout(function() {
20
      $commitData = $this->git->read($this->hash);
21
      $diffEngine = new GitDiff($this->git);
22
23
      $lines = explode("\n", $commitData);
24
      $msg = '';
25
      $isMsg = false;
26
      $headers = [];
27
      foreach ($lines as $line) {
28
        if ($line === '') { $isMsg = true; continue; }
29
        if ($isMsg) { $msg .= $line . "\n"; }
30
        else {
31
            if (preg_match('/^(\w+) (.*)$/', $line, $m)) $headers[$m[1]] = $m[2];
32
        }
33
      }
34
35
      $changes = $diffEngine->compare($this->hash);
36
37
      $this->renderBreadcrumbs();
38
39
      // Fix 1: Redact email address
40
      $author = $headers['author'] ?? 'Unknown';
41
      $author = preg_replace('/<[^>]+>/', '<email>', $author);
42
43
      echo '<div class="commit-details">';
44
      echo '<div class="commit-header">';
45
      echo '<h1 class="commit-title">' . htmlspecialchars(trim($msg)) . '</h1>';
46
      echo '<div class="commit-info">';
47
      echo '<div class="commit-info-row"><span class="commit-info-label">Author</span><span class="commit-author">' . htmlspecialchars($author) . '</span></div>';
48
      echo '<div class="commit-info-row"><span class="commit-info-label">Commit</span><span class="commit-info-value">' . $this->hash . '</span></div>';
49
50
      if (isset($headers['parent'])) {
51
          // Fix 2: Use '&' instead of '?' because parameters (action & hash) already exist
52
          $repoUrl = '&repo=' . urlencode($this->currentRepo['safe_name']);
53
          echo '<div class="commit-info-row"><span class="commit-info-label">Parent</span><span class="commit-info-value">';
54
          echo '<a href="?action=commit&hash=' . $headers['parent'] . $repoUrl . '" class="parent-link">' . substr($headers['parent'], 0, 7) . '</a>';
55
          echo '</span></div>';
56
      }
57
      echo '</div></div></div>';
58
59
      echo '<div class="diff-container">';
60
      foreach ($changes as $change) {
61
        $this->renderFileDiff($change);
62
      }
63
      if (empty($changes)) {
64
          echo '<div class="empty-state"><p>No changes detected.</p></div>';
65
      }
66
      echo '</div>';
67
68
    }, $this->currentRepo);
69
  }
70
71
  private function renderFileDiff($change) {
72
    $statusIcon = 'fa-file';
73
    $statusClass = '';
74
75
    if ($change['type'] === 'A') { $statusIcon = 'fa-plus-circle'; $statusClass = 'status-add'; }
76
    if ($change['type'] === 'D') { $statusIcon = 'fa-minus-circle'; $statusClass = 'status-del'; }
77
    if ($change['type'] === 'M') { $statusIcon = 'fa-pencil-alt'; $statusClass = 'status-mod'; }
78
79
    echo '<div class="diff-file">';
80
    echo '<div class="diff-header">';
81
    echo '<span class="diff-status ' . $statusClass . '"><i class="fa ' . $statusIcon . '"></i></span>';
82
    echo '<span class="diff-path">' . htmlspecialchars($change['path']) . '</span>';
83
    echo '</div>';
84
85
    if ($change['is_binary']) {
86
        echo '<div class="diff-binary">Binary files differ</div>';
87
    } else {
88
      echo '<div class="diff-content">';
89
      echo '<table><tbody>';
90
91
      foreach ($change['hunks'] as $line) {
92
          if (isset($line['t']) && $line['t'] === 'gap') {
93
              echo '<tr class="diff-gap"><td colspan="3">...</td></tr>';
94
              continue;
95
          }
96
97
          $class = 'diff-ctx';
98
          $char = ' ';
99
          if ($line['t'] === '+') { $class = 'diff-add'; $char = '+'; }
100
          if ($line['t'] === '-') { $class = 'diff-del'; $char = '-'; }
101
102
          echo '<tr class="' . $class . '">';
103
          echo '<td class="diff-num" data-num="' . $line['no'] . '"></td>';
104
          echo '<td class="diff-num" data-num="' . $line['nn'] . '"></td>';
105
          echo '<td class="diff-code"><span class="diff-marker">' . $char . '</span>' . htmlspecialchars($line['l']) . '</td>';
106
          echo '</tr>';
107
      }
108
109
      echo '</tbody></table>';
110
      echo '</div>';
111
    }
112
    echo '</div>';
113
  }
114
115
  private function renderBreadcrumbs() {
116
    $safeName = urlencode($this->currentRepo['safe_name']);
117
118
    $crumbs = [
119
      '<a href="?">Repositories</a>',
120
      '<a href="?repo=' . $safeName . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
121
      // Fix 3: Use '&' separator for the repo parameter
122
      '<a href="?action=commits&repo=' . $safeName . '">Commits</a>',
123
      substr($this->hash, 0, 7)
124
    ];
125
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
126
  }
127
}
1 128
A pages/FilePage.php
1
<?php
2
require_once __DIR__ . '/BasePage.php';
3
4
class FilePage 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
12
    $this->currentRepo = $currentRepo;
13
    $this->git         = $git;
14
    $this->hash        = $hash;
15
    $this->title       = $currentRepo['name'];
16
  }
17
18
  public function render() {
19
    $this->renderLayout(function() {
20
      $main = $this->git->getMainBranch();
21
22
      if (!$main) {
23
        echo '<div class="empty-state"><h3>No branches</h3></div>';
24
      } else {
25
        $target  = $this->hash ?: $main['hash'];
26
        $entries = [];
27
28
        $this->git->walk($target, function($file) use (&$entries) {
29
          $entries[] = $file;
30
        });
31
32
        if (!empty($entries)) {
33
          $this->renderTree($main, $target, $entries);
34
        } else {
35
          $this->renderBlob($target);
36
        }
37
      }
38
    }, $this->currentRepo);
39
  }
40
41
  private function renderTree($main, $targetHash, $entries) {
42
    $path = $_GET['name'] ?? '';
43
44
    $this->renderBreadcrumbs($targetHash, 'Tree');
45
46
    echo '<h2>' . htmlspecialchars($this->currentRepo['name']) .
47
         ' <span class="branch-badge">' .
48
         htmlspecialchars($main['name']) . '</span></h2>';
49
50
    usort($entries, function($a, $b) {
51
      return $a->compare($b);
52
    });
53
54
    echo '<div class="file-list">';
55
    $renderer = new HtmlFileRenderer($this->currentRepo['safe_name'], $path);
56
57
    foreach ($entries as $file) {
58
      $file->render($renderer);
59
    }
60
61
    echo '</div>';
62
  }
63
64
  private function renderBlob($targetHash) {
65
    $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']);
66
    $filename  = $_GET['name'] ?? '';
67
    $file      = $this->git->readFile($targetHash, $filename);
68
    $size      = $this->git->getObjectSize($targetHash);
69
70
    $renderer = new HtmlFileRenderer($this->currentRepo['safe_name']);
71
72
    $this->renderBreadcrumbs($targetHash, 'File');
73
74
    if ($size === 0) {
75
      $this->renderDownloadState($targetHash, "This file is empty.");
76
    } else {
77
      $rawUrl = '?action=raw&hash=' . $targetHash . $repoParam . '&name=' . urlencode($filename);
78
79
      if (!$file->renderMedia($rawUrl)) {
80
        if ($file->isText()) {
81
          if ($size > 524288) {
82
            ob_start();
83
            $file->renderSize($renderer);
84
            $sizeStr = ob_get_clean();
85
            $this->renderDownloadState($targetHash, "File is too large to display ($sizeStr).");
86
          } else {
87
            $content = '';
88
            $this->git->stream($targetHash, function($d) use (&$content) { $content .= $d; });
89
            echo '<div class="blob-content"><pre class="blob-code">' . htmlspecialchars($content) . '</pre></div>';
90
          }
91
        } else {
92
          $this->renderDownloadState($targetHash, "This is a binary file.");
93
        }
94
      }
95
    }
96
  }
97
98
  private function renderDownloadState($hash, $reason) {
99
    $filename = $_GET['name'] ?? '';
100
    $url = '?action=raw&hash=' . $hash . '&repo=' . urlencode($this->currentRepo['safe_name']) . '&name=' . urlencode($filename);
101
102
    echo '<div class="empty-state download-state">';
103
    echo   '<p>' . htmlspecialchars($reason) . '</p>';
104
    echo   '<a href="' . $url . '" class="btn-download">Download Raw File</a>';
105
    echo '</div>';
106
  }
107
108
  private function renderBreadcrumbs($hash, $type) {
109
    $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']);
110
    $path    = $_GET['name'] ?? '';
111
112
    $crumbs = [
113
      '<a href="?">Repositories</a>',
114
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>'
115
    ];
116
117
    if ($path) {
118
      $parts = explode('/', trim($path, '/'));
119
      $acc   = '';
120
      foreach ($parts as $idx => $part) {
121
        $acc .= ($idx === 0 ? '' : '/') . $part;
122
        if ($idx === count($parts) - 1) {
123
          $crumbs[] = htmlspecialchars($part);
124
        } else {
125
          $crumbs[] = '<a href="' . $repoUrl . '&name=' . urlencode($acc) . '">' .
126
                      htmlspecialchars($part) . '</a>';
127
        }
128
      }
129
    } elseif ($this->hash) {
130
      $crumbs[] = $type . ' ' . substr($hash, 0, 7);
131
    }
132
133
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
134
  }
135
}
1 136
A pages/HomePage.php
1
<?php
2
require_once __DIR__ . '/BasePage.php';
3
4
class HomePage extends BasePage {
5
  private $git;
6
7
  public function __construct(array $repositories, Git $git) {
8
    parent::__construct($repositories);
9
    $this->git = $git;
10
  }
11
12
  public function render() {
13
    $this->renderLayout(function() {
14
      echo '<h2>Repositories</h2>';
15
      if (empty($this->repositories)) {
16
        echo '<div class="empty-state">No repositories found.</div>';
17
        return;
18
      }
19
      echo '<div class="repo-grid">';
20
      foreach ($this->repositories as $repo) {
21
        $this->renderRepoCard($repo);
22
      }
23
      echo '</div>';
24
    });
25
  }
26
27
  private function renderRepoCard($repo) {
28
    $this->git->setRepository($repo['path']);
29
30
    $main = $this->git->getMainBranch();
31
32
    $stats = ['branches' => 0, 'tags' => 0];
33
    $this->git->eachBranch(function() use (&$stats) { $stats['branches']++; });
34
    $this->git->eachTag(function() use (&$stats) { $stats['tags']++; });
35
36
    echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">';
37
    echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>';
38
39
    echo '<p class="repo-meta">';
40
41
    $branchLabel = $stats['branches'] === 1 ? 'branch' : 'branches';
42
    $tagLabel = $stats['tags'] === 1 ? 'tag' : 'tags';
43
44
    echo $stats['branches'] . ' ' . $branchLabel . ', ' . $stats['tags'] . ' ' . $tagLabel;
45
46
    if ($main) {
47
      echo ', ';
48
      $this->git->history('HEAD', 1, function($c) use ($repo) {
49
        $renderer = new HtmlFileRenderer($repo['safe_name']);
50
        $renderer->renderTime($c->date);
51
      });
52
    }
53
    echo '</p>';
54
55
    $descPath = $repo['path'] . '/description';
56
    if (file_exists($descPath)) {
57
      $description = trim(file_get_contents($descPath));
58
      if ($description !== '') {
59
        echo '<p style="margin-top: 1.5em;">' . htmlspecialchars($description) . '</p>';
60
      }
61
    }
62
63
    echo '</a>';
64
  }
65
}
1 66
A pages/Page.php
1
<?php
2
interface Page {
3
    public function render();
4
}
1 5
A pages/RawPage.php
1
<?php
2
require_once __DIR__ . '/Page.php';
3
4
class RawPage implements Page {
5
  private $git;
6
  private $hash;
7
8
  public function __construct($git, $hash) {
9
    $this->git = $git;
10
    $this->hash = $hash;
11
  }
12
13
  public function render() {
14
    $filename = basename($_GET['name'] ?? '') ?: 'file';
15
    $file = $this->git->readFile($this->hash, $filename);
16
17
    while (ob_get_level()) {
18
      ob_end_clean();
19
    }
20
21
    $file->emitRawHeaders();
22
23
    $this->git->stream($this->hash, function($d) {
24
      echo $d;
25
    });
26
27
    exit;
28
  }
29
}
1 30
A pages/TagsPage.php
1
<?php
2
require_once __DIR__ . '/BasePage.php';
3
require_once __DIR__ . '/../render/TagRenderer.php';
4
5
class TagsPage extends BasePage {
6
  private $currentRepo;
7
  private $git;
8
9
  public function __construct(array $repositories, array $currentRepo, Git $git) {
10
    parent::__construct($repositories);
11
    $this->currentRepo = $currentRepo;
12
    $this->git = $git;
13
    $this->title = $currentRepo['name'] . ' - Tags';
14
  }
15
16
  public function render() {
17
    $this->renderLayout(function() {
18
      $this->renderBreadcrumbs();
19
20
      echo '<h2>Tags</h2>';
21
      echo '<table class="tag-table">';
22
      echo '<thead>';
23
      echo '<tr>';
24
      echo '<th>Name</th>';
25
      echo '<th>Message</th>';
26
      echo '<th>Author</th>';
27
      echo '<th class="tag-age-header">Age</th>';
28
      echo '<th class="tag-commit-header">Commit</th>';
29
      echo '</tr>';
30
      echo '</thead>';
31
      echo '<tbody>';
32
33
      $tags = [];
34
      $this->git->eachTag(function(Tag $tag) use (&$tags) {
35
        $tags[] = $tag;
36
      });
37
38
      usort($tags, function(Tag $a, Tag $b) {
39
          return $a->compare($b);
40
      });
41
42
      $renderer = new HtmlTagRenderer($this->currentRepo['safe_name']);
43
44
      if (empty($tags)) {
45
        echo '<tr><td colspan="5"><div class="empty-state"><p>No tags found.</p></div></td></tr>';
46
      } else {
47
        foreach ($tags as $tag) {
48
          $tag->render($renderer);
49
        }
50
      }
51
52
      echo '</tbody>';
53
      echo '</table>';
54
    }, $this->currentRepo);
55
  }
56
57
  private function renderBreadcrumbs() {
58
    $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']);
59
60
    $crumbs = [
61
      '<a href="?">Repositories</a>',
62
      '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>',
63
      'Tags'
64
    ];
65
66
    echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>';
67
  }
68
}
1 69
A render/FileRenderer.php
1
<?php
2
interface FileRenderer {
3
  public function renderFile(
4
    string $name,
5
    string $sha,
6
    string $mode,
7
    string $iconClass,
8
    int $timestamp,
9
    int $size = 0
10
  ): void;
11
12
  public function renderTime( int $timestamp ): void;
13
14
  public function renderSize( int $bytes ): void;
15
}
16
17
class HtmlFileRenderer implements FileRenderer {
18
  private string $repoSafeName;
19
  private string $currentPath;
20
21
  public function __construct( string $repoSafeName, string $currentPath = '' ) {
22
    $this->repoSafeName = $repoSafeName;
23
    $this->currentPath  = trim( $currentPath, '/' );
24
  }
25
26
  public function renderFile(
27
    string $name,
28
    string $sha,
29
    string $mode,
30
    string $iconClass,
31
    int $timestamp,
32
    int $size = 0
33
  ): void {
34
    $fullPath = ($this->currentPath===''?'':$this->currentPath.'/') . $name;
35
    $url      = '?repo=' . urlencode( $this->repoSafeName ) . '&hash=' . $sha . '&name=' . urlencode( $fullPath );
36
37
    echo '<a href="' . $url . '" class="file-item">';
38
    echo   '<span class="file-mode">' . $mode . '</span>';
39
    echo   '<span class="file-name">';
40
    echo     '<i class="fas ' . $iconClass . ' file-icon-container"></i>';
41
    echo     htmlspecialchars( $name );
42
    echo   '</span>';
43
44
    if( $size > 0 ) {
45
      echo '<span class="file-size">' . $this->formatSize($size) . '</span>';
46
    }
47
48
    if( $timestamp > 0 ) {
49
      echo '<span class="file-date">';
50
      $this->renderTime( $timestamp );
51
      echo '</span>';
52
    }
53
54
    echo '</a>';
55
  }
56
57
  public function renderTime( int $timestamp ): void {
58
    $tokens = [
59
      31536000 => 'year',
60
      2592000  => 'month',
61
      604800   => 'week',
62
      86400    => 'day',
63
      3600     => 'hour',
64
      60       => 'minute',
65
      1        => 'second'
66
    ];
67
68
    $diff = $timestamp ? time() - $timestamp : null;
69
    $result = 'never';
70
71
    if( $diff && $diff >= 5 ) {
72
      foreach( $tokens as $unit => $text ) {
73
        if( $diff < $unit ) {
74
          continue;
75
        }
76
77
        $num = floor( $diff / $unit );
78
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
79
        break;
80
      }
81
    } elseif( $diff ) {
82
      $result = 'just now';
83
    }
84
85
    echo $result;
86
  }
87
88
  public function renderSize( int $bytes ): void {
89
    echo $this->formatSize($bytes);
90
  }
91
92
  private function formatSize(int $bytes): string {
93
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
94
    $i = 0;
95
96
    while ($bytes >= 1024 && $i < count($units) - 1) {
97
      $bytes /= 1024;
98
      $i++;
99
    }
100
101
    return ($bytes === 0 ? 0 : round($bytes)) . ' ' . $units[$i];
102
  }
103
}
1 104
A render/TagRenderer.php
1
<?php
2
interface TagRenderer {
3
  public function renderTagItem(
4
    string $name,
5
    string $sha,
6
    string $targetSha,
7
    int $timestamp,
8
    string $message,
9
    string $author
10
  ): void;
11
12
  public function renderTime(int $timestamp): void;
13
}
14
15
class HtmlTagRenderer implements TagRenderer {
16
  private string $repoSafeName;
17
18
  public function __construct(string $repoSafeName) {
19
    $this->repoSafeName = $repoSafeName;
20
  }
21
22
  public function renderTagItem(
23
    string $name,
24
    string $sha,
25
    string $targetSha,
26
    int $timestamp,
27
    string $message,
28
    string $author
29
  ): void {
30
    $repoParam = '&repo=' . urlencode($this->repoSafeName);
31
    $filesUrl  = '?hash=' . $targetSha . $repoParam;
32
    $commitUrl = '?action=commit&hash=' . $targetSha . $repoParam;
33
34
    echo '<tr>';
35
36
    echo '<td class="tag-name">';
37
    echo   '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' . htmlspecialchars($name) . '</a>';
38
    echo '</td>';
39
40
    echo '<td class="tag-message">';
41
    echo ($message !== '') ? htmlspecialchars(strtok($message, "\n")) : '<span style="color: #484f58; font-style: italic;">No description</span>';
42
    echo '</td>';
43
44
    echo '<td class="tag-author">' . htmlspecialchars($author) . '</td>';
45
46
    echo '<td class="tag-time">';
47
    $this->renderTime($timestamp);
48
    echo '</td>';
49
50
    echo '<td class="tag-hash">';
51
    echo   '<a href="' . $commitUrl . '" class="commit-hash">' . substr($sha, 0, 7) . '</a>';
52
    echo '</td>';
53
54
    echo '</tr>';
55
  }
56
57
  public function renderTime(int $timestamp): void {
58
    if (!$timestamp) { echo 'never'; return; }
59
    $diff = time() - $timestamp;
60
    if ($diff < 5) { echo 'just now'; return; }
61
62
    $tokens = [
63
      31536000 => 'year',
64
      2592000  => 'month',
65
      604800   => 'week',
66
      86400    => 'day',
67
      3600     => 'hour',
68
      60       => 'minute',
69
      1        => 'second'
70
    ];
71
72
    foreach ($tokens as $unit => $text) {
73
      if ($diff < $unit) continue;
74
      $num = floor($diff / $unit);
75
      echo $num . ' ' . $text . (($num > 1) ? 's' : '') . ' ago';
76
      return;
77
    }
78
  }
79
}
1 80
M repo.css
1 1
* {
2
    margin: 0;
3
    padding: 0;
4
    box-sizing: border-box;
5
}
6
7
body {
8
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
9
    background: #0d1117;
10
    color: #c9d1d9;
11
    line-height: 1.6;
12
}
13
14
.container {
15
    max-width: 1200px;
16
    margin: 0 auto;
17
    padding: 20px;
18
}
19
20
header {
21
    border-bottom: 1px solid #30363d;
22
    padding-bottom: 20px;
23
    margin-bottom: 30px;
24
}
25
26
h1 {
27
    color: #f0f6fc;
28
    font-size: 1.8rem;
29
    margin-bottom: 10px;
30
}
31
32
h2 {
33
    color: #f0f6fc;
34
    font-size: 1.4rem;
35
    margin: 20px 0 15px;
36
    padding-bottom: 10px;
37
    border-bottom: 1px solid #21262d;
38
}
39
40
h3 {
41
    color: #f0f6fc;
42
    font-size: 1.1rem;
43
    margin: 15px 0 10px;
44
}
45
46
.nav {
47
    margin-top: 10px;
48
    display: flex;
49
    gap: 20px;
50
    flex-wrap: wrap;
51
    align-items: center;
52
}
53
54
.nav a {
55
    color: #58a6ff;
56
    text-decoration: none;
57
}
58
59
.nav a:hover {
60
    text-decoration: underline;
61
}
62
63
.repo-selector {
64
    margin-left: auto;
65
    display: flex;
66
    align-items: center;
67
    gap: 10px;
68
}
69
70
.repo-selector label {
71
    color: #8b949e;
72
    font-size: 0.875rem;
73
}
74
75
.repo-selector select {
76
    background: #21262d;
77
    color: #f0f6fc;
78
    border: 1px solid #30363d;
79
    padding: 6px 12px;
80
    border-radius: 6px;
81
    font-size: 0.875rem;
82
    cursor: pointer;
83
}
84
85
.repo-selector select:hover {
86
    border-color: #58a6ff;
87
}
88
89
.commit-list {
90
    list-style: none;
91
    margin-top: 20px;
92
}
93
94
.commit-item {
95
    background: #161b22;
96
    border: 1px solid #30363d;
97
    border-radius: 6px;
98
    padding: 16px;
99
    margin-bottom: 12px;
100
    transition: border-color 0.2s;
101
}
102
103
.commit-item:hover {
104
    border-color: #58a6ff;
105
}
106
107
.commit-hash {
108
    font-family: 'SFMono-Regular', Consolas, monospace;
109
    font-size: 0.85rem;
110
    color: #58a6ff;
111
    text-decoration: none;
112
}
113
114
.commit-meta {
115
    font-size: 0.875rem;
116
    color: #8b949e;
117
    margin-top: 8px;
118
}
119
120
.commit-author {
121
    color: #f0f6fc;
122
    font-weight: 500;
123
}
124
125
.commit-date {
126
    color: #8b949e;
127
}
128
129
.commit-message {
130
    margin-top: 8px;
131
    color: #c9d1d9;
132
    white-space: pre-wrap;
133
}
134
135
.file-list {
136
    background: #161b22;
137
    border: 1px solid #30363d;
138
    border-radius: 6px;
139
    overflow: hidden;
140
}
141
142
.file-item {
143
    display: flex;
144
    align-items: center;
145
    padding: 12px 16px;
146
    border-bottom: 1px solid #21262d;
147
    text-decoration: none;
148
    color: #c9d1d9;
149
    transition: background 0.2s;
150
}
151
152
.file-item:last-child {
153
    border-bottom: none;
154
}
155
156
.file-item:hover {
157
    background: #1f242c;
158
}
159
160
.file-mode {
161
    font-family: monospace;
162
    color: #8b949e;
163
    width: 80px;
164
    font-size: 0.875rem;
165
}
166
167
.file-name {
168
    flex: 1;
169
    color: #58a6ff;
170
}
171
172
.file-item:hover .file-name {
173
    text-decoration: underline;
174
}
175
176
.breadcrumb {
177
    background: #161b22;
178
    border: 1px solid #30363d;
179
    border-radius: 6px;
180
    padding: 12px 16px;
181
    margin-bottom: 20px;
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
.diff-container {
479
    display: flex;
480
    flex-direction: column;
481
    gap: 20px;
482
}
483
484
.diff-file {
485
    background: #161b22;
486
    border: 1px solid #30363d;
487
    border-radius: 6px;
488
    overflow: hidden;
489
}
490
491
.diff-header {
492
    background: #21262d;
493
    padding: 10px 16px;
494
    border-bottom: 1px solid #30363d;
495
    display: flex;
496
    align-items: center;
497
    gap: 10px;
498
}
499
500
.diff-path {
501
    font-family: monospace;
502
    font-size: 0.9rem;
503
    color: #f0f6fc;
504
}
505
506
.diff-binary {
507
    padding: 20px;
508
    text-align: center;
509
    color: #8b949e;
510
    font-style: italic;
511
}
512
513
.diff-content {
514
    overflow-x: auto;
515
}
516
517
.diff-content table {
518
    width: 100%;
519
    border-collapse: collapse;
520
    font-family: 'SFMono-Regular', Consolas, monospace;
521
    font-size: 12px;
522
}
523
524
.diff-content td {
525
    padding: 2px 0;
526
    line-height: 20px;
527
}
528
529
.diff-num {
530
    width: 1%;
531
    min-width: 40px;
532
    text-align: right;
533
    padding-right: 10px;
534
    color: #6e7681;
535
    user-select: none;
536
    background: #0d1117;
537
    border-right: 1px solid #30363d;
538
}
539
540
.diff-num::before {
541
    content: attr(data-num);
542
}
543
544
.diff-code {
545
    padding-left: 10px;
546
    white-space: pre-wrap;
547
    word-break: break-all;
548
    color: #c9d1d9;
549
}
550
551
.diff-marker {
552
    display: inline-block;
553
    width: 15px;
554
    user-select: none;
555
    color: #8b949e;
556
}
557
558
/* Protanopia Safe Colors: Blue (Add) and Yellow (Del) */
559
.diff-add {
560
    background-color: rgba(2, 59, 149, 0.25);
561
}
562
.diff-add .diff-code {
563
    color: #79c0ff;
564
}
565
.diff-add .diff-marker {
566
    color: #79c0ff;
567
}
568
569
.diff-del {
570
    background-color: rgba(148, 99, 0, 0.25);
571
}
572
.diff-del .diff-code {
573
    color: #d29922;
574
}
575
.diff-del .diff-marker {
576
    color: #d29922;
577
}
578
579
.diff-gap {
580
    background: #0d1117;
581
    color: #484f58;
582
    text-align: center;
583
    font-size: 0.8em;
584
    height: 20px;
585
}
586
.diff-gap td {
587
    padding: 0;
588
    line-height: 20px;
589
    background: rgba(110, 118, 129, 0.1);
590
}
591
592
.status-add { color: #58a6ff; }
593
.status-del { color: #d29922; }
594
.status-mod { color: #a371f7; }
595
596
.tag-table {
597
    width: 100%;
598
    border-collapse: collapse;
599
    margin-top: 10px;
600
}
601
602
.tag-table th {
603
    text-align: left;
604
    padding: 10px 16px;
605
    border-bottom: 2px solid #30363d;
606
    color: #8b949e;
607
    font-size: 0.875rem;
608
    font-weight: 600;
609
}
610
611
.tag-table td {
612
    padding: 12px 16px;
613
    border-bottom: 1px solid #21262d;
614
    vertical-align: middle;
615
    color: #c9d1d9;
616
    font-size: 0.9rem;
617
}
618
619
.tag-table tr:hover td {
620
    background: #161b22;
621
}
622
623
.tag-table .tag-name a {
624
    color: #58a6ff;
625
    text-decoration: none;
626
    font-family: 'SFMono-Regular', Consolas, monospace;
627
}
628
629
.tag-table .tag-message {
630
    font-weight: 500;
631
}
632
633
.tag-table .tag-author {
634
    color: #c9d1d9;
635
}
636
637
.tag-table .tag-time {
638
    color: #8b949e;
639
    white-space: nowrap;
640
}
641
642
.tag-table .tag-hash {
643
    text-align: right;
644
}
645
646
.tag-table .commit-hash {
647
    font-family: 'SFMono-Regular', Consolas, monospace;
648
    color: #58a6ff;
649
    text-decoration: none;
650
}
651
652
.tag-table .commit-hash:hover {
653
    text-decoration: underline;
654
}
2
  margin: 0;
3
  padding: 0;
4
  box-sizing: border-box;
5
}
6
7
body {
8
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
9
  background: #0d1117;
10
  color: #c9d1d9;
11
  line-height: 1.6;
12
}
13
14
.container {
15
  max-width: 1200px;
16
  margin: 0 auto;
17
  padding: 20px;
18
}
19
20
header {
21
  border-bottom: 1px solid #30363d;
22
  padding-bottom: 20px;
23
  margin-bottom: 30px;
24
}
25
26
h1 {
27
  color: #f0f6fc;
28
  font-size: 1.8rem;
29
  margin-bottom: 10px;
30
}
31
32
h2 {
33
  color: #f0f6fc;
34
  font-size: 1.4rem;
35
  margin: 20px 0 15px;
36
  padding-bottom: 10px;
37
  border-bottom: 1px solid #21262d;
38
}
39
40
h3 {
41
  color: #f0f6fc;
42
  font-size: 1.1rem;
43
  margin: 15px 0 10px;
44
}
45
46
.nav {
47
  margin-top: 10px;
48
  display: flex;
49
  gap: 20px;
50
  flex-wrap: wrap;
51
  align-items: center;
52
}
53
54
.nav a {
55
  color: #58a6ff;
56
  text-decoration: none;
57
}
58
59
.nav a:hover {
60
  text-decoration: underline;
61
}
62
63
.repo-selector {
64
  margin-left: auto;
65
  display: flex;
66
  align-items: center;
67
  gap: 10px;
68
}
69
70
.repo-selector label {
71
  color: #8b949e;
72
  font-size: 0.875rem;
73
}
74
75
.repo-selector select {
76
  background: #21262d;
77
  color: #f0f6fc;
78
  border: 1px solid #30363d;
79
  padding: 6px 12px;
80
  border-radius: 6px;
81
  font-size: 0.875rem;
82
  cursor: pointer;
83
}
84
85
.repo-selector select:hover {
86
  border-color: #58a6ff;
87
}
88
89
.commit-list {
90
  list-style: none;
91
  margin-top: 20px;
92
}
93
94
.commit-item {
95
  background: #161b22;
96
  border: 1px solid #30363d;
97
  border-radius: 6px;
98
  padding: 16px;
99
  margin-bottom: 12px;
100
  transition: border-color 0.2s;
101
}
102
103
.commit-item:hover {
104
  border-color: #58a6ff;
105
}
106
107
.commit-hash {
108
  font-family: 'SFMono-Regular', Consolas, monospace;
109
  font-size: 0.85rem;
110
  color: #58a6ff;
111
  text-decoration: none;
112
}
113
114
.commit-meta {
115
  font-size: 0.875rem;
116
  color: #8b949e;
117
  margin-top: 8px;
118
}
119
120
.commit-author {
121
  color: #f0f6fc;
122
  font-weight: 500;
123
}
124
125
.commit-date {
126
  color: #8b949e;
127
}
128
129
.commit-message {
130
  margin-top: 8px;
131
  color: #c9d1d9;
132
  white-space: pre-wrap;
133
}
134
135
.file-list {
136
  background: #161b22;
137
  border: 1px solid #30363d;
138
  border-radius: 6px;
139
  overflow: hidden;
140
}
141
142
.file-item {
143
  display: flex;
144
  align-items: center;
145
  padding: 12px 16px;
146
  border-bottom: 1px solid #21262d;
147
  text-decoration: none;
148
  color: #c9d1d9;
149
  transition: background 0.2s;
150
}
151
152
.file-item:last-child {
153
  border-bottom: none;
154
}
155
156
.file-item:hover {
157
  background: #1f242c;
158
}
159
160
.file-mode {
161
  font-family: monospace;
162
  color: #8b949e;
163
  width: 80px;
164
  font-size: 0.875rem;
165
}
166
167
.file-name {
168
  flex: 1;
169
  color: #58a6ff;
170
}
171
172
.file-item:hover .file-name {
173
  text-decoration: underline;
174
}
175
176
.breadcrumb {
177
  background: #161b22;
178
  border: 1px solid #30363d;
179
  border-radius: 6px;
180
  padding: 12px 16px;
181
  margin-bottom: 20px;
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
.diff-container {
479
  display: flex;
480
  flex-direction: column;
481
  gap: 20px;
482
}
483
484
.diff-file {
485
  background: #161b22;
486
  border: 1px solid #30363d;
487
  border-radius: 6px;
488
  overflow: hidden;
489
}
490
491
.diff-header {
492
  background: #21262d;
493
  padding: 10px 16px;
494
  border-bottom: 1px solid #30363d;
495
  display: flex;
496
  align-items: center;
497
  gap: 10px;
498
}
499
500
.diff-path {
501
  font-family: monospace;
502
  font-size: 0.9rem;
503
  color: #f0f6fc;
504
}
505
506
.diff-binary {
507
  padding: 20px;
508
  text-align: center;
509
  color: #8b949e;
510
  font-style: italic;
511
}
512
513
.diff-content {
514
  overflow-x: auto;
515
}
516
517
.diff-content table {
518
  width: 100%;
519
  border-collapse: collapse;
520
  font-family: 'SFMono-Regular', Consolas, monospace;
521
  font-size: 12px;
522
}
523
524
.diff-content td {
525
  padding: 2px 0;
526
  line-height: 20px;
527
}
528
529
.diff-num {
530
  width: 1%;
531
  min-width: 40px;
532
  text-align: right;
533
  padding-right: 10px;
534
  color: #6e7681;
535
  user-select: none;
536
  background: #0d1117;
537
  border-right: 1px solid #30363d;
538
}
539
540
.diff-num::before {
541
  content: attr(data-num);
542
}
543
544
.diff-code {
545
  padding-left: 10px;
546
  white-space: pre-wrap;
547
  word-break: break-all;
548
  color: #c9d1d9;
549
}
550
551
.diff-marker {
552
  display: inline-block;
553
  width: 15px;
554
  user-select: none;
555
  color: #8b949e;
556
}
557
558
/* Protanopia Safe Colors: Blue (Add) and Yellow (Del) */
559
.diff-add {
560
  background-color: rgba(2, 59, 149, 0.25);
561
}
562
.diff-add .diff-code {
563
  color: #79c0ff;
564
}
565
.diff-add .diff-marker {
566
  color: #79c0ff;
567
}
568
569
.diff-del {
570
  background-color: rgba(148, 99, 0, 0.25);
571
}
572
.diff-del .diff-code {
573
  color: #d29922;
574
}
575
.diff-del .diff-marker {
576
  color: #d29922;
577
}
578
579
.diff-gap {
580
  background: #0d1117;
581
  color: #484f58;
582
  text-align: center;
583
  font-size: 0.8em;
584
  height: 20px;
585
}
586
.diff-gap td {
587
  padding: 0;
588
  line-height: 20px;
589
  background: rgba(110, 118, 129, 0.1);
590
}
591
592
.status-add { color: #58a6ff; }
593
.status-del { color: #d29922; }
594
.status-mod { color: #a371f7; }
595
596
.tag-table {
597
  width: 100%;
598
  border-collapse: collapse;
599
  margin-top: 10px;
600
}
601
602
.tag-table th {
603
  text-align: left;
604
  padding: 10px 16px;
605
  border-bottom: 2px solid #30363d;
606
  color: #8b949e;
607
  font-size: 0.875rem;
608
  font-weight: 600;
609
  white-space: nowrap;
610
}
611
612
.tag-table td {
613
  padding: 12px 16px;
614
  border-bottom: 1px solid #21262d;
615
  vertical-align: top;
616
  color: #c9d1d9;
617
  font-size: 0.9rem;
618
}
619
620
.tag-table tr:hover td {
621
  background: #161b22;
622
}
623
624
.tag-table .tag-name {
625
  min-width: 140px;
626
  width: 20%;
627
}
628
629
.tag-table .tag-message {
630
  width: auto;
631
  white-space: normal;
632
  word-break: break-word;
633
  color: #c9d1d9;
634
  font-weight: 500;
635
}
636
637
.tag-table .tag-author,
638
.tag-table .tag-time,
639
.tag-table .tag-hash {
640
  width: 1%;
641
  white-space: nowrap;
642
}
643
644
.tag-table .tag-time {
645
  text-align: right;
646
  color: #8b949e;
647
}
648
649
.tag-table .tag-hash {
650
  text-align: right;
651
}
652
653
.tag-table .tag-name a {
654
  color: #58a6ff;
655
  text-decoration: none;
656
  font-family: 'SFMono-Regular', Consolas, monospace;
657
}
658
659
.tag-table .tag-author {
660
  color: #c9d1d9;
661
}
662
663
.tag-table .tag-age-header {
664
  text-align: right;
665
}
666
667
.tag-table .tag-commit-header {
668
  text-align: right;
669
}
670
671
.tag-table .commit-hash {
672
  font-family: 'SFMono-Regular', Consolas, monospace;
673
  color: #58a6ff;
674
  text-decoration: none;
675
}
676
677
.tag-table .commit-hash:hover {
678
  text-decoration: underline;
679
}
680
655 681