Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git

Adds alternate viewers

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
claude-viewer.php
+<?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();
kimi-viewer.php
+<?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">&gt;&gt;</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">&gt;&gt;</span>';
+ echo '<span class="file-name">Browse Files</span>';
+ echo "</a>";
+ echo "</div>";
+ }
+ break;
+ } ?>
+ </div>
+</body>
+</html>
+
viewer.php
-<?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>