| 33 | 33 | <a href="?action=commits&repo=<?php echo $safeName; ?>">Commits</a> |
| 34 | 34 | <a href="?action=refs&repo=<?php echo $safeName; ?>">Branches</a> |
| 35 | <a href="?action=tags&repo=<?php echo $safeName; ?>">Tags</a> |
|
| 35 | 36 | <?php endif; ?> |
| 36 | 37 | |
| 37 | 38 | <?php if ($currentRepo): ?> |
| 38 | 39 | <div class="repo-selector"> |
| 39 | 40 | <label>Repository:</label> |
| 40 | 41 | <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> |
| 41 | <option value="">Select repository...</option> |
|
| 42 | 42 | <?php foreach ($this->repositories as $r): ?> |
| 43 | 43 | <option value="<?php echo htmlspecialchars($r['safe_name']); ?>" |
| 6 | 6 | class Git { |
| 7 | 7 | private const CHUNK_SIZE = 128; |
| 8 | private const MAX_READ_SIZE = 1048576; |
|
| 8 | 9 | |
| 9 | 10 | private string $repoPath; |
| ... | ||
| 52 | 53 | |
| 53 | 54 | public function read( string $sha ): string { |
| 54 | $loosePath = $this->getLoosePath( $sha ); |
|
| 55 | ||
| 56 | if( file_exists( $loosePath ) ) { |
|
| 57 | $rawContent = file_get_contents( $loosePath ); |
|
| 58 | $inflated = $rawContent ? @gzuncompress( $rawContent ) : false; |
|
| 55 | $size = $this->getObjectSize( $sha ); |
|
| 59 | 56 | |
| 60 | return $inflated ? explode( "\0", $inflated, 2 )[1] : ''; |
|
| 57 | if( $size > self::MAX_READ_SIZE ) { |
|
| 58 | return ''; |
|
| 61 | 59 | } |
| 62 | 60 | |
| 63 | return $this->packs->read( $sha ) ?? ''; |
|
| 61 | $content = ''; |
|
| 62 | ||
| 63 | $this->slurp( $sha, function( $chunk ) use ( &$content ) { |
|
| 64 | $content .= $chunk; |
|
| 65 | } ); |
|
| 66 | ||
| 67 | return $content; |
|
| 64 | 68 | } |
| 65 | 69 | |
| 66 | 70 | public function stream( string $sha, callable $callback ): void { |
| 67 | $data = $this->read( $sha ); |
|
| 71 | $this->slurp( $sha, $callback ); |
|
| 72 | } |
|
| 68 | 73 | |
| 69 | if( $data !== '' ) { |
|
| 74 | private function slurp( string $sha, callable $callback ): void { |
|
| 75 | $loosePath = $this->getLoosePath( $sha ); |
|
| 76 | ||
| 77 | if( file_exists( $loosePath ) ) { |
|
| 78 | $fileHandle = @fopen( $loosePath, 'rb' ); |
|
| 79 | ||
| 80 | if( !$fileHandle ) return; |
|
| 81 | ||
| 82 | $inflator = inflate_init( ZLIB_ENCODING_DEFLATE ); |
|
| 83 | $buffer = ''; |
|
| 84 | $headerFound = false; |
|
| 85 | ||
| 86 | while( !feof( $fileHandle ) ) { |
|
| 87 | $chunk = fread( $fileHandle, 16384 ); |
|
| 88 | $inflatedChunk = @inflate_add( $inflator, $chunk ); |
|
| 89 | ||
| 90 | if( $inflatedChunk === false ) break; |
|
| 91 | ||
| 92 | if( !$headerFound ) { |
|
| 93 | $buffer .= $inflatedChunk; |
|
| 94 | $nullPos = strpos( $buffer, "\0" ); |
|
| 95 | ||
| 96 | if( $nullPos !== false ) { |
|
| 97 | $body = substr( $buffer, $nullPos + 1 ); |
|
| 98 | ||
| 99 | if( $body !== '' ) { |
|
| 100 | $callback( $body ); |
|
| 101 | } |
|
| 102 | ||
| 103 | $headerFound = true; |
|
| 104 | $buffer = ''; |
|
| 105 | } |
|
| 106 | } else { |
|
| 107 | $callback( $inflatedChunk ); |
|
| 108 | } |
|
| 109 | } |
|
| 110 | ||
| 111 | fclose( $fileHandle ); |
|
| 112 | return; |
|
| 113 | } |
|
| 114 | ||
| 115 | $data = $this->packs->read( $sha ); |
|
| 116 | ||
| 117 | if( $data !== null && $data !== '' ) { |
|
| 70 | 118 | $callback( $data ); |
| 71 | 119 | } |
| 4 | 4 | class GitDiff { |
| 5 | 5 | private $git; |
| 6 | private const MAX_DIFF_SIZE = 1048576; |
|
| 6 | 7 | |
| 7 | 8 | public function __construct(Git $git) { |
| ... | ||
| 99 | 100 | |
| 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 | ||
| 101 | 116 | $oldContent = $oldSha ? $this->git->read($oldSha) : ''; |
| 102 | 117 | $newContent = $newSha ? $this->git->read($newSha) : ''; |
| ... | ||
| 137 | 152 | $n = count($newLines); |
| 138 | 153 | |
| 139 | // LCS Algorithm |
|
| 154 | // LCS Algorithm Optimization: Trim matching start/end |
|
| 140 | 155 | $start = 0; |
| 141 | 156 | while ($start < $m && $start < $n && $oldLines[$start] === $newLines[$start]) { |
| ... | ||
| 150 | 165 | $oldSlice = array_slice($oldLines, $start, $m - $start - $end); |
| 151 | 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 | } |
|
| 152 | 174 | |
| 153 | 175 | $ops = $this->computeLCS($oldSlice, $newSlice); |
| 154 | 176 | |
| 155 | // Grouping Optimization: Reorder interleaved +/- to be - then + |
|
| 156 | 177 | $groupedOps = []; |
| 157 | 178 | $bufferDel = []; |
| 364 | 364 | |
| 365 | 365 | private function skipSize( string $data, int &$position ): void { |
| 366 | while( ord( $data[$position++] ) & 128 ) { |
|
| 367 | // Empty loop body |
|
| 366 | $length = strlen( $data ); |
|
| 367 | ||
| 368 | while( $position < $length && (ord( $data[$position++] ) & 128) ) { |
|
| 369 | // Loop continues while MSB is 1 |
|
| 368 | 370 | } |
| 369 | 371 | } |
| 24 | 24 | |
| 25 | 25 | private function renderRepoCard($repo) { |
| 26 | // REUSE: Re-target the single Git instance |
|
| 27 | 26 | $this->git->setRepository($repo['path']); |
| 28 | 27 | |
| ... | ||
| 35 | 34 | echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; |
| 36 | 35 | echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; |
| 37 | if ($main) echo '<p>Branch: ' . htmlspecialchars($main['name']) . '</p>'; |
|
| 38 | echo '<p>' . $stats['branches'] . ' branches, ' . $stats['tags'] . ' tags</p>'; |
|
| 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; |
|
| 39 | 43 | |
| 40 | 44 | if ($main) { |
| 45 | echo ', '; |
|
| 41 | 46 | $this->git->history('HEAD', 1, function($c) use ($repo) { |
| 42 | 47 | $renderer = new HtmlFileRenderer($repo['safe_name']); |
| 43 | echo '<p class="repo-card-time">'; |
|
| 44 | 48 | $renderer->renderTime($c->date); |
| 45 | echo '</p>'; |
|
| 46 | 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 | } |
|
| 47 | 59 | } |
| 48 | 60 | |
| 53 | 53 | |
| 54 | 54 | private const EXTENSION_MAP = [ |
| 55 | 'json' => [self::CAT_TEXT, 'application/json'], |
|
| 56 | 'xml' => [self::CAT_TEXT, 'application/xml'], |
|
| 55 | // Documentation / markup |
|
| 57 | 56 | 'md' => [self::CAT_TEXT, 'text/markdown'], |
| 58 | 57 | 'rmd' => [self::CAT_TEXT, 'text/r-markdown'], |
| 59 | 58 | 'txt' => [self::CAT_TEXT, 'text/plain'], |
| 60 | 'yaml' => [self::CAT_TEXT, 'text/yaml'], |
|
| 61 | 'yml' => [self::CAT_TEXT, 'text/yaml'], |
|
| 62 | 'gradle' => [self::CAT_TEXT, 'text/plain'], |
|
| 63 | 'gitignore' => [self::CAT_TEXT, 'text/plain'], |
|
| 64 | 59 | 'tex' => [self::CAT_TEXT, 'application/x-tex'], |
| 65 | 60 | 'lyx' => [self::CAT_TEXT, 'application/x-lyx'], |
| 66 | 'bat' => [self::CAT_TEXT, 'application/x-msdos-program'], |
|
| 67 | 'ts' => [self::CAT_TEXT, 'application/typescript'], |
|
| 68 | 'log' => [self::CAT_TEXT, 'text/plain'], |
|
| 69 | 'ini' => [self::CAT_TEXT, 'text/plain'], |
|
| 70 | 'conf' => [self::CAT_TEXT, 'text/plain'], |
|
| 71 | 'zip' => [self::CAT_ARCHIVE, 'application/zip'], |
|
| 72 | 'jpg' => [self::CAT_IMAGE, 'image/jpeg'], |
|
| 73 | 'jpeg' => [self::CAT_IMAGE, 'image/jpeg'], |
|
| 74 | 'png' => [self::CAT_IMAGE, 'image/png'], |
|
| 75 | 'gif' => [self::CAT_IMAGE, 'image/gif'], |
|
| 76 | 'svg' => [self::CAT_IMAGE, 'image/svg+xml'], |
|
| 77 | 'webp' => [self::CAT_IMAGE, 'image/webp'], |
|
| 78 | 'mp4' => [self::CAT_VIDEO, 'video/mp4'], |
|
| 79 | 'mp3' => [self::CAT_AUDIO, 'audio/mpeg'], |
|
| 80 | ||
| 81 | // Data formats |
|
| 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'], |
|
| 82 | 66 | 'csv' => [self::CAT_TEXT, 'text/csv'], |
| 83 | 67 | 'tsv' => [self::CAT_TEXT, 'text/tab-separated-values'], |
| 84 | 68 | 'psv' => [self::CAT_TEXT, 'text/plain'], |
| 85 | 'ndjson' => [self::CAT_TEXT, 'application/x-ndjson'], |
|
| 86 | 69 | |
| 87 | // Config formats |
|
| 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'], |
|
| 88 | 80 | 'toml' => [self::CAT_TEXT, 'application/toml'], |
| 89 | 81 | 'env' => [self::CAT_TEXT, 'text/plain'], |
| 90 | 82 | 'cfg' => [self::CAT_TEXT, 'text/plain'], |
| 91 | 83 | 'properties'=> [self::CAT_TEXT, 'text/plain'], |
| 92 | 84 | 'dotenv' => [self::CAT_TEXT, 'text/plain'], |
| 93 | ||
| 94 | // Documentation / markup |
|
| 95 | 'rst' => [self::CAT_TEXT, 'text/x-rst'], |
|
| 96 | 'asciidoc' => [self::CAT_TEXT, 'text/asciidoc'], |
|
| 97 | 'adoc' => [self::CAT_TEXT, 'text/asciidoc'], |
|
| 98 | 'org' => [self::CAT_TEXT, 'text/org'], |
|
| 99 | 'latex' => [self::CAT_TEXT, 'application/x-tex'], |
|
| 100 | 85 | |
| 101 | 86 | // Programming languages |
| 87 | 'gradle' => [self::CAT_TEXT, 'text/plain'], |
|
| 102 | 88 | 'php' => [self::CAT_TEXT, 'application/x-php'], |
| 103 | 89 | 'sql' => [self::CAT_TEXT, 'application/sql'], |
| 104 | 90 | 'html' => [self::CAT_TEXT, 'text/html'], |
| 91 | 'xhtml' => [self::CAT_TEXT, 'text/xhtml'], |
|
| 105 | 92 | 'css' => [self::CAT_TEXT, 'text/css'], |
| 106 | 93 | 'js' => [self::CAT_TEXT, 'application/javascript'], |
| ... | ||
| 131 | 118 | 'zsh' => [self::CAT_TEXT, 'application/x-sh'], |
| 132 | 119 | 'fish' => [self::CAT_TEXT, 'text/plain'], |
| 133 | 'ps1' => [self::CAT_TEXT, 'application/x-powershell'], |
|
| 134 | ||
| 135 | // Build / DevOps |
|
| 136 | 'dockerfile'=> [self::CAT_TEXT, 'text/plain'], |
|
| 137 | 'makefile' => [self::CAT_TEXT, 'text/x-makefile'], |
|
| 138 | 'cmake' => [self::CAT_TEXT, 'text/x-cmake'], |
|
| 139 | 'gitmodules'=> [self::CAT_TEXT, 'text/plain'], |
|
| 140 | 'editorconfig'=> [self::CAT_TEXT, 'text/plain'], |
|
| 141 | ||
| 142 | // Dependency / package files |
|
| 143 | 'lock' => [self::CAT_TEXT, 'text/plain'], |
|
| 144 | 'pipfile' => [self::CAT_TEXT, 'text/plain'], |
|
| 145 | 'pipfile.lock' => [self::CAT_TEXT, 'application/json'], |
|
| 146 | 'requirements.txt' => [self::CAT_TEXT, 'text/plain'], |
|
| 147 | ||
| 148 | // Misc text |
|
| 149 | 'license' => [self::CAT_TEXT, 'text/plain'], |
|
| 150 | 'readme' => [self::CAT_TEXT, 'text/plain'], |
|
| 151 | 'todo' => [self::CAT_TEXT, 'text/plain'], |
|
| 152 | 'manifest' => [self::CAT_TEXT, 'text/plain'], |
|
| 120 | 'bat' => [self::CAT_TEXT, 'application/x-msdos-program'], |
|
| 121 | 'ps1' => [self::CAT_TEXT, 'application/x-powershell'] |
|
| 153 | 122 | ]; |
| 154 | 123 | |
| 40 | 40 | |
| 41 | 41 | $lines = file($orderFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); |
| 42 | $order = array_flip(array_map('trim', $lines)); |
|
| 42 | $order = []; |
|
| 43 | $exclude = []; |
|
| 44 | ||
| 45 | foreach ($lines as $line) { |
|
| 46 | $line = trim($line); |
|
| 47 | if ($line === '') continue; |
|
| 48 | ||
| 49 | if ($line[0] === '-') { |
|
| 50 | $exclude[substr($line, 1)] = true; |
|
| 51 | } else { |
|
| 52 | $order[$line] = count($order); |
|
| 53 | } |
|
| 54 | } |
|
| 55 | ||
| 56 | foreach ($repos as $key => $repo) { |
|
| 57 | if (isset($exclude[$repo['safe_name']])) { |
|
| 58 | unset($repos[$key]); |
|
| 59 | } |
|
| 60 | } |
|
| 43 | 61 | |
| 44 | 62 | uasort($repos, function($a, $b) use ($order) { |
| 5 | 5 | require_once 'GitDiff.php'; |
| 6 | 6 | require_once 'DiffPage.php'; |
| 7 | require_once 'TagsPage.php'; |
|
| 7 | 8 | |
| 8 | 9 | class Router { |
| ... | ||
| 50 | 51 | if ($action === 'commits') { |
| 51 | 52 | return new CommitsPage($this->repositories, $currentRepo, $this->git, $hash); |
| 53 | } |
|
| 54 | ||
| 55 | if ($action === 'tags') { |
|
| 56 | return new TagsPage($this->repositories, $currentRepo, $this->git); |
|
| 52 | 57 | } |
| 53 | 58 | |
| 1 | <?php |
|
| 2 | class TagsPage extends BasePage { |
|
| 3 | private $currentRepo; |
|
| 4 | private $git; |
|
| 5 | ||
| 6 | public function __construct(array $repositories, array $currentRepo, Git $git) { |
|
| 7 | parent::__construct($repositories); |
|
| 8 | $this->currentRepo = $currentRepo; |
|
| 9 | $this->git = $git; |
|
| 10 | $this->title = $currentRepo['name'] . ' - Tags'; |
|
| 11 | } |
|
| 12 | ||
| 13 | public function render() { |
|
| 14 | $this->renderLayout(function() { |
|
| 15 | $this->renderBreadcrumbs(); |
|
| 16 | ||
| 17 | echo '<h2>Tags</h2>'; |
|
| 18 | echo '<div class="refs-list">'; |
|
| 19 | ||
| 20 | $tags = []; |
|
| 21 | $repoParam = '&repo=' . urlencode($this->currentRepo['safe_name']); |
|
| 22 | ||
| 23 | // 1. Collect tags and parse dates |
|
| 24 | $this->git->eachTag(function($name, $sha) use (&$tags) { |
|
| 25 | // Read the object to peel tags and get dates |
|
| 26 | $data = $this->git->read($sha); |
|
| 27 | $targetSha = $sha; |
|
| 28 | $timestamp = 0; |
|
| 29 | ||
| 30 | // Check if Annotated Tag (starts with 'object <sha>') |
|
| 31 | if (strncmp($data, 'object ', 7) === 0) { |
|
| 32 | // Extract target SHA |
|
| 33 | if (preg_match('/^object ([0-9a-f]{40})$/m', $data, $matches)) { |
|
| 34 | $targetSha = $matches[1]; |
|
| 35 | } |
|
| 36 | // Extract Tagger Date |
|
| 37 | if (preg_match('/^tagger .* (\d+) [+\-]\d{4}$/m', $data, $matches)) { |
|
| 38 | $timestamp = (int)$matches[1]; |
|
| 39 | } |
|
| 40 | } |
|
| 41 | // Lightweight Tag (Commit object) |
|
| 42 | else { |
|
| 43 | // Extract Author Date |
|
| 44 | if (preg_match('/^author .* (\d+) [+\-]\d{4}$/m', $data, $matches)) { |
|
| 45 | $timestamp = (int)$matches[1]; |
|
| 46 | } |
|
| 47 | } |
|
| 48 | ||
| 49 | $tags[] = [ |
|
| 50 | 'name' => $name, |
|
| 51 | 'sha' => $sha, |
|
| 52 | 'targetSha' => $targetSha, |
|
| 53 | 'timestamp' => $timestamp |
|
| 54 | ]; |
|
| 55 | }); |
|
| 56 | ||
| 57 | // 2. Sort by date descending (newest first) |
|
| 58 | usort($tags, function($a, $b) { |
|
| 59 | return $b['timestamp'] <=> $a['timestamp']; |
|
| 60 | }); |
|
| 61 | ||
| 62 | // 3. Render |
|
| 63 | if (empty($tags)) { |
|
| 64 | echo '<div class="empty-state"><p>No tags found.</p></div>'; |
|
| 65 | } else { |
|
| 66 | foreach ($tags as $tag) { |
|
| 67 | $dateStr = $tag['timestamp'] ? date('Y-M-d', $tag['timestamp']) : ''; |
|
| 68 | $filesUrl = '?hash=' . $tag['targetSha'] . $repoParam; |
|
| 69 | $commitUrl = '?action=commit&hash=' . $tag['targetSha'] . $repoParam; |
|
| 70 | ||
| 71 | echo '<div class="ref-item">'; |
|
| 72 | // "Tag" label removed here |
|
| 73 | echo '<a href="' . $filesUrl . '" class="ref-name">' . htmlspecialchars($tag['name']) . '</a>'; |
|
| 74 | ||
| 75 | if ($dateStr) { |
|
| 76 | echo '<span class="commit-date" style="margin-left: auto; margin-right: 15px;">' . $dateStr . '</span>'; |
|
| 77 | } |
|
| 78 | ||
| 79 | // We display the Tag SHA, but link to the Commit SHA |
|
| 80 | echo '<a href="' . $commitUrl . '" class="commit-hash" ' . (!$dateStr ? 'style="margin-left: auto;"' : '') . '>' . substr($tag['sha'], 0, 7) . '</a>'; |
|
| 81 | echo '</div>'; |
|
| 82 | } |
|
| 83 | } |
|
| 84 | ||
| 85 | echo '</div>'; |
|
| 86 | }, $this->currentRepo); |
|
| 87 | } |
|
| 88 | ||
| 89 | private function renderBreadcrumbs() { |
|
| 90 | $repoUrl = '?repo=' . urlencode($this->currentRepo['safe_name']); |
|
| 91 | ||
| 92 | $crumbs = [ |
|
| 93 | '<a href="?">Repositories</a>', |
|
| 94 | '<a href="' . $repoUrl . '">' . htmlspecialchars($this->currentRepo['name']) . '</a>', |
|
| 95 | 'Tags' |
|
| 96 | ]; |
|
| 97 | ||
| 98 | echo '<div class="breadcrumb">' . implode(' / ', $crumbs) . '</div>'; |
|
| 99 | } |
|
| 100 | } |
|
| 1 | 101 |