| | ini_set('error_log', __DIR__ . '/error.log'); |
| | |
| | -// Get list of available repositories |
| | -function getRepositories() { |
| | - $repos = []; |
| | - if (!is_dir(REPOS_PATH)) { |
| | - return $repos; |
| | - } |
| | - |
| | - foreach (glob(REPOS_PATH . '/*.git') as $path) { |
| | - if (is_dir($path)) { |
| | - $name = basename($path, '.git'); |
| | - $displayName = urldecode($name); |
| | - $repos[$name] = [ |
| | - 'path' => $path, |
| | - 'name' => $displayName, |
| | - 'safe_name' => $name |
| | - ]; |
| | - } |
| | - } |
| | - |
| | - uasort($repos, function($a, $b) { |
| | - return strcasecmp($a['name'], $b['name']); |
| | - }); |
| | - |
| | - return $repos; |
| | -} |
| | - |
| | -// Get current repository |
| | -function getCurrentRepo() { |
| | - $repos = getRepositories(); |
| | - $requested = $_GET['repo'] ?? ''; |
| | - $decodedRequested = urldecode($requested); |
| | - |
| | - foreach ($repos as $key => $repo) { |
| | - if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) { |
| | - return $repo; |
| | - } |
| | - } |
| | - |
| | - return null; |
| | -} |
| | - |
| | -function sanitizePath($path) { |
| | - $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); |
| | - return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); |
| | -} |
| | - |
| | -function getObjectPath($repoPath, $hash) { |
| | - if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { |
| | - return false; |
| | - } |
| | - $dir = substr($hash, 0, 2); |
| | - $file = substr($hash, 2); |
| | - return $repoPath . '/objects/' . $dir . '/' . $file; |
| | -} |
| | - |
| | -function readGitObject($repoPath, $hash) { |
| | - $path = getObjectPath($repoPath, $hash); |
| | - if (!$path || !file_exists($path)) { |
| | - return false; |
| | - } |
| | - |
| | - $content = file_get_contents($path); |
| | - if ($content === false) { |
| | - return false; |
| | - } |
| | - |
| | - $decompressed = @gzuncompress($content); |
| | - if ($decompressed === false) { |
| | - return false; |
| | - } |
| | - |
| | - $nullPos = strpos($decompressed, "\0"); |
| | - if ($nullPos === false) { |
| | - return false; |
| | - } |
| | - |
| | - $header = substr($decompressed, 0, $nullPos); |
| | - $parts = explode(' ', $header); |
| | - |
| | - return [ |
| | - 'type' => $parts[0] ?? 'unknown', |
| | - 'size' => $parts[1] ?? 0, |
| | - 'content' => substr($decompressed, $nullPos + 1) |
| | - ]; |
| | -} |
| | - |
| | -function parseTree($content) { |
| | - $entries = []; |
| | - $offset = 0; |
| | - |
| | - while ($offset < strlen($content)) { |
| | - $spacePos = strpos($content, ' ', $offset); |
| | - if ($spacePos === false) break; |
| | - |
| | - $mode = substr($content, $offset, $spacePos - $offset); |
| | - $offset = $spacePos + 1; |
| | - |
| | - $nullPos = strpos($content, "\0", $offset); |
| | - if ($nullPos === false) break; |
| | - |
| | - $name = substr($content, $offset, $nullPos - $offset); |
| | - $offset = $nullPos + 1; |
| | - |
| | - if ($offset + 20 > strlen($content)) break; |
| | - $hash = bin2hex(substr($content, $offset, 20)); |
| | - $offset += 20; |
| | - |
| | - $entries[] = [ |
| | - 'mode' => $mode, |
| | - 'name' => $name, |
| | - 'hash' => $hash, |
| | - 'type' => ($mode === '040000' || $mode === '40000') ? 'tree' : 'blob' |
| | - ]; |
| | - } |
| | - |
| | - usort($entries, function($a, $b) { |
| | - if ($a['type'] !== $b['type']) { |
| | - return $a['type'] === 'tree' ? -1 : 1; |
| | - } |
| | - return strcasecmp($a['name'], $b['name']); |
| | - }); |
| | - |
| | - return $entries; |
| | -} |
| | - |
| | -function parseCommit($content) { |
| | - $lines = explode("\n", $content); |
| | - $commit = [ |
| | - 'tree' => '', |
| | - 'parents' => [], |
| | - 'author' => '', |
| | - 'committer' => '', |
| | - 'message' => '' |
| | - ]; |
| | - |
| | - $inMessage = false; |
| | - $messageLines = []; |
| | - |
| | - foreach ($lines as $line) { |
| | - if ($inMessage) { |
| | - $messageLines[] = $line; |
| | - } elseif ($line === '') { |
| | - $inMessage = true; |
| | - } elseif (strpos($line, 'tree ') === 0) { |
| | - $commit['tree'] = substr($line, 5); |
| | - } elseif (strpos($line, 'parent ') === 0) { |
| | - $commit['parents'][] = substr($line, 7); |
| | - } elseif (strpos($line, 'author ') === 0) { |
| | - $commit['author'] = substr($line, 7); |
| | - } elseif (strpos($line, 'committer ') === 0) { |
| | - $commit['committer'] = substr($line, 10); |
| | - } |
| | - } |
| | - |
| | - $commit['message'] = implode("\n", $messageLines); |
| | - return $commit; |
| | -} |
| | - |
| | -function getHead($repoPath) { |
| | - $headFile = $repoPath . '/HEAD'; |
| | - if (!file_exists($headFile)) { |
| | - return false; |
| | - } |
| | - |
| | - $content = trim(file_get_contents($headFile)); |
| | - |
| | - if (preg_match('/^[a-f0-9]{40}$/', $content)) { |
| | - return ['type' => 'detached', 'hash' => $content, 'ref' => null]; |
| | - } |
| | - |
| | - if (strpos($content, 'ref: ') === 0) { |
| | - $ref = substr($content, 5); |
| | - $refFile = $repoPath . '/' . $ref; |
| | - if (file_exists($refFile)) { |
| | - $hash = trim(file_get_contents($refFile)); |
| | - return ['type' => 'ref', 'hash' => $hash, 'ref' => $ref]; |
| | - } |
| | - } |
| | - |
| | - return false; |
| | -} |
| | - |
| | -function listRefs($repoPath) { |
| | - $refs = []; |
| | - |
| | - $branchesDir = $repoPath . '/refs/heads'; |
| | - if (is_dir($branchesDir)) { |
| | - foreach (glob($branchesDir . '/*') as $file) { |
| | - if (is_file($file)) { |
| | - $name = basename($file); |
| | - $hash = trim(file_get_contents($file)); |
| | - $refs['branches'][$name] = $hash; |
| | - } |
| | - } |
| | - } |
| | - |
| | - $tagsDir = $repoPath . '/refs/tags'; |
| | - if (is_dir($tagsDir)) { |
| | - foreach (glob($tagsDir . '/*') as $file) { |
| | - if (is_file($file)) { |
| | - $name = basename($file); |
| | - $content = file_get_contents($file); |
| | - $refs['tags'][$name] = trim($content); |
| | - } |
| | - } |
| | - } |
| | - |
| | - return $refs; |
| | -} |
| | - |
| | -function formatDate($line) { |
| | - if (preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $matches)) { |
| | - $timestamp = $matches[1]; |
| | - return date('Y-m-d H:i:s', $timestamp); |
| | - } |
| | - return 'Unknown'; |
| | -} |
| | - |
| | -function getAuthor($line) { |
| | - if (preg_match('/^([^<]+)/', $line, $matches)) { |
| | - return trim($matches[1]); |
| | - } |
| | - return $line; |
| | -} |
| | - |
| | -function getLog($repoPath, $commitHash, $max = 20) { |
| | - $log = []; |
| | - $seen = []; |
| | - $queue = [$commitHash]; |
| | - |
| | - while (!empty($queue) && count($log) < $max) { |
| | - $hash = array_shift($queue); |
| | - if (isset($seen[$hash])) continue; |
| | - $seen[$hash] = true; |
| | - |
| | - $obj = readGitObject($repoPath, $hash); |
| | - if (!$obj || $obj['type'] !== 'commit') continue; |
| | - |
| | - $commit = parseCommit($obj['content']); |
| | - $commit['hash'] = $hash; |
| | - $log[] = $commit; |
| | - |
| | - foreach ($commit['parents'] as $parent) { |
| | - $queue[] = $parent; |
| | - } |
| | - } |
| | - |
| | - return $log; |
| | -} |
| | - |
| | -function getMainBranch($repoPath) { |
| | - $refs = listRefs($repoPath); |
| | - |
| | - $priority = ['main', 'master', 'trunk', 'develop']; |
| | - foreach ($priority as $branch) { |
| | - if (isset($refs['branches'][$branch])) { |
| | - return ['name' => $branch, 'hash' => $refs['branches'][$branch]]; |
| | - } |
| | - } |
| | - |
| | - if (!empty($refs['branches'])) { |
| | - $first = array_key_first($refs['branches']); |
| | - return ['name' => $first, 'hash' => $refs['branches'][$first]]; |
| | - } |
| | - |
| | - return null; |
| | -} |
| | - |
| | -$action = $_GET['action'] ?? 'home'; |
| | -$hash = sanitizePath($_GET['hash'] ?? ''); |
| | - |
| | -$currentRepo = getCurrentRepo(); |
| | -$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : ''; |
| | - |
| | -$repositories = getRepositories(); |
| | - |
| | -?> |
| | -<!DOCTYPE html> |
| | -<html lang="en"> |
| | -<head> |
| | - <meta charset="UTF-8"> |
| | - <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | - <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title> |
| | - <style> |
| | - * { |
| | - margin: 0; |
| | - padding: 0; |
| | - box-sizing: border-box; |
| | - } |
| | - |
| | - body { |
| | - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; |
| | - background: #0d1117; |
| | - color: #c9d1d9; |
| | - line-height: 1.6; |
| | - } |
| | - |
| | - .container { |
| | - max-width: 1200px; |
| | - margin: 0 auto; |
| | - padding: 20px; |
| | - } |
| | - |
| | - header { |
| | - border-bottom: 1px solid #30363d; |
| | - padding-bottom: 20px; |
| | - margin-bottom: 30px; |
| | - } |
| | - |
| | - h1 { |
| | - color: #f0f6fc; |
| | - font-size: 1.8rem; |
| | - margin-bottom: 10px; |
| | - } |
| | - |
| | - h2 { |
| | - color: #f0f6fc; |
| | - font-size: 1.4rem; |
| | - margin: 20px 0 15px; |
| | - padding-bottom: 10px; |
| | - border-bottom: 1px solid #21262d; |
| | - } |
| | - |
| | - h3 { |
| | - color: #f0f6fc; |
| | - font-size: 1.1rem; |
| | - margin: 15px 0 10px; |
| | - } |
| | - |
| | - .nav { |
| | - margin-top: 10px; |
| | - display: flex; |
| | - gap: 20px; |
| | - flex-wrap: wrap; |
| | - align-items: center; |
| | - } |
| | - |
| | - .nav a { |
| | - color: #58a6ff; |
| | - text-decoration: none; |
| | - } |
| | - |
| | - .nav a:hover { |
| | - text-decoration: underline; |
| | - } |
| | - |
| | - .repo-selector { |
| | - margin-left: auto; |
| | - display: flex; |
| | - align-items: center; |
| | - gap: 10px; |
| | - } |
| | - |
| | - .repo-selector label { |
| | - color: #8b949e; |
| | - font-size: 0.875rem; |
| | - } |
| | - |
| | - .repo-selector select { |
| | - background: #21262d; |
| | - color: #f0f6fc; |
| | - border: 1px solid #30363d; |
| | - padding: 6px 12px; |
| | - border-radius: 6px; |
| | - font-size: 0.875rem; |
| | - cursor: pointer; |
| | - } |
| | - |
| | - .repo-selector select:hover { |
| | - border-color: #58a6ff; |
| | - } |
| | - |
| | - .commit-list { |
| | - list-style: none; |
| | - } |
| | - |
| | - .commit-item { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 6px; |
| | - padding: 16px; |
| | - margin-bottom: 12px; |
| | - transition: border-color 0.2s; |
| | - } |
| | - |
| | - .commit-item:hover { |
| | - border-color: #58a6ff; |
| | - } |
| | - |
| | - .commit-hash { |
| | - font-family: 'SFMono-Regular', Consolas, monospace; |
| | - font-size: 0.85rem; |
| | - color: #58a6ff; |
| | - text-decoration: none; |
| | - } |
| | - |
| | - .commit-hash:hover { |
| | - text-decoration: underline; |
| | - } |
| | - |
| | - .commit-meta { |
| | - font-size: 0.875rem; |
| | - color: #8b949e; |
| | - margin-top: 8px; |
| | - } |
| | - |
| | - .commit-author { |
| | - color: #f0f6fc; |
| | - font-weight: 500; |
| | - } |
| | - |
| | - .commit-date { |
| | - color: #8b949e; |
| | - } |
| | - |
| | - .commit-message { |
| | - margin-top: 8px; |
| | - color: #c9d1d9; |
| | - white-space: pre-wrap; |
| | - } |
| | - |
| | - .file-list { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 6px; |
| | - overflow: hidden; |
| | - } |
| | - |
| | - .file-item { |
| | - display: flex; |
| | - align-items: center; |
| | - padding: 12px 16px; |
| | - border-bottom: 1px solid #21262d; |
| | - text-decoration: none; |
| | - color: #c9d1d9; |
| | - transition: background 0.2s; |
| | - } |
| | - |
| | - .file-item:last-child { |
| | - border-bottom: none; |
| | - } |
| | - |
| | - .file-item:hover { |
| | - background: #1f242c; |
| | - } |
| | - |
| | - .file-mode { |
| | - font-family: monospace; |
| | - color: #8b949e; |
| | - width: 80px; |
| | - font-size: 0.875rem; |
| | - } |
| | - |
| | - .file-name { |
| | - flex: 1; |
| | - color: #58a6ff; |
| | - } |
| | - |
| | - .file-item:hover .file-name { |
| | - text-decoration: underline; |
| | - } |
| | - |
| | - .breadcrumb { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 6px; |
| | - padding: 12px 16px; |
| | - margin-bottom: 20px; |
| | - } |
| | - |
| | - .breadcrumb a { |
| | - color: #58a6ff; |
| | - text-decoration: none; |
| | - } |
| | - |
| | - .breadcrumb a:hover { |
| | - text-decoration: underline; |
| | - } |
| | - |
| | - .breadcrumb span { |
| | - color: #8b949e; |
| | - margin: 0 8px; |
| | - } |
| | - |
| | - .blob-content { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 6px; |
| | - overflow: hidden; |
| | - } |
| | - |
| | - .blob-header { |
| | - background: #21262d; |
| | - padding: 12px 16px; |
| | - border-bottom: 1px solid #30363d; |
| | - font-size: 0.875rem; |
| | - color: #8b949e; |
| | - } |
| | - |
| | - .blob-code { |
| | - padding: 16px; |
| | - overflow-x: auto; |
| | - font-family: 'SFMono-Regular', Consolas, monospace; |
| | - font-size: 0.875rem; |
| | - line-height: 1.6; |
| | - white-space: pre; |
| | - } |
| | - |
| | - .refs-list { |
| | - display: grid; |
| | - gap: 10px; |
| | - } |
| | - |
| | - .ref-item { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 6px; |
| | - padding: 12px 16px; |
| | - display: flex; |
| | - align-items: center; |
| | - gap: 12px; |
| | - } |
| | - |
| | - .ref-type { |
| | - background: #238636; |
| | - color: white; |
| | - padding: 2px 8px; |
| | - border-radius: 12px; |
| | - font-size: 0.75rem; |
| | - font-weight: 600; |
| | - text-transform: uppercase; |
| | - } |
| | - |
| | - .ref-type.tag { |
| | - background: #8957e5; |
| | - } |
| | - |
| | - .ref-name { |
| | - font-weight: 600; |
| | - color: #f0f6fc; |
| | - } |
| | - |
| | - .empty-state { |
| | - text-align: center; |
| | - padding: 60px 20px; |
| | - color: #8b949e; |
| | - } |
| | - |
| | - .commit-details { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 6px; |
| | - padding: 20px; |
| | - margin-bottom: 20px; |
| | - } |
| | - |
| | - .commit-header { |
| | - margin-bottom: 20px; |
| | - } |
| | - |
| | - .commit-title { |
| | - font-size: 1.25rem; |
| | - color: #f0f6fc; |
| | - margin-bottom: 10px; |
| | - } |
| | - |
| | - .commit-info { |
| | - display: grid; |
| | - gap: 8px; |
| | - font-size: 0.875rem; |
| | - } |
| | - |
| | - .commit-info-row { |
| | - display: flex; |
| | - gap: 10px; |
| | - } |
| | - |
| | - .commit-info-label { |
| | - color: #8b949e; |
| | - width: 80px; |
| | - flex-shrink: 0; |
| | - } |
| | - |
| | - .commit-info-value { |
| | - color: #c9d1d9; |
| | - font-family: monospace; |
| | - } |
| | - |
| | - .parent-link { |
| | - color: #58a6ff; |
| | - text-decoration: none; |
| | - } |
| | - |
| | - .parent-link:hover { |
| | - text-decoration: underline; |
| | - } |
| | - |
| | - .repo-grid { |
| | - display: grid; |
| | - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
| | - gap: 16px; |
| | - margin-top: 20px; |
| | - } |
| | - |
| | - .repo-card { |
| | - background: #161b22; |
| | - border: 1px solid #30363d; |
| | - border-radius: 8px; |
| | - padding: 20px; |
| | - text-decoration: none; |
| | - color: inherit; |
| | - transition: border-color 0.2s, transform 0.1s; |
| | - } |
| | - |
| | - .repo-card:hover { |
| | - border-color: #58a6ff; |
| | - transform: translateY(-2px); |
| | - } |
| | - |
| | - .repo-card h3 { |
| | - color: #58a6ff; |
| | - margin-bottom: 8px; |
| | - font-size: 1.1rem; |
| | - } |
| | - |
| | - .repo-card p { |
| | - color: #8b949e; |
| | - font-size: 0.875rem; |
| | - margin: 0; |
| | - } |
| | - |
| | - .current-repo { |
| | - background: #21262d; |
| | - border: 1px solid #58a6ff; |
| | - padding: 8px 16px; |
| | - border-radius: 6px; |
| | - font-size: 0.875rem; |
| | - color: #f0f6fc; |
| | - } |
| | - |
| | - .current-repo strong { |
| | - color: #58a6ff; |
| | - } |
| | - |
| | - .dir-icon, .file-icon { |
| | - display: inline-block; |
| | - width: 20px; |
| | - text-align: center; |
| | - margin-right: 8px; |
| | - color: #8b949e; |
| | - } |
| | - |
| | - .branch-badge { |
| | - background: #238636; |
| | - color: white; |
| | - padding: 2px 8px; |
| | - border-radius: 12px; |
| | - font-size: 0.75rem; |
| | - font-weight: 600; |
| | - margin-left: 10px; |
| | - } |
| | - </style> |
| | -</head> |
| | -<body> |
| | - <div class="container"> |
| | - <header> |
| | - <h1><?php echo SITE_TITLE; ?></h1> |
| | - <nav class="nav"> |
| | - <a href="?">Home</a> |
| | - <?php if ($currentRepo): ?> |
| | - <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a> |
| | - <a href="?action=commits<?php echo $repoParam; ?>">Commits</a> |
| | - <a href="?action=refs<?php echo $repoParam; ?>">Branches</a> |
| | - <?php endif; ?> |
| | - |
| | - <?php if ($currentRepo): ?> |
| | - <div class="repo-selector"> |
| | - <label>Repository:</label> |
| | - <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> |
| | - <option value="">Select repository...</option> |
| | - <?php foreach ($repositories as $repo): ?> |
| | - <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> |
| | - <?php echo htmlspecialchars($repo['name']); ?> |
| | - </option> |
| | - <?php endforeach; ?> |
| | - </select> |
| | - </div> |
| | - <?php endif; ?> |
| | - </nav> |
| | - |
| | - <?php if ($currentRepo): ?> |
| | - <div style="margin-top: 15px;"> |
| | - <span class="current-repo"> |
| | - Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong> |
| | - </span> |
| | - </div> |
| | - <?php endif; ?> |
| | - </header> |
| | - |
| | - <?php |
| | - if (!$currentRepo) { |
| | - // Default view: list all repositories |
| | - echo '<h2>Repositories</h2>'; |
| | - |
| | - if (empty($repositories)) { |
| | - echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>'; |
| | - } else { |
| | - echo '<div class="repo-grid">'; |
| | - foreach ($repositories as $repo) { |
| | - $mainBranch = getMainBranch($repo['path']); |
| | - $head = getHead($repo['path']); |
| | - |
| | - echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; |
| | - echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; |
| | - |
| | - if ($mainBranch) { |
| | - echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>'; |
| | - } |
| | - |
| | - $refs = listRefs($repo['path']); |
| | - $branchCount = count($refs['branches'] ?? []); |
| | - $tagCount = count($refs['tags'] ?? []); |
| | - |
| | - echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>'; |
| | - |
| | - if ($head) { |
| | - $obj = readGitObject($repo['path'], $head['hash']); |
| | - if ($obj && $obj['type'] === 'commit') { |
| | - $commit = parseCommit($obj['content']); |
| | - echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($head['hash'], 0, 7) . ' - ' . htmlspecialchars(substr(trim(explode("\n", $commit['message'])[0]), 0, 50)) . '</p>'; |
| | - } |
| | - } |
| | - echo '</a>'; |
| | - } |
| | - echo '</div>'; |
| | - } |
| | - } else { |
| | - // Repository selected - determine main branch and show files |
| | - $mainBranch = getMainBranch($currentRepo['path']); |
| | - |
| | - if (!$mainBranch) { |
| | - echo '<div class="empty-state">'; |
| | - echo '<h3>No branches found</h3>'; |
| | - echo '<p>This repository appears to be empty.</p>'; |
| | - echo '</div>'; |
| | - } else { |
| | - // Get the tree hash from the main branch commit |
| | - $obj = readGitObject($currentRepo['path'], $mainBranch['hash']); |
| | - |
| | - if (!$obj || $obj['type'] !== 'commit') { |
| | - echo '<div class="empty-state">Error reading commit</div>'; |
| | - } else { |
| | - $commit = parseCommit($obj['content']); |
| | - $treeHash = $commit['tree']; |
| | - |
| | - // Check if we're viewing a specific tree or blob |
| | - $viewHash = $hash ?: $treeHash; |
| | - $viewObj = readGitObject($currentRepo['path'], $viewHash); |
| | - |
| | - if ($viewObj && $viewObj['type'] === 'blob') { |
| | - // Viewing a file |
| | - echo '<div class="breadcrumb">'; |
| | - echo '<a href="?">Repositories</a>'; |
| | - echo '<span>/</span>'; |
| | - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; |
| | - echo '<span>/</span>'; |
| | - echo '<span>File</span>'; |
| | - echo '</div>'; |
| | - |
| | - echo '<h2>' . substr($viewHash, 0, 7) . '</h2>'; |
| | - |
| | - echo '<div class="blob-content">'; |
| | - echo '<div class="blob-header">' . strlen($viewObj['content']) . ' bytes</div>'; |
| | - echo '<div class="blob-code">' . htmlspecialchars($viewObj['content']) . '</div>'; |
| | - echo '</div>'; |
| | - } else { |
| | - // Viewing tree (directory) |
| | - $treeObj = readGitObject($currentRepo['path'], $viewHash); |
| | - |
| | - if (!$treeObj || $treeObj['type'] !== 'tree') { |
| | - echo '<div class="empty-state">Directory not found</div>'; |
| | - } else { |
| | - $entries = parseTree($treeObj['content']); |
| | - |
| | - echo '<div class="breadcrumb">'; |
| | - echo '<a href="?">Repositories</a>'; |
| | - echo '<span>/</span>'; |
| | - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; |
| | - if ($viewHash !== $treeHash) { |
| | - echo '<span>/</span>'; |
| | - echo '<span>Tree ' . substr($viewHash, 0, 7) . '</span>'; |
| | - } |
| | - echo '</div>'; |
| | - |
| | - echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>'; |
| | - |
| | - // Show latest commit info |
| | - echo '<div class="commit-item" style="margin-bottom: 20px;">'; |
| | - echo '<div><a href="?action=commit&hash=' . $mainBranch['hash'] . $repoParam . '" class="commit-hash">' . substr($mainBranch['hash'], 0, 7) . '</a></div>'; |
| | - echo '<div class="commit-message">' . htmlspecialchars(trim($commit['message'])) . '</div>'; |
| | - echo '<div class="commit-meta">'; |
| | - echo '<span class="commit-author">' . htmlspecialchars(getAuthor($commit['author'])) . '</span>'; |
| | - echo ' committed on '; |
| | - echo '<span class="commit-date">' . formatDate($commit['committer']) . '</span>'; |
| | - echo '</div>'; |
| | - echo '</div>'; |
| | - |
| | - echo '<h3>Files</h3>'; |
| | - |
| | - if (empty($entries)) { |
| | - echo '<div class="empty-state">Empty directory</div>'; |
| | - } else { |
| | - echo '<div class="file-list">'; |
| | - foreach ($entries as $entry) { |
| | - $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]'; |
| | - $action = $entry['type'] === 'tree' ? '' : '&hash=' . $entry['hash']; |
| | - |
| | - echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . $action . '" class="file-item">'; |
| | - echo '<span class="file-mode">' . $entry['mode'] . '</span>'; |
| | - echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>'; |
| | - echo '</a>'; |
| | - } |
| | - echo '</div>'; |
| | - } |
| | - } |
| | - } |
| | - } |
| | - } |
| | - } |
| | - ?> |
| | - </div> |
| | -</body> |
| | -</html> |
| | - |
| | +function getRepositories() { |
| | + $repos = []; |
| | + if (!is_dir(REPOS_PATH)) { |
| | + return $repos; |
| | + } |
| | + |
| | + foreach (glob(REPOS_PATH . '/*.git') as $path) { |
| | + if (is_dir($path)) { |
| | + $name = basename($path, '.git'); |
| | + $displayName = urldecode($name); |
| | + $repos[$name] = [ |
| | + 'path' => $path, |
| | + 'name' => $displayName, |
| | + 'safe_name' => $name |
| | + ]; |
| | + } |
| | + } |
| | + |
| | + uasort($repos, function($a, $b) { |
| | + return strcasecmp($a['name'], $b['name']); |
| | + }); |
| | + |
| | + return $repos; |
| | +} |
| | + |
| | +function getCurrentRepo() { |
| | + $repos = getRepositories(); |
| | + $requested = $_GET['repo'] ?? ''; |
| | + $decodedRequested = urldecode($requested); |
| | + |
| | + foreach ($repos as $key => $repo) { |
| | + if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) { |
| | + return $repo; |
| | + } |
| | + } |
| | + |
| | + return null; |
| | +} |
| | + |
| | +function sanitizePath($path) { |
| | + $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path); |
| | + return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path); |
| | +} |
| | + |
| | +function getObjectPath($repoPath, $hash) { |
| | + if (!preg_match('/^[a-f0-9]{40}$/', $hash)) { |
| | + return false; |
| | + } |
| | + $dir = substr($hash, 0, 2); |
| | + $file = substr($hash, 2); |
| | + return $repoPath . '/objects/' . $dir . '/' . $file; |
| | +} |
| | + |
| | +function readPackedObject($repoPath, $hash) { |
| | + $packDir = $repoPath . '/objects/pack'; |
| | + if (!is_dir($packDir)) { |
| | + return false; |
| | + } |
| | + |
| | + foreach (glob($packDir . '/*.idx') as $idxFile) { |
| | + $result = readFromPackIndex($idxFile, $hash); |
| | + if ($result !== false) { |
| | + return $result; |
| | + } |
| | + } |
| | + |
| | + return false; |
| | +} |
| | + |
| | +function readFromPackIndex($idxFile, $hash) { |
| | + $packFile = str_replace('.idx', '.pack', $idxFile); |
| | + if (!file_exists($packFile)) { |
| | + return false; |
| | + } |
| | + |
| | + $idx = fopen($idxFile, 'rb'); |
| | + if (!$idx) return false; |
| | + |
| | + $magic = fread($idx, 4); |
| | + $version = 0; |
| | + |
| | + if ($magic === "\377tOc") { |
| | + $versionData = fread($idx, 4); |
| | + $version = unpack('N', $versionData)[1]; |
| | + if ($version !== 2) { |
| | + fclose($idx); |
| | + return false; |
| | + } |
| | + } else { |
| | + fseek($idx, 0); |
| | + } |
| | + |
| | + fseek($idx, 256 * 4 - 4); |
| | + $numObjects = unpack('N', fread($idx, 4))[1]; |
| | + |
| | + fseek($idx, 256 * 4); |
| | + $targetHash = hex2bin($hash); |
| | + $left = 0; |
| | + $right = $numObjects - 1; |
| | + $foundOffset = -1; |
| | + |
| | + while ($left <= $right) { |
| | + $mid = (int)(($left + $right) / 2); |
| | + fseek($idx, 256 * 4 + $mid * 20); |
| | + $midHash = fread($idx, 20); |
| | + |
| | + $cmp = strcmp($midHash, $targetHash); |
| | + if ($cmp === 0) { |
| | + fseek($idx, 256 * 4 + $numObjects * 20 + $mid * 4); |
| | + $offset = unpack('N', fread($idx, 4))[1]; |
| | + |
| | + if ($offset & 0x80000000) { |
| | + fseek($idx, 256 * 4 + $numObjects * 24 + ($offset & 0x7fffffff) * 8); |
| | + $offset = unpack('J', fread($idx, 8))[1]; |
| | + } |
| | + |
| | + $foundOffset = $offset; |
| | + break; |
| | + } elseif ($cmp < 0) { |
| | + $left = $mid + 1; |
| | + } else { |
| | + $right = $mid - 1; |
| | + } |
| | + } |
| | + |
| | + fclose($idx); |
| | + |
| | + if ($foundOffset < 0) { |
| | + return false; |
| | + } |
| | + |
| | + return readPackObject($packFile, $foundOffset); |
| | +} |
| | + |
| | +function readPackObject($packFile, $offset) { |
| | + $pack = fopen($packFile, 'rb'); |
| | + if (!$pack) return false; |
| | + |
| | + fseek($pack, $offset); |
| | + |
| | + $byte = ord(fread($pack, 1)); |
| | + $type = ($byte >> 4) & 0x07; |
| | + $size = $byte & 0x0f; |
| | + $shift = 4; |
| | + |
| | + while ($byte & 0x80) { |
| | + $byte = ord(fread($pack, 1)); |
| | + $size |= ($byte & 0x7f) << $shift; |
| | + $shift += 7; |
| | + } |
| | + |
| | + $types = ['', 'commit', 'tree', 'blob', 'tag', '', 'ofs_delta', 'ref_delta']; |
| | + $typeName = $types[$type] ?? 'unknown'; |
| | + |
| | + if ($type === 6 || $type === 7) { |
| | + fclose($pack); |
| | + return false; |
| | + } |
| | + |
| | + $compressed = ''; |
| | + $context = inflate_init(ZLIB_ENCODING_DEFLATE); |
| | + $uncompressed = ''; |
| | + |
| | + while (!feof($pack)) { |
| | + $chunk = fread($pack, 8192); |
| | + $uncompressed .= inflate_add($context, $chunk); |
| | + |
| | + if (strlen($uncompressed) >= $size) { |
| | + break; |
| | + } |
| | + } |
| | + |
| | + fclose($pack); |
| | + |
| | + if (strlen($uncompressed) !== $size) { |
| | + return false; |
| | + } |
| | + |
| | + return [ |
| | + 'type' => $typeName, |
| | + 'size' => $size, |
| | + 'content' => $uncompressed |
| | + ]; |
| | +} |
| | + |
| | +function readGitObject($repoPath, $hash) { |
| | + $path = getObjectPath($repoPath, $hash); |
| | + if ($path && file_exists($path)) { |
| | + $content = @file_get_contents($path); |
| | + if ($content !== false) { |
| | + $decompressed = @gzuncompress($content); |
| | + if ($decompressed !== false) { |
| | + $nullPos = strpos($decompressed, "\0"); |
| | + if ($nullPos !== false) { |
| | + $header = substr($decompressed, 0, $nullPos); |
| | + $parts = explode(' ', $header, 2); |
| | + return [ |
| | + 'type' => $parts[0] ?? 'unknown', |
| | + 'size' => $parts[1] ?? 0, |
| | + 'content' => substr($decompressed, $nullPos + 1) |
| | + ]; |
| | + } |
| | + } |
| | + } |
| | + } |
| | + |
| | + return readPackedObject($repoPath, $hash); |
| | +} |
| | + |
| | +function parseTree($content) { |
| | + $entries = []; |
| | + $offset = 0; |
| | + $len = strlen($content); |
| | + |
| | + while ($offset < $len) { |
| | + $spacePos = strpos($content, ' ', $offset); |
| | + if ($spacePos === false) break; |
| | + |
| | + $mode = substr($content, $offset, $spacePos - $offset); |
| | + $offset = $spacePos + 1; |
| | + |
| | + $nullPos = strpos($content, "\0", $offset); |
| | + if ($nullPos === false) break; |
| | + |
| | + $name = substr($content, $offset, $nullPos - $offset); |
| | + $offset = $nullPos + 1; |
| | + |
| | + if ($offset + 20 > $len) break; |
| | + $hash = bin2hex(substr($content, $offset, 20)); |
| | + $offset += 20; |
| | + |
| | + $isTree = in_array($mode, ['040000', '40000', '160000']); |
| | + |
| | + $entries[] = [ |
| | + 'mode' => $mode, |
| | + 'name' => $name, |
| | + 'hash' => $hash, |
| | + 'type' => $isTree ? 'tree' : 'blob' |
| | + ]; |
| | + } |
| | + |
| | + usort($entries, function($a, $b) { |
| | + if ($a['type'] !== $b['type']) { |
| | + return $a['type'] === 'tree' ? -1 : 1; |
| | + } |
| | + return strcasecmp($a['name'], $b['name']); |
| | + }); |
| | + |
| | + return $entries; |
| | +} |
| | + |
| | +function parseCommit($content) { |
| | + $lines = explode("\n", $content); |
| | + $commit = [ |
| | + 'tree' => '', |
| | + 'parents' => [], |
| | + 'author' => '', |
| | + 'committer' => '', |
| | + 'message' => '' |
| | + ]; |
| | + |
| | + $inMessage = false; |
| | + $messageLines = []; |
| | + |
| | + foreach ($lines as $line) { |
| | + if ($inMessage) { |
| | + $messageLines[] = $line; |
| | + } elseif ($line === '') { |
| | + $inMessage = true; |
| | + } elseif (strpos($line, 'tree ') === 0) { |
| | + $commit['tree'] = substr($line, 5); |
| | + } elseif (strpos($line, 'parent ') === 0) { |
| | + $commit['parents'][] = substr($line, 7); |
| | + } elseif (strpos($line, 'author ') === 0) { |
| | + $commit['author'] = substr($line, 7); |
| | + } elseif (strpos($line, 'committer ') === 0) { |
| | + $commit['committer'] = substr($line, 10); |
| | + } |
| | + } |
| | + |
| | + $commit['message'] = implode("\n", $messageLines); |
| | + return $commit; |
| | +} |
| | + |
| | +function getHead($repoPath) { |
| | + $headFile = $repoPath . '/HEAD'; |
| | + if (!file_exists($headFile)) { |
| | + return false; |
| | + } |
| | + |
| | + $content = trim(file_get_contents($headFile)); |
| | + |
| | + if (preg_match('/^[a-f0-9]{40}$/', $content)) { |
| | + return ['type' => 'detached', 'hash' => $content, 'ref' => null]; |
| | + } |
| | + |
| | + if (strpos($content, 'ref: ') === 0) { |
| | + $ref = substr($content, 5); |
| | + $refFile = $repoPath . '/' . $ref; |
| | + if (file_exists($refFile)) { |
| | + $hash = trim(file_get_contents($refFile)); |
| | + return ['type' => 'ref', 'hash' => $hash, 'ref' => $ref]; |
| | + } |
| | + } |
| | + |
| | + return false; |
| | +} |
| | + |
| | +function listRefs($repoPath) { |
| | + $refs = []; |
| | + |
| | + $branchesDir = $repoPath . '/refs/heads'; |
| | + if (is_dir($branchesDir)) { |
| | + foreach (glob($branchesDir . '/*') as $file) { |
| | + if (is_file($file)) { |
| | + $name = basename($file); |
| | + $hash = trim(file_get_contents($file)); |
| | + $refs['branches'][$name] = $hash; |
| | + } |
| | + } |
| | + } |
| | + |
| | + $tagsDir = $repoPath . '/refs/tags'; |
| | + if (is_dir($tagsDir)) { |
| | + foreach (glob($tagsDir . '/*') as $file) { |
| | + if (is_file($file)) { |
| | + $name = basename($file); |
| | + $content = file_get_contents($file); |
| | + $refs['tags'][$name] = trim($content); |
| | + } |
| | + } |
| | + } |
| | + |
| | + return $refs; |
| | +} |
| | + |
| | +function formatDate($line) { |
| | + if (preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $matches)) { |
| | + $timestamp = $matches[1]; |
| | + return date('Y-m-d H:i:s', $timestamp); |
| | + } |
| | + return 'Unknown'; |
| | +} |
| | + |
| | +function getAuthor($line) { |
| | + if (preg_match('/^([^<]+)/', $line, $matches)) { |
| | + return trim($matches[1]); |
| | + } |
| | + return $line; |
| | +} |
| | + |
| | +function getLog($repoPath, $commitHash, $max = 20) { |
| | + $log = []; |
| | + $seen = []; |
| | + $queue = [$commitHash]; |
| | + |
| | + while (!empty($queue) && count($log) < $max) { |
| | + $hash = array_shift($queue); |
| | + if (isset($seen[$hash])) continue; |
| | + $seen[$hash] = true; |
| | + |
| | + $obj = readGitObject($repoPath, $hash); |
| | + if (!$obj || $obj['type'] !== 'commit') continue; |
| | + |
| | + $commit = parseCommit($obj['content']); |
| | + $commit['hash'] = $hash; |
| | + $log[] = $commit; |
| | + |
| | + foreach ($commit['parents'] as $parent) { |
| | + $queue[] = $parent; |
| | + } |
| | + } |
| | + |
| | + return $log; |
| | +} |
| | + |
| | +function getMainBranch($repoPath) { |
| | + $refs = listRefs($repoPath); |
| | + |
| | + $priority = ['main', 'master', 'trunk', 'develop']; |
| | + foreach ($priority as $branch) { |
| | + if (isset($refs['branches'][$branch])) { |
| | + return ['name' => $branch, 'hash' => $refs['branches'][$branch]]; |
| | + } |
| | + } |
| | + |
| | + if (!empty($refs['branches'])) { |
| | + $first = array_key_first($refs['branches']); |
| | + return ['name' => $first, 'hash' => $refs['branches'][$first]]; |
| | + } |
| | + |
| | + return null; |
| | +} |
| | + |
| | +$action = $_GET['action'] ?? 'home'; |
| | +$hash = sanitizePath($_GET['hash'] ?? ''); |
| | + |
| | +$currentRepo = getCurrentRepo(); |
| | +$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : ''; |
| | + |
| | +$repositories = getRepositories(); |
| | + |
| | +?> |
| | +<!DOCTYPE html> |
| | +<html lang="en"> |
| | +<head> |
| | + <meta charset="UTF-8"> |
| | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | + <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title> |
| | + <style> |
| | + * { |
| | + margin: 0; |
| | + padding: 0; |
| | + box-sizing: border-box; |
| | + } |
| | + body { |
| | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; |
| | + background: #0d1117; |
| | + color: #c9d1d9; |
| | + line-height: 1.6; |
| | + } |
| | + |
| | + .container { |
| | + max-width: 1200px; |
| | + margin: 0 auto; |
| | + padding: 20px; |
| | + } |
| | + |
| | + header { |
| | + border-bottom: 1px solid #30363d; |
| | + padding-bottom: 20px; |
| | + margin-bottom: 30px; |
| | + } |
| | + |
| | + h1 { |
| | + color: #f0f6fc; |
| | + font-size: 1.8rem; |
| | + margin-bottom: 10px; |
| | + } |
| | + |
| | + h2 { |
| | + color: #f0f6fc; |
| | + font-size: 1.4rem; |
| | + margin: 20px 0 15px; |
| | + padding-bottom: 10px; |
| | + border-bottom: 1px solid #21262d; |
| | + } |
| | + |
| | + h3 { |
| | + color: #f0f6fc; |
| | + font-size: 1.1rem; |
| | + margin: 15px 0 10px; |
| | + } |
| | + |
| | + .nav { |
| | + margin-top: 10px; |
| | + display: flex; |
| | + gap: 20px; |
| | + flex-wrap: wrap; |
| | + align-items: center; |
| | + } |
| | + |
| | + .nav a { |
| | + color: #58a6ff; |
| | + text-decoration: none; |
| | + } |
| | + |
| | + .nav a:hover { |
| | + text-decoration: underline; |
| | + } |
| | + |
| | + .repo-selector { |
| | + margin-left: auto; |
| | + display: flex; |
| | + align-items: center; |
| | + gap: 10px; |
| | + } |
| | + |
| | + .repo-selector label { |
| | + color: #8b949e; |
| | + font-size: 0.875rem; |
| | + } |
| | + |
| | + .repo-selector select { |
| | + background: #21262d; |
| | + color: #f0f6fc; |
| | + border: 1px solid #30363d; |
| | + padding: 6px 12px; |
| | + border-radius: 6px; |
| | + font-size: 0.875rem; |
| | + cursor: pointer; |
| | + } |
| | + |
| | + .repo-selector select:hover { |
| | + border-color: #58a6ff; |
| | + } |
| | + |
| | + .commit-list { |
| | + list-style: none; |
| | + } |
| | + |
| | + .commit-item { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 6px; |
| | + padding: 16px; |
| | + margin-bottom: 12px; |
| | + transition: border-color 0.2s; |
| | + } |
| | + |
| | + .commit-item:hover { |
| | + border-color: #58a6ff; |
| | + } |
| | + |
| | + .commit-hash { |
| | + font-family: 'SFMono-Regular', Consolas, monospace; |
| | + font-size: 0.85rem; |
| | + color: #58a6ff; |
| | + text-decoration: none; |
| | + } |
| | + |
| | + .commit-hash:hover { |
| | + text-decoration: underline; |
| | + } |
| | + |
| | + .commit-meta { |
| | + font-size: 0.875rem; |
| | + color: #8b949e; |
| | + margin-top: 8px; |
| | + } |
| | + |
| | + .commit-author { |
| | + color: #f0f6fc; |
| | + font-weight: 500; |
| | + } |
| | + |
| | + .commit-date { |
| | + color: #8b949e; |
| | + } |
| | + |
| | + .commit-message { |
| | + margin-top: 8px; |
| | + color: #c9d1d9; |
| | + white-space: pre-wrap; |
| | + } |
| | + |
| | + .file-list { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 6px; |
| | + overflow: hidden; |
| | + } |
| | + |
| | + .file-item { |
| | + display: flex; |
| | + align-items: center; |
| | + padding: 12px 16px; |
| | + border-bottom: 1px solid #21262d; |
| | + text-decoration: none; |
| | + color: #c9d1d9; |
| | + transition: background 0.2s; |
| | + } |
| | + |
| | + .file-item:last-child { |
| | + border-bottom: none; |
| | + } |
| | + |
| | + .file-item:hover { |
| | + background: #1f242c; |
| | + } |
| | + |
| | + .file-mode { |
| | + font-family: monospace; |
| | + color: #8b949e; |
| | + width: 80px; |
| | + font-size: 0.875rem; |
| | + } |
| | + |
| | + .file-name { |
| | + flex: 1; |
| | + color: #58a6ff; |
| | + } |
| | + |
| | + .file-item:hover .file-name { |
| | + text-decoration: underline; |
| | + } |
| | + |
| | + .breadcrumb { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 6px; |
| | + padding: 12px 16px; |
| | + margin-bottom: 20px; |
| | + } |
| | + |
| | + .breadcrumb a { |
| | + color: #58a6ff; |
| | + text-decoration: none; |
| | + } |
| | + |
| | + .breadcrumb a:hover { |
| | + text-decoration: underline; |
| | + } |
| | + |
| | + .breadcrumb span { |
| | + color: #8b949e; |
| | + margin: 0 8px; |
| | + } |
| | + |
| | + .blob-content { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 6px; |
| | + overflow: hidden; |
| | + } |
| | + |
| | + .blob-header { |
| | + background: #21262d; |
| | + padding: 12px 16px; |
| | + border-bottom: 1px solid #30363d; |
| | + font-size: 0.875rem; |
| | + color: #8b949e; |
| | + } |
| | + |
| | + .blob-code { |
| | + padding: 16px; |
| | + overflow-x: auto; |
| | + font-family: 'SFMono-Regular', Consolas, monospace; |
| | + font-size: 0.875rem; |
| | + line-height: 1.6; |
| | + white-space: pre; |
| | + } |
| | + |
| | + .refs-list { |
| | + display: grid; |
| | + gap: 10px; |
| | + } |
| | + |
| | + .ref-item { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 6px; |
| | + padding: 12px 16px; |
| | + display: flex; |
| | + align-items: center; |
| | + gap: 12px; |
| | + } |
| | + |
| | + .ref-type { |
| | + background: #238636; |
| | + color: white; |
| | + padding: 2px 8px; |
| | + border-radius: 12px; |
| | + font-size: 0.75rem; |
| | + font-weight: 600; |
| | + text-transform: uppercase; |
| | + } |
| | + |
| | + .ref-type.tag { |
| | + background: #8957e5; |
| | + } |
| | + |
| | + .ref-name { |
| | + font-weight: 600; |
| | + color: #f0f6fc; |
| | + } |
| | + |
| | + .empty-state { |
| | + text-align: center; |
| | + padding: 60px 20px; |
| | + color: #8b949e; |
| | + } |
| | + |
| | + .commit-details { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 6px; |
| | + padding: 20px; |
| | + margin-bottom: 20px; |
| | + } |
| | + |
| | + .commit-header { |
| | + margin-bottom: 20px; |
| | + } |
| | + |
| | + .commit-title { |
| | + font-size: 1.25rem; |
| | + color: #f0f6fc; |
| | + margin-bottom: 10px; |
| | + } |
| | + |
| | + .commit-info { |
| | + display: grid; |
| | + gap: 8px; |
| | + font-size: 0.875rem; |
| | + } |
| | + |
| | + .commit-info-row { |
| | + display: flex; |
| | + gap: 10px; |
| | + } |
| | + |
| | + .commit-info-label { |
| | + color: #8b949e; |
| | + width: 80px; |
| | + flex-shrink: 0; |
| | + } |
| | + |
| | + .commit-info-value { |
| | + color: #c9d1d9; |
| | + font-family: monospace; |
| | + } |
| | + |
| | + .parent-link { |
| | + color: #58a6ff; |
| | + text-decoration: none; |
| | + } |
| | + |
| | + .parent-link:hover { |
| | + text-decoration: underline; |
| | + } |
| | + |
| | + .repo-grid { |
| | + display: grid; |
| | + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
| | + gap: 16px; |
| | + margin-top: 20px; |
| | + } |
| | + |
| | + .repo-card { |
| | + background: #161b22; |
| | + border: 1px solid #30363d; |
| | + border-radius: 8px; |
| | + padding: 20px; |
| | + text-decoration: none; |
| | + color: inherit; |
| | + transition: border-color 0.2s, transform 0.1s; |
| | + } |
| | + |
| | + .repo-card:hover { |
| | + border-color: #58a6ff; |
| | + transform: translateY(-2px); |
| | + } |
| | + |
| | + .repo-card h3 { |
| | + color: #58a6ff; |
| | + margin-bottom: 8px; |
| | + font-size: 1.1rem; |
| | + } |
| | + |
| | + .repo-card p { |
| | + color: #8b949e; |
| | + font-size: 0.875rem; |
| | + margin: 0; |
| | + } |
| | + |
| | + .current-repo { |
| | + background: #21262d; |
| | + border: 1px solid #58a6ff; |
| | + padding: 8px 16px; |
| | + border-radius: 6px; |
| | + font-size: 0.875rem; |
| | + color: #f0f6fc; |
| | + } |
| | + |
| | + .current-repo strong { |
| | + color: #58a6ff; |
| | + } |
| | + |
| | + .dir-icon, .file-icon { |
| | + display: inline-block; |
| | + width: 20px; |
| | + text-align: center; |
| | + margin-right: 8px; |
| | + color: #8b949e; |
| | + } |
| | + |
| | + .branch-badge { |
| | + background: #238636; |
| | + color: white; |
| | + padding: 2px 8px; |
| | + border-radius: 12px; |
| | + font-size: 0.75rem; |
| | + font-weight: 600; |
| | + margin-left: 10px; |
| | + } |
| | +</style> |
| | +</head> |
| | +<body> |
| | + <div class="container"> |
| | + <header> |
| | + <h1><?php echo SITE_TITLE; ?></h1> |
| | + <nav class="nav"> |
| | + <a href="?">Home</a> |
| | + <?php if ($currentRepo): ?> |
| | + <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a> |
| | + <a href="?action=commits<?php echo $repoParam; ?>">Commits</a> |
| | + <a href="?action=refs<?php echo $repoParam; ?>">Branches</a> |
| | + <?php endif; ?> |
| | + <?php if ($currentRepo): ?> |
| | + <div class="repo-selector"> |
| | + <label>Repository:</label> |
| | + <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)"> |
| | + <option value="">Select repository...</option> |
| | + <?php foreach ($repositories as $repo): ?> |
| | + <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>> |
| | + <?php echo htmlspecialchars($repo['name']); ?> |
| | + </option> |
| | + <?php endforeach; ?> |
| | + </select> |
| | + </div> |
| | + <?php endif; ?> |
| | + </nav> |
| | + |
| | + <?php if ($currentRepo): ?> |
| | + <div style="margin-top: 15px;"> |
| | + <span class="current-repo"> |
| | + Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong> |
| | + </span> |
| | + </div> |
| | + <?php endif; ?> |
| | + </header> |
| | + |
| | + <?php |
| | + if (!$currentRepo) { |
| | + echo '<h2>Repositories</h2>'; |
| | + |
| | + if (empty($repositories)) { |
| | + echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>'; |
| | + } else { |
| | + echo '<div class="repo-grid">'; |
| | + foreach ($repositories as $repo) { |
| | + $mainBranch = getMainBranch($repo['path']); |
| | + $head = getHead($repo['path']); |
| | + |
| | + echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">'; |
| | + echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>'; |
| | + |
| | + if ($mainBranch) { |
| | + echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>'; |
| | + } |
| | + |
| | + $refs = listRefs($repo['path']); |
| | + $branchCount = count($refs['branches'] ?? []); |
| | + $tagCount = count($refs['tags'] ?? []); |
| | + |
| | + echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>'; |
| | + |
| | + if ($head) { |
| | + $obj = readGitObject($repo['path'], $head['hash']); |
| | + if ($obj && $obj['type'] === 'commit') { |
| | + $commit = parseCommit($obj['content']); |
| | + echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($head['hash'], 0, 7) . ' - ' . htmlspecialchars(substr(trim(explode("\n", $commit['message'])[0]), 0, 50)) . '</p>'; |
| | + } |
| | + } |
| | + echo '</a>'; |
| | + } |
| | + echo '</div>'; |
| | + } |
| | + } else { |
| | + $mainBranch = getMainBranch($currentRepo['path']); |
| | + |
| | + if (!$mainBranch) { |
| | + echo '<div class="empty-state">'; |
| | + echo '<h3>No branches found</h3>'; |
| | + echo '<p>This repository appears to be empty.</p>'; |
| | + echo '</div>'; |
| | + } else { |
| | + $obj = readGitObject($currentRepo['path'], $mainBranch['hash']); |
| | + |
| | + if (!$obj || $obj['type'] !== 'commit') { |
| | + echo '<div class="empty-state">Error reading commit</div>'; |
| | + } else { |
| | + $commit = parseCommit($obj['content']); |
| | + $treeHash = $commit['tree']; |
| | + |
| | + $viewHash = $hash ?: $treeHash; |
| | + $viewObj = readGitObject($currentRepo['path'], $viewHash); |
| | + |
| | + if ($viewObj && $viewObj['type'] === 'blob') { |
| | + echo '<div class="breadcrumb">'; |
| | + echo '<a href="?">Repositories</a>'; |
| | + echo '<span>/</span>'; |
| | + echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; |
| | + echo '<span>/</span>'; |
| | + echo '<span>File</span>'; |
| | + echo '</div>'; |
| | + |
| | + echo '<h2>' . substr($viewHash, 0, 7) . '</h2>'; |
| | + |
| | + echo '<div class="blob-content">'; |
| | + echo '<div class="blob-header">' . strlen($viewObj['content']) . ' bytes</div>'; |
| | + echo '<div class="blob-code">' . htmlspecialchars($viewObj['content']) . '</div>'; |
| | + echo '</div>'; |
| | + } else { |
| | + $treeObj = readGitObject($currentRepo['path'], $viewHash); |
| | + |
| | + if (!$treeObj || $treeObj['type'] !== 'tree') { |
| | + echo '<div class="empty-state">Directory not found</div>'; |
| | + } else { |
| | + $entries = parseTree($treeObj['content']); |
| | + |
| | + echo '<div class="breadcrumb">'; |
| | + echo '<a href="?">Repositories</a>'; |
| | + echo '<span>/</span>'; |
| | + echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>'; |
| | + if ($viewHash !== $treeHash) { |
| | + echo '<span>/</span>'; |
| | + echo '<span>Tree ' . substr($viewHash, 0, 7) . '</span>'; |
| | + } |
| | + echo '</div>'; |
| | + |
| | + echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>'; |
| | + |
| | + echo '<div class="commit-item" style="margin-bottom: 20px;">'; |
| | + echo '<div><a href="?action=commit&hash=' . $mainBranch['hash'] . $repoParam . '" class="commit-hash">' . substr($mainBranch['hash'], 0, 7) . '</a></div>'; |
| | + echo '<div class="commit-message">' . htmlspecialchars(trim($commit['message'])) . '</div>'; |
| | + echo '<div class="commit-meta">'; |
| | + echo '<span class="commit-author">' . htmlspecialchars(getAuthor($commit['author'])) . '</span>'; |
| | + echo ' committed on '; |
| | + echo '<span class="commit-date">' . formatDate($commit['committer']) . '</span>'; |
| | + echo '</div>'; |
| | + echo '</div>'; |
| | + |
| | + echo '<h3>Files</h3>'; |
| | + |
| | + if (empty($entries)) { |
| | + echo '<div class="empty-state">Empty directory</div>'; |
| | + } else { |
| | + echo '<div class="file-list">'; |
| | + foreach ($entries as $entry) { |
| | + $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]'; |
| | + // CRITICAL FIX: Always include hash so directories work |
| | + $url = '?repo=' . urlencode($currentRepo['safe_name']) . '&hash=' . $entry['hash']; |
| | + |
| | + echo '<a href="' . $url . '" class="file-item">'; |
| | + echo '<span class="file-mode">' . $entry['mode'] . '</span>'; |
| | + echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>'; |
| | + echo '</a>'; |
| | + } |
| | + echo '</div>'; |
| | + } |
| | + } |
| | + } |
| | + } |
| | + } |
| | + } |
| | + ?> |
| | +</div> |
| | +</body> |
| | +</html> |
| | |