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