| Author | Dave Jarvis <email> |
|---|---|
| Date | 2026-02-08 13:16:17 GMT-0800 |
| Commit | 4504a79bdac12af3c6c9d4f108dd43cd29bdc979 |
| Parent | bfe80b2 |
| Delta | 2101 lines added, 554 lines removed, 1547-line increase |
|---|
| +<?php | ||
| +// Configuration | ||
| +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"); | ||
| + | ||
| +// Security: Sanitize path components | ||
| +function sanitizePath($path) | ||
| +{ | ||
| + // Remove any .. to prevent directory traversal | ||
| + $path = str_replace("..", "", $path); | ||
| + $path = str_replace("\0", "", $path); | ||
| + return trim($path, "/"); | ||
| +} | ||
| + | ||
| +// Security: Validate SHA-1 hash | ||
| +function isValidSha1($hash) | ||
| +{ | ||
| + return preg_match('/^[a-f0-9]{40}$/i', $hash) === 1; | ||
| +} | ||
| + | ||
| +// Security: Validate repository name | ||
| +function isValidRepoName($name) | ||
| +{ | ||
| + return preg_match('/^[a-zA-Z0-9._-]+\.git$/i', $name) === 1; | ||
| +} | ||
| + | ||
| +// Git object reader | ||
| +class GitReader | ||
| +{ | ||
| + private $gitDir; | ||
| + | ||
| + public function __construct($repoPath) | ||
| + { | ||
| + $this->gitDir = $repoPath; | ||
| + } | ||
| + | ||
| + // Read a Git object by hash | ||
| + public function readObject($hash) | ||
| + { | ||
| + if (!isValidSha1($hash)) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Try loose object first | ||
| + $loosePath = | ||
| + $this->gitDir . | ||
| + "/objects/" . | ||
| + substr($hash, 0, 2) . | ||
| + "/" . | ||
| + substr($hash, 2); | ||
| + if (file_exists($loosePath)) { | ||
| + $compressed = file_get_contents($loosePath); | ||
| + $decompressed = @gzuncompress($compressed); | ||
| + if ($decompressed === false) { | ||
| + return null; | ||
| + } | ||
| + return $this->parseObject($decompressed); | ||
| + } | ||
| + | ||
| + // Try packed objects | ||
| + $packedObject = $this->readPackedObject($hash); | ||
| + if ($packedObject !== null) { | ||
| + return $packedObject; | ||
| + } | ||
| + | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Parse object header and content | ||
| + private function parseObject($data) | ||
| + { | ||
| + $nullPos = strpos($data, "\0"); | ||
| + if ($nullPos === false) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $header = substr($data, 0, $nullPos); | ||
| + $content = substr($data, $nullPos + 1); | ||
| + | ||
| + $parts = explode(" ", $header); | ||
| + if (count($parts) < 2) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $type = $parts[0]; | ||
| + $size = (int) $parts[1]; | ||
| + | ||
| + return [ | ||
| + "type" => $type, | ||
| + "size" => $size, | ||
| + "content" => $content, | ||
| + ]; | ||
| + } | ||
| + | ||
| + // Read packed objects (basic implementation for idx v2) | ||
| + private function readPackedObject($hash) | ||
| + { | ||
| + $packDir = $this->gitDir . "/objects/pack"; | ||
| + if (!is_dir($packDir)) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $idxFiles = glob($packDir . "/pack-*.idx"); | ||
| + foreach ($idxFiles as $idxFile) { | ||
| + $packFile = str_replace(".idx", ".pack", $idxFile); | ||
| + if (!file_exists($packFile)) { | ||
| + continue; | ||
| + } | ||
| + | ||
| + $offset = $this->findOffsetInIdx($idxFile, $hash); | ||
| + if ($offset !== null) { | ||
| + return $this->readObjectFromPack($packFile, $offset); | ||
| + } | ||
| + } | ||
| + | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Find object offset in .idx file (v2 format) | ||
| + private function findOffsetInIdx($idxFile, $hash) | ||
| + { | ||
| + $fp = fopen($idxFile, "rb"); | ||
| + if (!$fp) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Read magic and version | ||
| + $magic = fread($fp, 4); | ||
| + $version = unpack("N", fread($fp, 4))[1]; | ||
| + | ||
| + if ($magic !== "\xfftOc" || $version !== 2) { | ||
| + fclose($fp); | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Read fanout table | ||
| + $fanout = []; | ||
| + for ($i = 0; $i < 256; $i++) { | ||
| + $fanout[$i] = unpack("N", fread($fp, 4))[1]; | ||
| + } | ||
| + | ||
| + $totalObjects = $fanout[255]; | ||
| + $firstByte = hexdec(substr($hash, 0, 2)); | ||
| + $start = $firstByte > 0 ? $fanout[$firstByte - 1] : 0; | ||
| + $end = $fanout[$firstByte]; | ||
| + | ||
| + // Read SHA-1 table | ||
| + fseek($fp, 8 + 256 * 4); | ||
| + $shaTable = fread($fp, $totalObjects * 20); | ||
| + | ||
| + // Binary search for hash | ||
| + $hashBin = hex2bin($hash); | ||
| + for ($i = $start; $i < $end; $i++) { | ||
| + $sha = substr($shaTable, $i * 20, 20); | ||
| + if ($sha === $hashBin) { | ||
| + // Found it! Read offset from offset table | ||
| + fseek( | ||
| + $fp, | ||
| + 8 + 256 * 4 + $totalObjects * 20 + $totalObjects * 4 + $i * 4, | ||
| + ); | ||
| + $offset = unpack("N", fread($fp, 4))[1]; | ||
| + fclose($fp); | ||
| + return $offset; | ||
| + } | ||
| + } | ||
| + | ||
| + fclose($fp); | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Read object from .pack file | ||
| + private function readObjectFromPack($packFile, $offset) | ||
| + { | ||
| + $fp = fopen($packFile, "rb"); | ||
| + if (!$fp) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + fseek($fp, $offset); | ||
| + | ||
| + // Read object type and size | ||
| + $byte = ord(fread($fp, 1)); | ||
| + $type = ($byte >> 4) & 7; | ||
| + $size = $byte & 15; | ||
| + $shift = 4; | ||
| + | ||
| + while ($byte & 0x80) { | ||
| + $byte = ord(fread($fp, 1)); | ||
| + $size |= ($byte & 0x7f) << $shift; | ||
| + $shift += 7; | ||
| + } | ||
| + | ||
| + // Read compressed data | ||
| + $compressed = ""; | ||
| + while (!feof($fp)) { | ||
| + $chunk = fread($fp, 8192); | ||
| + $compressed .= $chunk; | ||
| + // Try to decompress | ||
| + $decompressed = @gzuncompress($compressed); | ||
| + if ($decompressed !== false && strlen($decompressed) >= $size) { | ||
| + fclose($fp); | ||
| + $typeMap = [1 => "commit", 2 => "tree", 3 => "blob", 4 => "tag"]; | ||
| + return [ | ||
| + "type" => $typeMap[$type] ?? "unknown", | ||
| + "size" => $size, | ||
| + "content" => substr($decompressed, 0, $size), | ||
| + ]; | ||
| + } | ||
| + } | ||
| + | ||
| + fclose($fp); | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Parse commit object | ||
| + public function parseCommit($content) | ||
| + { | ||
| + $lines = explode("\n", $content); | ||
| + $commit = [ | ||
| + "tree" => "", | ||
| + "parents" => [], | ||
| + "author" => "", | ||
| + "committer" => "", | ||
| + "message" => "", | ||
| + ]; | ||
| + | ||
| + $messageStarted = false; | ||
| + $message = []; | ||
| + | ||
| + foreach ($lines as $line) { | ||
| + if ($messageStarted) { | ||
| + $message[] = $line; | ||
| + } elseif (empty($line)) { | ||
| + $messageStarted = 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", $message); | ||
| + return $commit; | ||
| + } | ||
| + | ||
| + // Parse tree object | ||
| + public function parseTree($content) | ||
| + { | ||
| + $entries = []; | ||
| + $pos = 0; | ||
| + $len = strlen($content); | ||
| + | ||
| + while ($pos < $len) { | ||
| + // Read mode | ||
| + $spacePos = strpos($content, " ", $pos); | ||
| + if ($spacePos === false) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $mode = substr($content, $pos, $spacePos - $pos); | ||
| + $pos = $spacePos + 1; | ||
| + | ||
| + // Read name | ||
| + $nullPos = strpos($content, "\0", $pos); | ||
| + if ($nullPos === false) { | ||
| + break; | ||
| + } | ||
| + | ||
| + $name = substr($content, $pos, $nullPos - $pos); | ||
| + $pos = $nullPos + 1; | ||
| + | ||
| + // Read SHA-1 (20 bytes) | ||
| + if ($pos + 20 > $len) { | ||
| + break; | ||
| + } | ||
| + $sha = bin2hex(substr($content, $pos, 20)); | ||
| + $pos += 20; | ||
| + | ||
| + $entries[] = [ | ||
| + "mode" => $mode, | ||
| + "name" => $name, | ||
| + "sha" => $sha, | ||
| + "type" => | ||
| + substr($mode, 0, 2) === "04" || substr($mode, 0, 2) === "40" | ||
| + ? "tree" | ||
| + : "blob", | ||
| + ]; | ||
| + } | ||
| + | ||
| + return $entries; | ||
| + } | ||
| + | ||
| + // Get HEAD reference | ||
| + public function getHead() | ||
| + { | ||
| + $headFile = $this->gitDir . "/HEAD"; | ||
| + if (!file_exists($headFile)) { | ||
| + return null; | ||
| + } | ||
| + | ||
| + $content = trim(file_get_contents($headFile)); | ||
| + | ||
| + // If it's a symbolic ref | ||
| + if (strpos($content, "ref: ") === 0) { | ||
| + $refPath = substr($content, 5); | ||
| + $refFile = $this->gitDir . "/" . $refPath; | ||
| + if (file_exists($refFile)) { | ||
| + return trim(file_get_contents($refFile)); | ||
| + } | ||
| + return null; | ||
| + } | ||
| + | ||
| + // Direct hash | ||
| + return $content; | ||
| + } | ||
| + | ||
| + // Get all refs | ||
| + public function getRefs() | ||
| + { | ||
| + $refs = []; | ||
| + $refsDir = $this->gitDir . "/refs"; | ||
| + | ||
| + if (is_dir($refsDir)) { | ||
| + $this->scanRefs($refsDir, "", $refs); | ||
| + } | ||
| + | ||
| + return $refs; | ||
| + } | ||
| + | ||
| + private function scanRefs($dir, $prefix, &$refs) | ||
| + { | ||
| + $items = scandir($dir); | ||
| + foreach ($items as $item) { | ||
| + if ($item === "." || $item === "..") { | ||
| + continue; | ||
| + } | ||
| + | ||
| + $path = $dir . "/" . $item; | ||
| + $refName = $prefix . $item; | ||
| + | ||
| + if (is_dir($path)) { | ||
| + $this->scanRefs($path, $refName . "/", $refs); | ||
| + } elseif (is_file($path)) { | ||
| + $hash = trim(file_get_contents($path)); | ||
| + if (isValidSha1($hash)) { | ||
| + $refs[$refName] = $hash; | ||
| + } | ||
| + } | ||
| + } | ||
| + } | ||
| +} | ||
| + | ||
| +// Get list of repositories | ||
| +function getRepositories() | ||
| +{ | ||
| + if (!is_dir(REPOS_PATH)) { | ||
| + return []; | ||
| + } | ||
| + | ||
| + $repos = []; | ||
| + $items = scandir(REPOS_PATH); | ||
| + | ||
| + foreach ($items as $item) { | ||
| + if ($item === "." || $item === "..") { | ||
| + continue; | ||
| + } | ||
| + | ||
| + $path = REPOS_PATH . "/" . $item; | ||
| + if (is_dir($path) && isValidRepoName($item)) { | ||
| + $repos[] = $item; | ||
| + } | ||
| + } | ||
| + | ||
| + sort($repos); | ||
| + return $repos; | ||
| +} | ||
| + | ||
| +// Format date from Git timestamp | ||
| +function formatDate($gitDate) | ||
| +{ | ||
| + // Git date format: "Name <email> timestamp timezone" | ||
| + if (preg_match('/ (\d+) ([\+\-]\d{4})$/', $gitDate, $matches)) { | ||
| + $timestamp = (int) $matches[1]; | ||
| + return date("Y-m-d H:i:s", $timestamp); | ||
| + } | ||
| + return $gitDate; | ||
| +} | ||
| + | ||
| +// Extract author name from Git author line | ||
| +function extractAuthorName($gitAuthor) | ||
| +{ | ||
| + if (preg_match("/^(.+?) </", $gitAuthor, $matches)) { | ||
| + return htmlspecialchars($matches[1]); | ||
| + } | ||
| + return htmlspecialchars($gitAuthor); | ||
| +} | ||
| + | ||
| +// HTML helpers | ||
| +function h($str) | ||
| +{ | ||
| + return htmlspecialchars($str, ENT_QUOTES, "UTF-8"); | ||
| +} | ||
| + | ||
| +function pageHeader($title = "") | ||
| +{ | ||
| + $fullTitle = $title ? h($title) . " - " . h(SITE_TITLE) : h(SITE_TITLE); ?> | ||
| +<!DOCTYPE html> | ||
| +<html lang="en"> | ||
| +<head> | ||
| + <meta charset="UTF-8"> | ||
| + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| + <title><?= $fullTitle ?></title> | ||
| + <style> | ||
| + * { | ||
| + margin: 0; | ||
| + padding: 0; | ||
| + box-sizing: border-box; | ||
| + } | ||
| + | ||
| + body { | ||
| + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; | ||
| + background: #0d1117; | ||
| + color: #c9d1d9; | ||
| + line-height: 1.6; | ||
| + padding: 20px; | ||
| + } | ||
| + | ||
| + .container { | ||
| + max-width: 1200px; | ||
| + margin: 0 auto; | ||
| + } | ||
| + | ||
| + h1, h2, h3 { | ||
| + margin-bottom: 16px; | ||
| + color: #f0f6fc; | ||
| + } | ||
| + | ||
| + h1 { | ||
| + font-size: 32px; | ||
| + border-bottom: 1px solid #21262d; | ||
| + padding-bottom: 8px; | ||
| + margin-bottom: 20px; | ||
| + } | ||
| + | ||
| + h2 { | ||
| + font-size: 24px; | ||
| + margin-top: 24px; | ||
| + } | ||
| + | ||
| + a { | ||
| + color: #58a6ff; | ||
| + text-decoration: none; | ||
| + } | ||
| + | ||
| + a:hover { | ||
| + text-decoration: underline; | ||
| + } | ||
| + | ||
| + .repo-list { | ||
| + list-style: none; | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + overflow: hidden; | ||
| + } | ||
| + | ||
| + .repo-list li { | ||
| + border-bottom: 1px solid #21262d; | ||
| + padding: 16px; | ||
| + } | ||
| + | ||
| + .repo-list li:last-child { | ||
| + border-bottom: none; | ||
| + } | ||
| + | ||
| + .repo-list a { | ||
| + font-size: 18px; | ||
| + font-weight: 600; | ||
| + } | ||
| + | ||
| + .breadcrumb { | ||
| + margin-bottom: 16px; | ||
| + color: #8b949e; | ||
| + } | ||
| + | ||
| + .breadcrumb a { | ||
| + color: #58a6ff; | ||
| + } | ||
| + | ||
| + .breadcrumb span { | ||
| + margin: 0 8px; | ||
| + } | ||
| + | ||
| + .commit-list, .tree-list { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + overflow: hidden; | ||
| + } | ||
| + | ||
| + .commit-item, .tree-item { | ||
| + border-bottom: 1px solid #21262d; | ||
| + padding: 16px; | ||
| + } | ||
| + | ||
| + .commit-item:last-child, .tree-item:last-child { | ||
| + border-bottom: none; | ||
| + } | ||
| + | ||
| + .commit-message { | ||
| + font-size: 16px; | ||
| + font-weight: 600; | ||
| + margin-bottom: 8px; | ||
| + } | ||
| + | ||
| + .commit-meta { | ||
| + font-size: 14px; | ||
| + color: #8b949e; | ||
| + } | ||
| + | ||
| + .commit-hash { | ||
| + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| + font-size: 12px; | ||
| + color: #58a6ff; | ||
| + } | ||
| + | ||
| + .tree-item { | ||
| + display: flex; | ||
| + align-items: center; | ||
| + } | ||
| + | ||
| + .tree-icon { | ||
| + margin-right: 8px; | ||
| + color: #8b949e; | ||
| + } | ||
| + | ||
| + .tree-name { | ||
| + flex: 1; | ||
| + } | ||
| + | ||
| + .blob-content { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + padding: 16px; | ||
| + overflow-x: auto; | ||
| + } | ||
| + | ||
| + .blob-content pre { | ||
| + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| + font-size: 12px; | ||
| + line-height: 1.5; | ||
| + white-space: pre; | ||
| + } | ||
| + | ||
| + .refs-section { | ||
| + margin-bottom: 24px; | ||
| + } | ||
| + | ||
| + .refs-list { | ||
| + background: #161b22; | ||
| + border: 1px solid #30363d; | ||
| + border-radius: 6px; | ||
| + padding: 16px; | ||
| + } | ||
| + | ||
| + .ref-item { | ||
| + margin-bottom: 8px; | ||
| + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; | ||
| + font-size: 13px; | ||
| + } | ||
| + | ||
| + .ref-name { | ||
| + color: #8b949e; | ||
| + display: inline-block; | ||
| + min-width: 200px; | ||
| + } | ||
| + | ||
| + .error { | ||
| + background: #3d1f1f; | ||
| + border: 1px solid #6e3636; | ||
| + border-radius: 6px; | ||
| + padding: 16px; | ||
| + margin: 16px 0; | ||
| + color: #f85149; | ||
| + } | ||
| + | ||
| + .back-link { | ||
| + margin-bottom: 16px; | ||
| + display: inline-block; | ||
| + } | ||
| + </style> | ||
| +</head> | ||
| +<body> | ||
| + <div class="container"> | ||
| +<?php | ||
| +} | ||
| + | ||
| +function pageFooter() | ||
| +{ | ||
| + ?> | ||
| + </div> | ||
| +</body> | ||
| +</html> | ||
| +<?php | ||
| +} | ||
| + | ||
| +// Router | ||
| +$repo = isset($_GET["repo"]) ? sanitizePath($_GET["repo"]) : ""; | ||
| +$action = isset($_GET["action"]) ? $_GET["action"] : "index"; | ||
| +$hash = isset($_GET["hash"]) ? sanitizePath($_GET["hash"]) : ""; | ||
| +$path = isset($_GET["path"]) ? sanitizePath($_GET["path"]) : ""; | ||
| + | ||
| +// Index: List all repositories | ||
| +if ($action === "index" || empty($repo)) { | ||
| + pageHeader(); | ||
| + echo "<h1>" . h(SITE_TITLE) . "</h1>"; | ||
| + | ||
| + $repos = getRepositories(); | ||
| + | ||
| + if (empty($repos)) { | ||
| + echo "<p>No repositories found in " . h(REPOS_PATH) . "</p>"; | ||
| + } else { | ||
| + echo '<ul class="repo-list">'; | ||
| + foreach ($repos as $repoName) { | ||
| + $displayName = str_replace(".git", "", $repoName); | ||
| + echo '<li><a href="?repo=' . | ||
| + urlencode($repoName) . | ||
| + '&action=summary">' . | ||
| + h($displayName) . | ||
| + "</a></li>"; | ||
| + } | ||
| + echo "</ul>"; | ||
| + } | ||
| + | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +// Validate repository | ||
| +if (!isValidRepoName($repo)) { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Invalid repository name</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +$repoPath = REPOS_PATH . "/" . $repo; | ||
| +if (!is_dir($repoPath)) { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Repository not found</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +$git = new GitReader($repoPath); | ||
| +$displayName = str_replace(".git", "", $repo); | ||
| + | ||
| +// Repository summary | ||
| +if ($action === "summary") { | ||
| + pageHeader($displayName); | ||
| + | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a> <span>/</span> ' . h($displayName); | ||
| + echo "</div>"; | ||
| + | ||
| + echo "<h1>" . h($displayName) . "</h1>"; | ||
| + | ||
| + $head = $git->getHead(); | ||
| + $refs = $git->getRefs(); | ||
| + | ||
| + // Show branches and tags | ||
| + echo '<div class="refs-section">'; | ||
| + echo "<h2>Branches & Tags</h2>"; | ||
| + echo '<div class="refs-list">'; | ||
| + | ||
| + if (empty($refs)) { | ||
| + echo "<p>No refs found</p>"; | ||
| + } else { | ||
| + foreach ($refs as $refName => $refHash) { | ||
| + echo '<div class="ref-item">'; | ||
| + echo '<span class="ref-name">' . h($refName) . "</span> "; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + "&action=commit&hash=" . | ||
| + urlencode($refHash) . | ||
| + '">'; | ||
| + echo h(substr($refHash, 0, 8)) . "</a>"; | ||
| + echo "</div>"; | ||
| + } | ||
| + } | ||
| + | ||
| + echo "</div>"; | ||
| + echo "</div>"; | ||
| + | ||
| + // Show recent commits from HEAD | ||
| + if ($head && isValidSha1($head)) { | ||
| + echo "<h2>Recent Commits</h2>"; | ||
| + echo '<div class="commit-list">'; | ||
| + | ||
| + $commitHash = $head; | ||
| + $count = 0; | ||
| + $maxCommits = 20; | ||
| + | ||
| + while ($commitHash && $count < $maxCommits) { | ||
| + $obj = $git->readObject($commitHash); | ||
| + if (!$obj || $obj["type"] !== "commit") { | ||
| + break; | ||
| + } | ||
| + | ||
| + $commit = $git->parseCommit($obj["content"]); | ||
| + $message = explode("\n", trim($commit["message"]))[0]; | ||
| + | ||
| + echo '<div class="commit-item">'; | ||
| + echo '<div class="commit-message">'; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + "&action=commit&hash=" . | ||
| + urlencode($commitHash) . | ||
| + '">'; | ||
| + echo h($message); | ||
| + echo "</a>"; | ||
| + echo "</div>"; | ||
| + echo '<div class="commit-meta">'; | ||
| + echo extractAuthorName($commit["author"]) . " committed "; | ||
| + echo formatDate($commit["author"]); | ||
| + echo ' <span class="commit-hash">' . | ||
| + h(substr($commitHash, 0, 8)) . | ||
| + "</span>"; | ||
| + echo "</div>"; | ||
| + echo "</div>"; | ||
| + | ||
| + $commitHash = !empty($commit["parents"]) ? $commit["parents"][0] : null; | ||
| + $count++; | ||
| + } | ||
| + | ||
| + echo "</div>"; | ||
| + } | ||
| + | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +// View commit | ||
| +if ($action === "commit") { | ||
| + if (!isValidSha1($hash)) { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Invalid commit hash</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| + } | ||
| + | ||
| + $obj = $git->readObject($hash); | ||
| + if (!$obj || $obj["type"] !== "commit") { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Commit not found</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| + } | ||
| + | ||
| + $commit = $git->parseCommit($obj["content"]); | ||
| + | ||
| + pageHeader($displayName . " - Commit"); | ||
| + | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a> <span>/</span> '; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + '&action=summary">' . | ||
| + h($displayName) . | ||
| + "</a>"; | ||
| + echo " <span>/</span> Commit " . h(substr($hash, 0, 8)); | ||
| + echo "</div>"; | ||
| + | ||
| + echo "<h1>Commit " . h(substr($hash, 0, 8)) . "</h1>"; | ||
| + | ||
| + echo '<div class="blob-content">'; | ||
| + echo "<pre>"; | ||
| + echo "commit " . h($hash) . "\n"; | ||
| + echo "Author: " . h($commit["author"]) . "\n"; | ||
| + echo "Date: " . formatDate($commit["author"]) . "\n\n"; | ||
| + echo h($commit["message"]); | ||
| + echo "</pre>"; | ||
| + echo "</div>"; | ||
| + | ||
| + echo '<p style="margin-top: 16px;">'; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + "&action=tree&hash=" . | ||
| + urlencode($commit["tree"]) . | ||
| + '">Browse tree</a>'; | ||
| + echo "</p>"; | ||
| + | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +// View tree | ||
| +if ($action === "tree") { | ||
| + if (!isValidSha1($hash)) { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Invalid tree hash</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| + } | ||
| + | ||
| + $obj = $git->readObject($hash); | ||
| + if (!$obj || $obj["type"] !== "tree") { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Tree not found</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| + } | ||
| + | ||
| + $entries = $git->parseTree($obj["content"]); | ||
| + | ||
| + pageHeader($displayName . " - Tree"); | ||
| + | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a> <span>/</span> '; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + '&action=summary">' . | ||
| + h($displayName) . | ||
| + "</a>"; | ||
| + echo " <span>/</span> "; | ||
| + | ||
| + if (!empty($path)) { | ||
| + $pathParts = explode("/", $path); | ||
| + $currentPath = ""; | ||
| + foreach ($pathParts as $part) { | ||
| + $currentPath .= ($currentPath ? "/" : "") . $part; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + "&action=tree&hash=" . | ||
| + urlencode($hash) . | ||
| + "&path=" . | ||
| + urlencode($currentPath) . | ||
| + '">'; | ||
| + echo h($part) . "</a> <span>/</span> "; | ||
| + } | ||
| + } else { | ||
| + echo "Tree"; | ||
| + } | ||
| + | ||
| + echo "</div>"; | ||
| + | ||
| + echo "<h1>Tree " . h(substr($hash, 0, 8)) . "</h1>"; | ||
| + | ||
| + echo '<div class="tree-list">'; | ||
| + | ||
| + foreach ($entries as $entry) { | ||
| + echo '<div class="tree-item">'; | ||
| + | ||
| + if ($entry["type"] === "tree") { | ||
| + echo '<span class="tree-icon">📁</span>'; | ||
| + $newPath = $path ? $path . "/" . $entry["name"] : $entry["name"]; | ||
| + echo '<div class="tree-name">'; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + "&action=tree&hash=" . | ||
| + urlencode($entry["sha"]) . | ||
| + "&path=" . | ||
| + urlencode($newPath) . | ||
| + '">'; | ||
| + echo h($entry["name"]) . "/</a>"; | ||
| + echo "</div>"; | ||
| + } else { | ||
| + echo '<span class="tree-icon">📄</span>'; | ||
| + echo '<div class="tree-name">'; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + "&action=blob&hash=" . | ||
| + urlencode($entry["sha"]) . | ||
| + "&name=" . | ||
| + urlencode($entry["name"]) . | ||
| + '">'; | ||
| + echo h($entry["name"]) . "</a>"; | ||
| + echo "</div>"; | ||
| + } | ||
| + | ||
| + echo "</div>"; | ||
| + } | ||
| + | ||
| + echo "</div>"; | ||
| + | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +// View blob | ||
| +if ($action === "blob") { | ||
| + if (!isValidSha1($hash)) { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Invalid blob hash</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| + } | ||
| + | ||
| + $obj = $git->readObject($hash); | ||
| + if (!$obj || $obj["type"] !== "blob") { | ||
| + pageHeader("Error"); | ||
| + echo '<div class="error">Blob not found</div>'; | ||
| + pageFooter(); | ||
| + exit(); | ||
| + } | ||
| + | ||
| + $name = isset($_GET["name"]) ? sanitizePath($_GET["name"]) : "file"; | ||
| + | ||
| + pageHeader($displayName . " - " . $name); | ||
| + | ||
| + echo '<div class="breadcrumb">'; | ||
| + echo '<a href="?">Repositories</a> <span>/</span> '; | ||
| + echo '<a href="?repo=' . | ||
| + urlencode($repo) . | ||
| + '&action=summary">' . | ||
| + h($displayName) . | ||
| + "</a>"; | ||
| + echo " <span>/</span> " . h($name); | ||
| + echo "</div>"; | ||
| + | ||
| + echo "<h1>" . h($name) . "</h1>"; | ||
| + | ||
| + echo '<div class="blob-content">'; | ||
| + echo "<pre>" . h($obj["content"]) . "</pre>"; | ||
| + echo "</div>"; | ||
| + | ||
| + pageFooter(); | ||
| + exit(); | ||
| +} | ||
| + | ||
| +// Default: redirect to index | ||
| +header("Location: ?"); | ||
| +exit(); | ||
| +<?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; | ||
| + } ?> | ||
| + </div> | ||
| +</body> | ||
| +</html> | ||
| + | ||
| -<?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 ''; | ||
| -} | ||
| - | ||
| - | ||
| -ini_set('display_errors', 0); | ||
| -error_reporting(0); | ||
| - | ||
| -define('REPOS_PATH', getHomeDirectory() . '/repos'); | ||
| - | ||
| -if (!is_dir(REPO_PATH) || !is_file(REPO_PATH . '/HEAD')) { | ||
| - die('Invalid Git repository'); | ||
| -} | ||
| - | ||
| -function validateHash($hash) { | ||
| - return preg_match('/^[a-f0-9]{40}$/', $hash) === 1; | ||
| -} | ||
| - | ||
| -function validatePartialHash($hash) { | ||
| - return preg_match('/^[a-f0-9]{4,40}$/', $hash) === 1; | ||
| -} | ||
| - | ||
| -function e($str) { | ||
| - return htmlspecialchars($str, ENT_QUOTES, 'UTF-8'); | ||
| -} | ||
| - | ||
| -// Read Git object by SHA-1 hash | ||
| -function readGitObject($hash) { | ||
| - if (!validateHash($hash)) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $objectPath = REPO_PATH . '/objects/' . substr($hash, 0, 2) . '/' . substr($hash, 2); | ||
| - | ||
| - $realPath = realpath($objectPath); | ||
| - if ($realPath === false || strpos($realPath, realpath(REPO_PATH)) !== 0) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - if (!is_file($objectPath)) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $compressed = file_get_contents($objectPath); | ||
| - if ($compressed === false) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $data = @gzuncompress($compressed); | ||
| - if ($data === false) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - // Parse: "<type> <size>\0<content>" | ||
| - $nullPos = strpos($data, "\0"); | ||
| - if ($nullPos === false) { | ||
| - return null; | ||
| - } | ||
| - | ||
| - $header = substr($data, 0, $nullPos); | ||
| - $content = substr($data, $nullPos + 1); | ||
| - | ||
| - list($type, $size) = explode(' ', $header, 2); | ||
| - | ||
| - return [ | ||
| - 'type' => $type, | ||
| - 'size' => (int)$size, | ||
| - 'content' => $content | ||
| - ]; | ||
| -} | ||
| - | ||
| -// Parse commit object | ||
| -function parseCommit($content) { | ||
| - $lines = explode("\n", $content); | ||
| - $commit = [ | ||
| - 'tree' => '', | ||
| - 'parents' => [], | ||
| - 'author' => '', | ||
| - 'committer' => '', | ||
| - 'message' => '' | ||
| - ]; | ||
| - | ||
| - $messageStart = false; | ||
| - $message = []; | ||
| - | ||
| - foreach ($lines as $line) { | ||
| - if ($messageStart) { | ||
| - $message[] = $line; | ||
| - continue; | ||
| - } | ||
| - | ||
| - if ($line === '') { | ||
| - $messageStart = true; | ||
| - continue; | ||
| - } | ||
| - | ||
| - if (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", $message); | ||
| - return $commit; | ||
| -} | ||
| - | ||
| -// Parse tree object | ||
| -function parseTree($content) { | ||
| - $entries = []; | ||
| - $offset = 0; | ||
| - $length = strlen($content); | ||
| - | ||
| - while ($offset < $length) { | ||
| - $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; | ||
| - | ||
| - $hash = bin2hex(substr($content, $offset, 20)); | ||
| - $offset += 20; | ||
| - | ||
| - $entries[] = [ | ||
| - 'mode' => $mode, | ||
| - 'name' => $name, | ||
| - 'hash' => $hash, | ||
| - 'type' => (substr($mode, 0, 2) === '40') ? 'tree' : 'blob' | ||
| - ]; | ||
| - } | ||
| - | ||
| - return $entries; | ||
| -} | ||
| - | ||
| -// Get current HEAD reference | ||
| -function getHead() { | ||
| - $headFile = REPO_PATH . '/HEAD'; | ||
| - $head = trim(file_get_contents($headFile)); | ||
| - | ||
| - if (strpos($head, 'ref: ') === 0) { | ||
| - $ref = substr($head, 5); | ||
| - $refFile = REPO_PATH . '/' . $ref; | ||
| - if (is_file($refFile)) { | ||
| - return trim(file_get_contents($refFile)); | ||
| - } | ||
| - } | ||
| - | ||
| - return $head; | ||
| -} | ||
| - | ||
| -// Get all refs | ||
| -function getAllRefs() { | ||
| - $refs = []; | ||
| - $headsDir = REPO_PATH . '/refs/heads'; | ||
| - | ||
| - if (is_dir($headsDir)) { | ||
| - $files = scandir($headsDir); | ||
| - foreach ($files as $file) { | ||
| - if ($file === '.' || $file === '..') continue; | ||
| - $hash = trim(file_get_contents($headsDir . '/' . $file)); | ||
| - if (validateHash($hash)) { | ||
| - $refs[$file] = $hash; | ||
| - } | ||
| - } | ||
| - } | ||
| - | ||
| - return $refs; | ||
| -} | ||
| - | ||
| -// Walk commit history | ||
| -function getCommitHistory($startHash, $limit = 20) { | ||
| - $commits = []; | ||
| - $hash = $startHash; | ||
| - $seen = []; | ||
| - | ||
| - for ($i = 0; $i < $limit && $hash; $i++) { | ||
| - if (isset($seen[$hash])) break; | ||
| - $seen[$hash] = true; | ||
| - | ||
| - $object = readGitObject($hash); | ||
| - if (!$object || $object['type'] !== 'commit') break; | ||
| - | ||
| - $commit = parseCommit($object['content']); | ||
| - $commit['hash'] = $hash; | ||
| - $commits[] = $commit; | ||
| - | ||
| - $hash = isset($commit['parents'][0]) ? $commit['parents'][0] : null; | ||
| - } | ||
| - | ||
| - return $commits; | ||
| -} | ||
| - | ||
| -$allowedActions = ['log', 'tree', 'blob', 'refs']; | ||
| -$action = isset($_GET['action']) ? $_GET['action'] : 'log'; | ||
| -if (!in_array($action, $allowedActions, true)) { | ||
| - $action = 'log'; | ||
| -} | ||
| - | ||
| -$hash = isset($_GET['hash']) ? $_GET['hash'] : ''; | ||
| -if (!empty($hash) && !validatePartialHash($hash)) { | ||
| - $hash = ''; | ||
| -} | ||
| - | ||
| -// Expand partial hash to full hash if needed | ||
| -if (!empty($hash) && strlen($hash) < 40) { | ||
| - $prefix = substr($hash, 0, 2); | ||
| - $dir = REPO_PATH . '/objects/' . $prefix; | ||
| - if (is_dir($dir)) { | ||
| - $files = scandir($dir); | ||
| - foreach ($files as $file) { | ||
| - if ($file === '.' || $file === '..') continue; | ||
| - $fullHash = $prefix . $file; | ||
| - if (strpos($fullHash, $hash) === 0 && validateHash($fullHash)) { | ||
| - $hash = $fullHash; | ||
| - break; | ||
| - } | ||
| - } | ||
| - } | ||
| -} | ||
| - | ||
| -?> | ||
| -<!DOCTYPE html> | ||
| -<html lang="en"> | ||
| -<head> | ||
| - <meta charset="UTF-8"> | ||
| - <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||
| - <title>Git Repository Viewer</title> | ||
| - <style> | ||
| - * { margin: 0; padding: 0; box-sizing: border-box; } | ||
| - body { | ||
| - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | ||
| - line-height: 1.6; | ||
| - padding: 20px; | ||
| - background: #f5f5f5; | ||
| - } | ||
| - .container { | ||
| - max-width: 1200px; | ||
| - margin: 0 auto; | ||
| - background: white; | ||
| - padding: 30px; | ||
| - border-radius: 8px; | ||
| - box-shadow: 0 2px 10px rgba(0,0,0,0.1); | ||
| - } | ||
| - h1 { | ||
| - color: #333; | ||
| - margin-bottom: 20px; | ||
| - border-bottom: 3px solid #f14e32; | ||
| - padding-bottom: 10px; | ||
| - } | ||
| - h2 { color: #555; margin: 20px 0 10px; } | ||
| - nav { | ||
| - margin-bottom: 30px; | ||
| - padding: 15px; | ||
| - background: #f8f9fa; | ||
| - border-radius: 5px; | ||
| - } | ||
| - nav a { | ||
| - display: inline-block; | ||
| - padding: 8px 16px; | ||
| - margin-right: 10px; | ||
| - background: #f14e32; | ||
| - color: white; | ||
| - text-decoration: none; | ||
| - border-radius: 4px; | ||
| - font-size: 14px; | ||
| - } | ||
| - nav a:hover { background: #d43f2a; } | ||
| - nav a.active { background: #28a745; } | ||
| - .commit { | ||
| - padding: 15px; | ||
| - margin-bottom: 15px; | ||
| - background: #f8f9fa; | ||
| - border-left: 4px solid #f14e32; | ||
| - border-radius: 3px; | ||
| - } | ||
| - .hash { | ||
| - color: #f14e32; | ||
| - font-family: 'Courier New', monospace; | ||
| - font-weight: bold; | ||
| - font-size: 14px; | ||
| - } | ||
| - .hash a { | ||
| - color: #f14e32; | ||
| - text-decoration: none; | ||
| - } | ||
| - .hash a:hover { | ||
| - text-decoration: underline; | ||
| - } | ||
| - .message { | ||
| - margin: 8px 0; | ||
| - font-weight: 500; | ||
| - color: #333; | ||
| - } | ||
| - .meta { | ||
| - color: #666; | ||
| - font-size: 13px; | ||
| - margin-top: 5px; | ||
| - } | ||
| - .tree-entry { | ||
| - padding: 10px; | ||
| - border-bottom: 1px solid #eee; | ||
| - display: flex; | ||
| - align-items: center; | ||
| - } | ||
| - .tree-entry:hover { | ||
| - background: #f8f9fa; | ||
| - } | ||
| - .tree-entry a { | ||
| - color: #f14e32; | ||
| - text-decoration: none; | ||
| - flex: 1; | ||
| - } | ||
| - .tree-entry a:hover { | ||
| - text-decoration: underline; | ||
| - } | ||
| - .mode { | ||
| - font-family: 'Courier New', monospace; | ||
| - color: #666; | ||
| - margin-right: 15px; | ||
| - min-width: 60px; | ||
| - } | ||
| - .icon { | ||
| - margin-right: 8px; | ||
| - } | ||
| - pre { | ||
| - background: #272822; | ||
| - color: #f8f8f2; | ||
| - padding: 20px; | ||
| - border-radius: 5px; | ||
| - overflow-x: auto; | ||
| - font-size: 13px; | ||
| - line-height: 1.5; | ||
| - border: 1px solid #ddd; | ||
| - } | ||
| - .alert { | ||
| - padding: 15px; | ||
| - margin-bottom: 20px; | ||
| - border-radius: 5px; | ||
| - background: #fff3cd; | ||
| - border: 1px solid #ffc107; | ||
| - color: #856404; | ||
| - } | ||
| - .badge { | ||
| - display: inline-block; | ||
| - padding: 4px 8px; | ||
| - background: #007bff; | ||
| - color: white; | ||
| - border-radius: 3px; | ||
| - font-size: 12px; | ||
| - margin-left: 10px; | ||
| - } | ||
| - </style> | ||
| -</head> | ||
| -<body> | ||
| - <div class="container"> | ||
| - <h1>🔍 Git Repository Viewer</h1> | ||
| - <p style="color: #666; margin-bottom: 20px;">Pure PHP implementation - No external processes</p> | ||
| - | ||
| - <nav> | ||
| - <a href="?" class="<?= $action === 'log' ? 'active' : '' ?>">Commit Log</a> | ||
| - <a href="?action=refs" class="<?= $action === 'refs' ? 'active' : '' ?>">Branches</a> | ||
| - </nav> | ||
| - | ||
| - <?php | ||
| - switch ($action) { | ||
| - case 'log': | ||
| - echo '<h2>Recent Commits</h2>'; | ||
| - $head = getHead(); | ||
| - | ||
| - if (!validateHash($head)) { | ||
| - echo '<div class="alert">Invalid HEAD reference</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - $commits = getCommitHistory($head, 20); | ||
| - | ||
| - if (empty($commits)) { | ||
| - echo '<div class="alert">No commits found</div>'; | ||
| - } else { | ||
| - foreach ($commits as $commit) { | ||
| - echo '<div class="commit">'; | ||
| - echo '<div class="hash">'; | ||
| - echo '<a href="?action=tree&hash=' . e(substr($commit['hash'], 0, 7)) . '">'; | ||
| - echo e(substr($commit['hash'], 0, 7)); | ||
| - echo '</a>'; | ||
| - echo '</div>'; | ||
| - | ||
| - $messageLine = explode("\n", trim($commit['message']))[0]; | ||
| - echo '<div class="message">' . e($messageLine) . '</div>'; | ||
| - | ||
| - // Parse author | ||
| - if (preg_match('/^(.+?) <(.+?)> (\d+)/', $commit['author'], $matches)) { | ||
| - $author = $matches[1]; | ||
| - $timestamp = (int)$matches[3]; | ||
| - $date = date('Y-m-d H:i:s', $timestamp); | ||
| - echo '<div class="meta">👤 ' . e($author) . ' • 📅 ' . e($date) . '</div>'; | ||
| - } | ||
| - | ||
| - echo '</div>'; | ||
| - } | ||
| - } | ||
| - break; | ||
| - | ||
| - case 'tree': | ||
| - if (empty($hash)) { | ||
| - echo '<div class="alert">No hash specified</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - if (!validateHash($hash)) { | ||
| - echo '<div class="alert">Invalid hash</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - $object = readGitObject($hash); | ||
| - | ||
| - if (!$object) { | ||
| - echo '<div class="alert">Object not found</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - if ($object['type'] === 'commit') { | ||
| - $commit = parseCommit($object['content']); | ||
| - echo '<h2>Commit ' . e(substr($hash, 0, 7)) . '</h2>'; | ||
| - echo '<div class="commit">'; | ||
| - echo '<div class="message">' . e(trim(explode("\n", $commit['message'])[0])) . '</div>'; | ||
| - | ||
| - if (preg_match('/^(.+?) <(.+?)> (\d+)/', $commit['author'], $matches)) { | ||
| - $author = $matches[1]; | ||
| - $timestamp = (int)$matches[3]; | ||
| - $date = date('Y-m-d H:i:s', $timestamp); | ||
| - echo '<div class="meta">👤 ' . e($author) . ' • 📅 ' . e($date) . '</div>'; | ||
| - } | ||
| - | ||
| - echo '</div>'; | ||
| - | ||
| - // Show tree | ||
| - $hash = $commit['tree']; | ||
| - $object = readGitObject($hash); | ||
| - } | ||
| - | ||
| - if ($object && $object['type'] === 'tree') { | ||
| - echo '<h2>Tree <span class="hash">' . e(substr($hash, 0, 7)) . '</span></h2>'; | ||
| - $entries = parseTree($object['content']); | ||
| - | ||
| - if (empty($entries)) { | ||
| - echo '<div class="alert">Empty tree</div>'; | ||
| - } else { | ||
| - usort($entries, function($a, $b) { | ||
| - if ($a['type'] === $b['type']) { | ||
| - return strcmp($a['name'], $b['name']); | ||
| - } | ||
| - return $a['type'] === 'tree' ? -1 : 1; | ||
| - }); | ||
| - | ||
| - foreach ($entries as $entry) { | ||
| - echo '<div class="tree-entry">'; | ||
| - echo '<span class="mode">' . e($entry['mode']) . '</span>'; | ||
| - | ||
| - if ($entry['type'] === 'tree') { | ||
| - echo '<span class="icon">📁</span>'; | ||
| - echo '<a href="?action=tree&hash=' . e($entry['hash']) . '">'; | ||
| - echo e($entry['name']) . '/</a>'; | ||
| - } else { | ||
| - echo '<span class="icon">📄</span>'; | ||
| - echo '<a href="?action=blob&hash=' . e($entry['hash']) . '">'; | ||
| - echo e($entry['name']) . '</a>'; | ||
| - } | ||
| - | ||
| - echo '<span class="hash">' . e(substr($entry['hash'], 0, 7)) . '</span>'; | ||
| - echo '</div>'; | ||
| - } | ||
| - } | ||
| - } | ||
| - break; | ||
| - | ||
| - case 'blob': | ||
| - if (empty($hash)) { | ||
| - echo '<div class="alert">No hash specified</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - if (!validateHash($hash)) { | ||
| - echo '<div class="alert">Invalid hash</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - $object = readGitObject($hash); | ||
| - | ||
| - if (!$object || $object['type'] !== 'blob') { | ||
| - echo '<div class="alert">Blob not found</div>'; | ||
| - break; | ||
| - } | ||
| - | ||
| - echo '<h2>Blob <span class="hash">' . e(substr($hash, 0, 7)) . '</span>'; | ||
| - echo '<span class="badge">' . e($object['size']) . ' bytes</span></h2>'; | ||
| - | ||
| - // Check if binary | ||
| - if (strpos($object['content'], "\0") !== false) { | ||
| - echo '<div class="alert">Binary file (' . e($object['size']) . ' bytes)</div>'; | ||
| - } else { | ||
| - echo '<pre>' . e($object['content']) . '</pre>'; | ||
| - } | ||
| - break; | ||
| - | ||
| - case 'refs': | ||
| - echo '<h2>Branches</h2>'; | ||
| - $refs = getAllRefs(); | ||
| - | ||
| - if (empty($refs)) { | ||
| - echo '<div class="alert">No branches found</div>'; | ||
| - } else { | ||
| - foreach ($refs as $name => $hash) { | ||
| - echo '<div class="commit">'; | ||
| - echo '<strong>' . e($name) . '</strong><br>'; | ||
| - echo '<span class="hash">'; | ||
| - echo '<a href="?action=tree&hash=' . e($hash) . '">' . e(substr($hash, 0, 7)) . '</a>'; | ||
| - echo '</span>'; | ||
| - echo '</div>'; | ||
| - } | ||
| - } | ||
| - break; | ||
| - } | ||
| - ?> | ||
| - </div> | ||
| -</body> | ||
| -</html> | ||