| 1 | 1 | .htaccess |
| 2 | 2 | order.txt |
| 3 | favicon.ico |
|
| 3 | 4 |
| 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 |
| 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) . ' • ' . 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 |
| 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); |
| 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 |
| 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 | } |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 1 | <?php |
|
| 2 | interface Page { |
|
| 3 | public function render(); |
|
| 4 | } |
|
| 5 | 1 |
| 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 |
| 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 | } |
| 1 | 1 | <?php |
| 2 | require_once 'TagRenderer.php'; |
|
| 2 | require_once __DIR__ . '/render/TagRenderer.php'; |
|
| 3 | 3 | |
| 4 | 4 | class Tag { |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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(); |
| 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 |
| 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 |
| 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) . ' • ' . 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 |
| 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 |
| 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 |
| 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 |
| 1 | <?php |
|
| 2 | interface Page { |
|
| 3 | public function render(); |
|
| 4 | } |
|
| 1 | 5 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |