| | <?php |
| | -function getHomeDirectory() |
| | -{ |
| | - if (!empty($_SERVER["HOME"])) { |
| | - return $_SERVER["HOME"]; |
| | - } |
| | - |
| | - if (!empty(getenv("HOME"))) { |
| | - return getenv("HOME"); |
| | - } |
| | - |
| | - if (function_exists("posix_getpwuid") && function_exists("posix_getuid")) { |
| | - $userInfo = posix_getpwuid(posix_getuid()); |
| | - |
| | - if (!empty($userInfo["dir"])) { |
| | - return $userInfo["dir"]; |
| | - } |
| | - } |
| | - |
| | - return ""; |
| | -} |
| | - |
| | -define("REPOS_PATH", getHomeDirectory() . "/repos"); |
| | -define("SITE_TITLE", "Dave Jarvis' Repositories"); |
| | - |
| | -ini_set("display_errors", 0); |
| | -ini_set("log_errors", 1); |
| | -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 $repos ? array_values($repos)[0] : 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", |
| | - ]; |
| | - } |
| | - |
| | - 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 $content; |
| | - } |
| | - |
| | - if (strpos($content, "ref: ") === 0) { |
| | - $ref = substr($content, 5); |
| | - $refFile = $repoPath . "/" . $ref; |
| | - if (file_exists($refFile)) { |
| | - return trim(file_get_contents($refFile)); |
| | - } |
| | - } |
| | - |
| | - 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; |
| | -} |
| | - |
| | -$action = $_GET["action"] ?? "home"; |
| | -$hash = sanitizePath($_GET["hash"] ?? ""); |
| | - |
| | -$currentRepo = getCurrentRepo(); |
| | -$repoParam = $currentRepo |
| | - ? "&repo=" . urlencode($currentRepo["safe_name"]) |
| | - : ""; |
| | - |
| | -if (!$currentRepo) { |
| | - die("Error: No repositories found in " . htmlspecialchars(REPOS_PATH)); |
| | -} |
| | - |
| | -if (!is_dir($currentRepo["path"])) { |
| | - die( |
| | - "Error: Repository not found at " . htmlspecialchars($currentRepo["path"]) |
| | - ); |
| | -} |
| | -?> |
| | -<!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 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; |
| | - } |
| | - </style> |
| | -</head> |
| | -<body> |
| | - <div class="container"> |
| | - <header> |
| | - <h1><?php echo SITE_TITLE; ?></h1> |
| | - <nav class="nav"> |
| | - <a href="?action=home<?php echo $repoParam; ?>">Home</a> |
| | - <a href="?action=repos">All Repositories</a> |
| | - <a href="?action=refs<?php echo $repoParam; ?>">Branches & Tags</a> |
| | - <a href="?action=log<?php echo $repoParam; ?>">Commit Log</a> |
| | - |
| | - <?php if ($currentRepo): ?> |
| | - <div class="repo-selector"> |
| | - <label>Repository:</label> |
| | - <select onchange="window.location.href='?action=home&repo=' + encodeURIComponent(this.value)"> |
| | - <?php foreach (getRepositories() 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 && $action !== "repos"): ?> |
| | - <div style="margin-top: 15px;"> |
| | - <span class="current-repo"> |
| | - Currently viewing: <strong><?php echo htmlspecialchars( |
| | - $currentRepo["name"], |
| | - ); ?></strong> |
| | - </span> |
| | - </div> |
| | - <?php endif; ?> |
| | - </header> |
| | - |
| | - <?php switch ($action) { |
| | - case "repos": |
| | - echo "<h2>All Repositories</h2>"; |
| | - $repos = getRepositories(); |
| | - |
| | - if (empty($repos)) { |
| | - echo '<div class="empty-state">No repositories found in ' . |
| | - htmlspecialchars(REPOS_PATH) . |
| | - "</div>"; |
| | - } else { |
| | - echo '<div class="repo-grid">'; |
| | - foreach ($repos as $repo) { |
| | - echo '<a href="?action=home&repo=' . |
| | - urlencode($repo["safe_name"]) . |
| | - '" class="repo-card">'; |
| | - echo "<h3>" . htmlspecialchars($repo["name"]) . "</h3>"; |
| | - |
| | - $head = getHead($repo["path"]); |
| | - $refs = listRefs($repo["path"]); |
| | - $branchCount = count($refs["branches"] ?? []); |
| | - $tagCount = count($refs["tags"] ?? []); |
| | - |
| | - echo "<p>" . |
| | - $branchCount . |
| | - " branches, " . |
| | - $tagCount . |
| | - " tags</p>"; |
| | - if ($head) { |
| | - echo '<p style="margin-top: 8px; font-family: monospace;">HEAD: ' . |
| | - substr($head, 0, 7) . |
| | - "</p>"; |
| | - } |
| | - echo "</a>"; |
| | - } |
| | - echo "</div>"; |
| | - } |
| | - break; |
| | - |
| | - case "refs": |
| | - echo "<h2>Branches & Tags - " . |
| | - htmlspecialchars($currentRepo["name"]) . |
| | - "</h2>"; |
| | - $refs = listRefs($currentRepo["path"]); |
| | - |
| | - if (empty($refs["branches"]) && empty($refs["tags"])) { |
| | - echo '<div class="empty-state">No refs found</div>'; |
| | - } else { |
| | - echo '<div class="refs-list">'; |
| | - |
| | - if (!empty($refs["branches"])) { |
| | - foreach ($refs["branches"] as $name => $hash) { |
| | - echo '<div class="ref-item">'; |
| | - echo '<span class="ref-type">Branch</span>'; |
| | - echo '<a href="?action=commit&hash=' . |
| | - $hash . |
| | - $repoParam . |
| | - '" class="ref-name">' . |
| | - htmlspecialchars($name) . |
| | - "</a>"; |
| | - echo '<code style="color: #8b949e; font-size: 0.875rem;">' . |
| | - substr($hash, 0, 7) . |
| | - "</code>"; |
| | - echo "</div>"; |
| | - } |
| | - } |
| | - |
| | - if (!empty($refs["tags"])) { |
| | - foreach ($refs["tags"] as $name => $hash) { |
| | - echo '<div class="ref-item">'; |
| | - echo '<span class="ref-type tag">Tag</span>'; |
| | - echo '<a href="?action=commit&hash=' . |
| | - $hash . |
| | - $repoParam . |
| | - '" class="ref-name">' . |
| | - htmlspecialchars($name) . |
| | - "</a>"; |
| | - echo '<code style="color: #8b949e; font-size: 0.875rem;">' . |
| | - substr($hash, 0, 7) . |
| | - "</code>"; |
| | - echo "</div>"; |
| | - } |
| | - } |
| | - |
| | - echo "</div>"; |
| | - } |
| | - break; |
| | - |
| | - case "log": |
| | - $head = getHead($currentRepo["path"]); |
| | - if (!$head) { |
| | - echo '<div class="empty-state">No commits found</div>'; |
| | - break; |
| | - } |
| | - |
| | - echo "<h2>Commit History - " . |
| | - htmlspecialchars($currentRepo["name"]) . |
| | - "</h2>"; |
| | - $log = getLog($currentRepo["path"], $head); |
| | - |
| | - if (empty($log)) { |
| | - echo '<div class="empty-state">No commits found</div>'; |
| | - } else { |
| | - echo '<ul class="commit-list">'; |
| | - foreach ($log as $commit) { |
| | - echo '<li class="commit-item">'; |
| | - echo "<div>"; |
| | - echo '<a href="?action=commit&hash=' . |
| | - $commit["hash"] . |
| | - $repoParam . |
| | - '" class="commit-hash">' . |
| | - substr($commit["hash"], 0, 7) . |
| | - "</a>"; |
| | - echo "</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 "</li>"; |
| | - } |
| | - echo "</ul>"; |
| | - } |
| | - break; |
| | - |
| | - case "commit": |
| | - if (!$hash || strlen($hash) !== 40) { |
| | - echo '<div class="empty-state">Invalid commit hash</div>'; |
| | - break; |
| | - } |
| | - |
| | - $obj = readGitObject($currentRepo["path"], $hash); |
| | - if (!$obj || $obj["type"] !== "commit") { |
| | - echo '<div class="empty-state">Commit not found</div>'; |
| | - break; |
| | - } |
| | - |
| | - $commit = parseCommit($obj["content"]); |
| | - $commit["hash"] = $hash; |
| | - |
| | - echo '<div class="commit-details">'; |
| | - echo '<div class="commit-header">'; |
| | - echo '<div class="commit-title">' . |
| | - htmlspecialchars(trim(explode("\n", $commit["message"])[0])) . |
| | - "</div>"; |
| | - echo "</div>"; |
| | - |
| | - echo '<div class="commit-info">'; |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Commit</span>'; |
| | - echo '<span class="commit-info-value">' . $hash . "</span>"; |
| | - echo "</div>"; |
| | - |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Tree</span>'; |
| | - echo '<span class="commit-info-value"><a href="?action=tree&hash=' . |
| | - $commit["tree"] . |
| | - $repoParam . |
| | - '" class="parent-link">' . |
| | - $commit["tree"] . |
| | - "</a></span>"; |
| | - echo "</div>"; |
| | - |
| | - if (!empty($commit["parents"])) { |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Parent' . |
| | - (count($commit["parents"]) > 1 ? "s" : "") . |
| | - "</span>"; |
| | - echo '<span class="commit-info-value">'; |
| | - foreach ($commit["parents"] as $i => $parent) { |
| | - if ($i > 0) { |
| | - echo ", "; |
| | - } |
| | - echo '<a href="?action=commit&hash=' . |
| | - $parent . |
| | - $repoParam . |
| | - '" class="parent-link">' . |
| | - $parent . |
| | - "</a>"; |
| | - } |
| | - echo "</span>"; |
| | - echo "</div>"; |
| | - } |
| | - |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Author</span>'; |
| | - echo '<span class="commit-info-value">' . |
| | - htmlspecialchars($commit["author"]) . |
| | - "</span>"; |
| | - echo "</div>"; |
| | - |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Committer</span>'; |
| | - echo '<span class="commit-info-value">' . |
| | - htmlspecialchars($commit["committer"]) . |
| | - "</span>"; |
| | - echo "</div>"; |
| | - echo "</div>"; |
| | - |
| | - echo "</div>"; |
| | - |
| | - echo "<h3>Files at this commit</h3>"; |
| | - echo '<div class="file-list">'; |
| | - echo '<a href="?action=tree&hash=' . |
| | - $commit["tree"] . |
| | - $repoParam . |
| | - '" class="file-item">'; |
| | - echo '<span class="file-mode">040000</span>'; |
| | - echo '<span class="file-name"><span class="dir-icon">[dir]</span> View Root Tree</span>'; |
| | - echo "</a>"; |
| | - echo "</div>"; |
| | - break; |
| | - |
| | - case "tree": |
| | - if (!$hash || strlen($hash) !== 40) { |
| | - echo '<div class="empty-state">Invalid tree hash</div>'; |
| | - break; |
| | - } |
| | - |
| | - $obj = readGitObject($currentRepo["path"], $hash); |
| | - if (!$obj || $obj["type"] !== "tree") { |
| | - echo '<div class="empty-state">Tree not found</div>'; |
| | - break; |
| | - } |
| | - |
| | - $entries = parseTree($obj["content"]); |
| | - |
| | - echo '<div class="breadcrumb">'; |
| | - echo '<a href="?action=home' . |
| | - $repoParam . |
| | - '">' . |
| | - htmlspecialchars($currentRepo["name"]) . |
| | - "</a>"; |
| | - echo "<span>/</span>"; |
| | - echo '<a href="?action=tree&hash=' . |
| | - $hash . |
| | - $repoParam . |
| | - '">Tree ' . |
| | - substr($hash, 0, 7) . |
| | - "</a>"; |
| | - echo "</div>"; |
| | - |
| | - echo "<h2>Tree " . substr($hash, 0, 7) . "</h2>"; |
| | - |
| | - 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" ? "tree" : "blob"; |
| | - |
| | - echo '<a href="?action=' . |
| | - $action . |
| | - "&hash=" . |
| | - $entry["hash"] . |
| | - $repoParam . |
| | - '" 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>"; |
| | - } |
| | - break; |
| | - |
| | - case "blob": |
| | - if (!$hash || strlen($hash) !== 40) { |
| | - echo '<div class="empty-state">Invalid blob hash</div>'; |
| | - break; |
| | - } |
| | - |
| | - $obj = readGitObject($currentRepo["path"], $hash); |
| | - if (!$obj || $obj["type"] !== "blob") { |
| | - echo '<div class="empty-state">File not found</div>'; |
| | - break; |
| | - } |
| | - |
| | - echo '<div class="breadcrumb">'; |
| | - echo '<a href="?action=home' . |
| | - $repoParam . |
| | - '">' . |
| | - htmlspecialchars($currentRepo["name"]) . |
| | - "</a>"; |
| | - echo "<span>/</span>"; |
| | - echo "<span>Blob " . substr($hash, 0, 7) . "</span>"; |
| | - echo "</div>"; |
| | - |
| | - echo "<h2>File " . substr($hash, 0, 7) . "</h2>"; |
| | - |
| | - echo '<div class="blob-content">'; |
| | - echo '<div class="blob-header">' . |
| | - strlen($obj["content"]) . |
| | - " bytes</div>"; |
| | - echo '<div class="blob-code">' . |
| | - htmlspecialchars($obj["content"]) . |
| | - "</div>"; |
| | - echo "</div>"; |
| | - break; |
| | - |
| | - case "home": |
| | - default: |
| | - $head = getHead($currentRepo["path"]); |
| | - |
| | - if (!$head) { |
| | - echo '<div class="empty-state">'; |
| | - echo "<h3>Empty repository or no commits</h3>"; |
| | - echo "<p>Repository: " . |
| | - htmlspecialchars($currentRepo["name"]) . |
| | - "</p>"; |
| | - echo "</div>"; |
| | - break; |
| | - } |
| | - |
| | - echo "<h2>" . htmlspecialchars($currentRepo["name"]) . "</h2>"; |
| | - |
| | - $obj = readGitObject($currentRepo["path"], $head); |
| | - |
| | - if ($obj && $obj["type"] === "commit") { |
| | - $commit = parseCommit($obj["content"]); |
| | - $commit["hash"] = $head; |
| | - |
| | - echo "<h3>Latest Commit</h3>"; |
| | - echo '<div class="commit-item">'; |
| | - echo "<div>"; |
| | - echo '<a href="?action=commit&hash=' . |
| | - $head . |
| | - $repoParam . |
| | - '" class="commit-hash">' . |
| | - substr($head, 0, 7) . |
| | - "</a>"; |
| | - echo "</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>Repository Stats</h3>"; |
| | - $refs = listRefs($currentRepo["path"]); |
| | - $branchCount = count($refs["branches"] ?? []); |
| | - $tagCount = count($refs["tags"] ?? []); |
| | - |
| | - echo '<div class="commit-details">'; |
| | - echo '<div class="commit-info">'; |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Branches</span>'; |
| | - echo '<span class="commit-info-value"><a href="?action=refs' . |
| | - $repoParam . |
| | - '" class="parent-link">' . |
| | - $branchCount . |
| | - "</a></span>"; |
| | - echo "</div>"; |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">Tags</span>'; |
| | - echo '<span class="commit-info-value"><a href="?action=refs' . |
| | - $repoParam . |
| | - '" class="parent-link">' . |
| | - $tagCount . |
| | - "</a></span>"; |
| | - echo "</div>"; |
| | - echo '<div class="commit-info-row">'; |
| | - echo '<span class="commit-info-label">HEAD</span>'; |
| | - echo '<span class="commit-info-value">' . |
| | - substr($head, 0, 7) . |
| | - "</span>"; |
| | - echo "</div>"; |
| | - echo "</div>"; |
| | - echo "</div>"; |
| | - |
| | - echo "<h3>Quick Actions</h3>"; |
| | - echo '<div class="file-list">'; |
| | - echo '<a href="?action=log' . $repoParam . '" class="file-item">'; |
| | - echo '<span class="file-mode">>></span>'; |
| | - echo '<span class="file-name">View Full Commit Log</span>'; |
| | - echo "</a>"; |
| | - echo '<a href="?action=tree&hash=' . |
| | - $commit["tree"] . |
| | - $repoParam . |
| | - '" class="file-item">'; |
| | - echo '<span class="file-mode">>></span>'; |
| | - echo '<span class="file-name">Browse Files</span>'; |
| | - echo "</a>"; |
| | - echo "</div>"; |
| | - } |
| | - break; |
| | - } ?> |
| | +/** |
| | + * SimpleGit - A secure multi-repository Git viewer |
| | + * No external dependencies, no shell execution, no uploads |
| | + */ |
| | + |
| | +function getHomeDirectory() { |
| | + if (!empty($_SERVER['HOME'])) { |
| | + return $_SERVER['HOME']; |
| | + } |
| | + |
| | + if (!empty(getenv('HOME'))) { |
| | + return getenv('HOME'); |
| | + } |
| | + |
| | + if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) { |
| | + $userInfo = posix_getpwuid(posix_getuid()); |
| | + |
| | + if (!empty($userInfo['dir'])) { |
| | + return $userInfo['dir']; |
| | + } |
| | + } |
| | + |
| | + return ''; |
| | +} |
| | + |
| | +define('REPOS_PATH', getHomeDirectory() . '/repos'); |
| | +define('SITE_TITLE', "Dave Jarvis' Repositories"); |
| | + |
| | +ini_set('display_errors', 0); |
| | +ini_set('log_errors', 1); |
| | +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> |