Dave Jarvis' Repositories

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

Moves viewer into directory to avoid filename clash

Author Dave Jarvis <email>
Date 2026-02-08 15:19:14 GMT-0800
Commit ce9ae039d30b1372332e2184492d7f9a8b124c1d
Parent 19af2da
new/Git.php
+<?php
+class Git {
+ private string $path;
+ private string $objPath;
+
+ public function __construct(string $repoPath) {
+ $this->path = rtrim($repoPath, '/');
+ $this->objPath = $this->path . '/objects';
+ }
+
+ public function eachBranch(callable $callback): void {
+ $this->scanRefs('refs/heads', $callback);
+ }
+
+ public function eachTag(callable $callback): void {
+ $this->scanRefs('refs/tags', $callback);
+ }
+
+ public function walk(string $refOrSha, callable $callback): void {
+ $sha = $this->resolve($refOrSha);
+ if (!$sha) return;
+
+ $treeSha = $this->findTree($sha);
+ if (!$treeSha) return;
+
+ $data = $this->read($treeSha);
+ if (!$data) return;
+
+ $pos = 0;
+ while ($pos < strlen($data)) {
+ $space = strpos($data, ' ', $pos);
+ $null = strpos($data, "\0", $space);
+ if ($space === false || $null === false) break;
+
+ $mode = substr($data, $pos, $space - $pos);
+ $name = substr($data, $space + 1, $null - $space - 1);
+ $entrySha = bin2hex(substr($data, $null + 1, 20));
+
+ $callback((object)[
+ 'name' => $name,
+ 'sha' => $entrySha,
+ 'isDir' => ($mode === '40000'),
+ 'mode' => $mode
+ ]);
+
+ $pos = $null + 21;
+ }
+ }
+
+ public function history(string $refOrSha, int $limit, callable $callback): void {
+ $currentSha = $this->resolve($refOrSha);
+ $count = 0;
+
+ while ($currentSha && $count < $limit) {
+ $data = $this->read($currentSha);
+ if (!$data) break;
+
+ $message = (strpos($data, "\n\n") !== false) ? substr($data, strpos($data, "\n\n") + 2) : '';
+ preg_match('/^author (.*) <(.*)> (\d+)/m', $data, $auth);
+
+ $callback((object)[
+ 'sha' => $currentSha,
+ 'message' => trim($message),
+ 'author' => $auth[1] ?? 'Unknown',
+ 'email' => $auth[2] ?? '',
+ 'date' => isset($auth[3]) ? (int)$auth[3] : 0
+ ]);
+
+ // Move to parent commit
+ $currentSha = preg_match('/^parent ([0-9a-f]{40})$/m', $data, $m) ? $m[1] : null;
+ $count++;
+ }
+ }
+
+ public function stream(string $sha, callable $callback): void {
+ $data = $this->read($sha);
+ if ($data) $callback($data);
+ }
+
+ private function resolve(string $input): ?string {
+ if (preg_match('/^[0-9a-f]{40}$/', $input)) return $input;
+
+ if ($input === 'HEAD' && file_exists($h = "{$this->path}/HEAD")) {
+ $head = trim(file_get_contents($h));
+ return (strpos($head, 'ref: ') === 0) ? $this->resolve(substr($head, 5)) : $head;
+ }
+
+ foreach ([$input, "refs/heads/$input", "refs/tags/$input"] as $p) {
+ if (file_exists($f = "{$this->path}/$p")) return trim(file_get_contents($f));
+ }
+
+ if (file_exists($packed = "{$this->path}/packed-refs")) {
+ foreach (file($packed) as $line) {
+ if ($line[0] === '#' || $line[0] === '^') continue;
+ $parts = explode(' ', trim($line));
+ if (count($parts) < 2) continue;
+ if ($parts[1] === $input || $parts[1] === "refs/heads/$input" || $parts[1] === "refs/tags/$input") return $parts[0];
+ }
+ }
+ return null;
+ }
+
+ private function findTree(string $sha): ?string {
+ $data = $this->read($sha);
+ return ($data && preg_match('/^tree ([0-9a-f]{40})$/m', $data, $m)) ? $m[1] : null;
+ }
+
+ private function read(string $sha): ?string {
+ $loose = "{$this->objPath}/" . substr($sha, 0, 2) . "/" . substr($sha, 2);
+ if (file_exists($loose)) {
+ $inflated = @gzuncompress(file_get_contents($loose));
+ return $inflated ? explode("\0", $inflated, 2)[1] : null;
+ }
+ return $this->fromPack($sha);
+ }
+
+ private function scanRefs(string $prefix, callable $callback): void {
+ $dir = "{$this->path}/$prefix";
+ if (is_dir($dir)) {
+ foreach (array_diff(scandir($dir), ['.', '..']) as $f) {
+ $callback($f, trim(file_get_contents("$dir/$f")));
+ }
+ }
+ }
+
+ private function fromPack(string $sha): ?string {
+ $packs = glob("{$this->objPath}/pack/*.idx");
+ if (!$packs) return null;
+
+ foreach ($packs as $idxFile) {
+ $f = @fopen($idxFile, 'rb');
+ if (!$f) continue;
+
+ fseek($f, 8 + (hexdec(substr($sha, 0, 2)) * 4));
+ $count = unpack('N', fread($f, 4))[1];
+ fseek($f, 8 + (255 * 4));
+ $total = unpack('N', fread($f, 4))[1];
+
+ fseek($f, 8 + (256 * 4));
+ $idx = -1;
+ for ($i = 0; $i < $total; $i++) {
+ if (bin2hex(fread($f, 20)) === $sha) { $idx = $i; break; }
+ }
+ if ($idx === -1) { fclose($f); continue; }
+
+ fseek($f, 8 + (256 * 4) + ($total * 20) + ($total * 4) + ($idx * 4));
+ $offset = unpack('N', fread($f, 4))[1];
+ fclose($f);
+
+ $pf = @fopen(str_replace('.idx', '.pack', $idxFile), 'rb');
+ if (!$pf) continue;
+ fseek($pf, $offset);
+ $header = ord(fread($pf, 1));
+ while ($header & 128) { $header = ord(fread($pf, 1)); }
+
+ $data = @gzuncompress(fread($pf, 1024 * 512)); // 512KB buffer for safety
+ fclose($pf);
+ return $data ?: null;
+ }
+ return null;
+ }
+}
+
new/kimi-style.css
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ background: #0d1117;
+ color: #c9d1d9;
+ line-height: 1.6;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 20px;
+}
+
+header {
+ border-bottom: 1px solid #30363d;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+}
+
+h1 {
+ color: #f0f6fc;
+ font-size: 1.8rem;
+ margin-bottom: 10px;
+}
+
+h2 {
+ color: #f0f6fc;
+ font-size: 1.4rem;
+ margin: 20px 0 15px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #21262d;
+}
+
+h3 {
+ color: #f0f6fc;
+ font-size: 1.1rem;
+ margin: 15px 0 10px;
+}
+
+.nav {
+ margin-top: 10px;
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.nav a {
+ color: #58a6ff;
+ text-decoration: none;
+}
+
+.nav a:hover {
+ text-decoration: underline;
+}
+
+.repo-selector {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.repo-selector label {
+ color: #8b949e;
+ font-size: 0.875rem;
+}
+
+.repo-selector select {
+ background: #21262d;
+ color: #f0f6fc;
+ border: 1px solid #30363d;
+ padding: 6px 12px;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ cursor: pointer;
+}
+
+.repo-selector select:hover {
+ border-color: #58a6ff;
+}
+
+.commit-list {
+ list-style: none;
+}
+
+.commit-item {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 16px;
+ margin-bottom: 12px;
+ transition: border-color 0.2s;
+}
+
+.commit-item:hover {
+ border-color: #58a6ff;
+}
+
+.commit-hash {
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ font-size: 0.85rem;
+ color: #58a6ff;
+ text-decoration: none;
+}
+
+.commit-hash:hover {
+ text-decoration: underline;
+}
+
+.commit-meta {
+ font-size: 0.875rem;
+ color: #8b949e;
+ margin-top: 8px;
+}
+
+.commit-author {
+ color: #f0f6fc;
+ font-weight: 500;
+}
+
+.commit-date {
+ color: #8b949e;
+}
+
+.commit-message {
+ margin-top: 8px;
+ color: #c9d1d9;
+ white-space: pre-wrap;
+}
+
+.file-list {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.file-item {
+ display: flex;
+ align-items: center;
+ padding: 12px 16px;
+ border-bottom: 1px solid #21262d;
+ text-decoration: none;
+ color: #c9d1d9;
+ transition: background 0.2s;
+}
+
+.file-item:last-child {
+ border-bottom: none;
+}
+
+.file-item:hover {
+ background: #1f242c;
+}
+
+.file-mode {
+ font-family: monospace;
+ color: #8b949e;
+ width: 80px;
+ font-size: 0.875rem;
+}
+
+.file-name {
+ flex: 1;
+ color: #58a6ff;
+}
+
+.file-item:hover .file-name {
+ text-decoration: underline;
+}
+
+.breadcrumb {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 12px 16px;
+ margin-bottom: 20px;
+}
+
+.breadcrumb a {
+ color: #58a6ff;
+ text-decoration: none;
+}
+
+.breadcrumb a:hover {
+ text-decoration: underline;
+}
+
+.breadcrumb span {
+ color: #8b949e;
+ margin: 0 8px;
+}
+
+.blob-content {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.blob-header {
+ background: #21262d;
+ padding: 12px 16px;
+ border-bottom: 1px solid #30363d;
+ font-size: 0.875rem;
+ color: #8b949e;
+}
+
+.blob-code {
+ padding: 16px;
+ overflow-x: auto;
+ font-family: 'SFMono-Regular', Consolas, monospace;
+ font-size: 0.875rem;
+ line-height: 1.6;
+ white-space: pre;
+}
+
+.refs-list {
+ display: grid;
+ gap: 10px;
+}
+
+.ref-item {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.ref-type {
+ background: #238636;
+ color: white;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.ref-type.tag {
+ background: #8957e5;
+}
+
+.ref-name {
+ font-weight: 600;
+ color: #f0f6fc;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 60px 20px;
+ color: #8b949e;
+}
+
+.commit-details {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 6px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.commit-header {
+ margin-bottom: 20px;
+}
+
+.commit-title {
+ font-size: 1.25rem;
+ color: #f0f6fc;
+ margin-bottom: 10px;
+}
+
+.commit-info {
+ display: grid;
+ gap: 8px;
+ font-size: 0.875rem;
+}
+
+.commit-info-row {
+ display: flex;
+ gap: 10px;
+}
+
+.commit-info-label {
+ color: #8b949e;
+ width: 80px;
+ flex-shrink: 0;
+}
+
+.commit-info-value {
+ color: #c9d1d9;
+ font-family: monospace;
+}
+
+.parent-link {
+ color: #58a6ff;
+ text-decoration: none;
+}
+
+.parent-link:hover {
+ text-decoration: underline;
+}
+
+.repo-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: 16px;
+ margin-top: 20px;
+}
+
+.repo-card {
+ background: #161b22;
+ border: 1px solid #30363d;
+ border-radius: 8px;
+ padding: 20px;
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.2s, transform 0.1s;
+}
+
+.repo-card:hover {
+ border-color: #58a6ff;
+ transform: translateY(-2px);
+}
+
+.repo-card h3 {
+ color: #58a6ff;
+ margin-bottom: 8px;
+ font-size: 1.1rem;
+}
+
+.repo-card p {
+ color: #8b949e;
+ font-size: 0.875rem;
+ margin: 0;
+}
+
+.current-repo {
+ background: #21262d;
+ border: 1px solid #58a6ff;
+ padding: 8px 16px;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ color: #f0f6fc;
+}
+
+.current-repo strong {
+ color: #58a6ff;
+}
+
+.dir-icon, .file-icon {
+ display: inline-block;
+ width: 20px;
+ text-align: center;
+ margin-right: 8px;
+ color: #8b949e;
+}
+
+.branch-badge {
+ background: #238636;
+ color: white;
+ padding: 2px 8px;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ margin-left: 10px;
+}
+
new/kimi-viewer.php
+<?php
+/**
+ * SimpleGit - A secure multi-repository Git viewer
+ * No external dependencies, no shell execution, no uploads
+ */
+
+function getHomeDirectory() {
+ if (!empty($_SERVER['HOME'])) {
+ return $_SERVER['HOME'];
+ }
+
+ if (!empty(getenv('HOME'))) {
+ return getenv('HOME');
+ }
+
+ if (function_exists('posix_getpwuid') && function_exists('posix_getuid')) {
+ $userInfo = posix_getpwuid(posix_getuid());
+
+ if (!empty($userInfo['dir'])) {
+ return $userInfo['dir'];
+ }
+ }
+
+ return '';
+}
+
+define('REPOS_PATH', getHomeDirectory() . '/repos');
+define('SITE_TITLE', "Dave Jarvis' Repositories");
+
+ini_set('display_errors', 0);
+ini_set('log_errors', 1);
+ini_set('error_log', __DIR__ . '/error.log');
+
+function getRepositories() {
+ $repos = [];
+ if (!is_dir(REPOS_PATH)) {
+ return $repos;
+ }
+
+ foreach (glob(REPOS_PATH . '/*.git') as $path) {
+ if (is_dir($path)) {
+ $name = basename($path, '.git');
+ $displayName = urldecode($name);
+ $repos[$name] = [
+ 'path' => $path,
+ 'name' => $displayName,
+ 'safe_name' => $name
+ ];
+ }
+ }
+
+ uasort($repos, function($a, $b) {
+ return strcasecmp($a['name'], $b['name']);
+ });
+
+ return $repos;
+}
+
+function getCurrentRepo() {
+ $repos = getRepositories();
+ $requested = $_GET['repo'] ?? '';
+ $decodedRequested = urldecode($requested);
+
+ foreach ($repos as $key => $repo) {
+ if ($repo['safe_name'] === $requested || $repo['name'] === $decodedRequested) {
+ return $repo;
+ }
+ }
+
+ return null;
+}
+
+function sanitizePath($path) {
+ $path = str_replace(['..', '\\', "\0"], ['', '/', ''], $path);
+ return preg_replace('/[^a-zA-Z0-9_\-\.\/]/', '', $path);
+}
+
+function getObjectPath($repoPath, $hash) {
+ if (!preg_match('/^[a-f0-9]{40}$/', $hash)) {
+ return false;
+ }
+ $dir = substr($hash, 0, 2);
+ $file = substr($hash, 2);
+ return $repoPath . '/objects/' . $dir . '/' . $file;
+}
+
+function readPackedObject($repoPath, $hash) {
+ $packDir = $repoPath . '/objects/pack';
+ if (!is_dir($packDir)) {
+ return false;
+ }
+
+ foreach (glob($packDir . '/*.idx') as $idxFile) {
+ $result = readFromPackIndex($idxFile, $hash);
+ if ($result !== false) {
+ return $result;
+ }
+ }
+
+ return false;
+}
+
+function readFromPackIndex($idxFile, $hash) {
+ $packFile = str_replace('.idx', '.pack', $idxFile);
+ if (!file_exists($packFile)) {
+ return false;
+ }
+
+ $idx = fopen($idxFile, 'rb');
+ if (!$idx) return false;
+
+ $magic = fread($idx, 4);
+ $version = 0;
+
+ if ($magic === "\377tOc") {
+ $versionData = fread($idx, 4);
+ $version = unpack('N', $versionData)[1];
+ if ($version !== 2) {
+ fclose($idx);
+ return false;
+ }
+ } else {
+ fseek($idx, 0);
+ }
+
+ fseek($idx, 256 * 4 - 4);
+ $numObjects = unpack('N', fread($idx, 4))[1];
+
+ fseek($idx, 256 * 4);
+ $targetHash = hex2bin($hash);
+ $left = 0;
+ $right = $numObjects - 1;
+ $foundOffset = -1;
+
+ while ($left <= $right) {
+ $mid = (int)(($left + $right) / 2);
+ fseek($idx, 256 * 4 + $mid * 20);
+ $midHash = fread($idx, 20);
+
+ $cmp = strcmp($midHash, $targetHash);
+ if ($cmp === 0) {
+ fseek($idx, 256 * 4 + $numObjects * 20 + $mid * 4);
+ $offset = unpack('N', fread($idx, 4))[1];
+
+ if ($offset & 0x80000000) {
+ fseek($idx, 256 * 4 + $numObjects * 24 + ($offset & 0x7fffffff) * 8);
+ $offset = unpack('J', fread($idx, 8))[1];
+ }
+
+ $foundOffset = $offset;
+ break;
+ } elseif ($cmp < 0) {
+ $left = $mid + 1;
+ } else {
+ $right = $mid - 1;
+ }
+ }
+
+ fclose($idx);
+
+ if ($foundOffset < 0) {
+ return false;
+ }
+
+ return readPackObject($packFile, $foundOffset);
+}
+
+/**
+ * FIXED: Uses stream-aware decompression to avoid trailing data errors
+ */
+function uncompressGitData($handle) {
+ $inflator = inflate_init(ZLIB_ENCODING_ANY);
+ $output = '';
+ while (!feof($handle)) {
+ $chunk = fread($handle, 8192);
+ if ($chunk === false) break;
+ $output .= inflate_add($inflator, $chunk, PHP_ZLIB_FINISH_FLUSH);
+ if (inflate_get_status($inflator) === ZLIB_STREAM_END) break;
+ }
+ return $output;
+}
+
+function readPackObject($packFile, $offset) {
+ $pack = fopen($packFile, 'rb');
+ if (!$pack) return false;
+
+ fseek($pack, $offset);
+ $byte = ord(fread($pack, 1));
+ $type = ($byte >> 4) & 0x07;
+ $size = $byte & 0x0f;
+ $shift = 4;
+
+ while ($byte & 0x80) {
+ $byte = ord(fread($pack, 1));
+ $size |= ($byte & 0x7f) << $shift;
+ $shift += 7;
+ }
+
+ // Handle Offset Deltas (Type 6)
+ if ($type === 6) {
+ $byte = ord(fread($pack, 1));
+ $baseOffset = $byte & 0x7f;
+ while ($byte & 0x80) {
+ $byte = ord(fread($pack, 1));
+ $baseOffset = (($baseOffset + 1) << 7) | ($byte & 0x7f);
+ }
+ $deltaData = uncompressGitData($pack);
+ $baseObj = readPackObject($packFile, $offset - $baseOffset);
+ fclose($pack);
+ return [
+ 'type' => $baseObj['type'],
+ 'content' => applyGitDelta($baseObj['content'], $deltaData)
+ ];
+ }
+
+ // Standard Objects (Commit, Tree, Blob)
+ $uncompressed = uncompressGitData($pack);
+ fclose($pack);
+
+ $types = ['', 'commit', 'tree', 'blob', 'tag'];
+ return [
+ 'type' => $types[$type] ?? 'unknown',
+ 'content' => $uncompressed
+ ];
+}
+
+function applyGitDelta($base, $delta) {
+ $pos = 0;
+ $readVarInt = function() use (&$delta, &$pos) {
+ $res = 0; $shift = 0;
+ do {
+ $b = ord($delta[$pos++]);
+ $res |= ($b & 0x7f) << $shift;
+ $shift += 7;
+ } while ($b & 0x80);
+ return $res;
+ };
+
+ $baseSize = $readVarInt();
+ $targetSize = $readVarInt();
+ $res = '';
+
+ while ($pos < strlen($delta)) {
+ $opcode = ord($delta[$pos++]);
+ if ($opcode & 0x80) { // Copy from base
+ $off = 0; $sz = 0;
+ if ($opcode & 0x01) $off |= ord($delta[$pos++]);
+ if ($opcode & 0x02) $off |= ord($delta[$pos++] ) << 8;
+ if ($opcode & 0x04) $off |= ord($delta[$pos++] ) << 16;
+ if ($opcode & 0x08) $off |= ord($delta[$pos++] ) << 24;
+ if ($opcode & 0x10) $sz |= ord($delta[$pos++]);
+ if ($opcode & 0x20) $sz |= ord($delta[$pos++] ) << 8;
+ if ($opcode & 0x40) $sz |= ord($delta[$pos++] ) << 16;
+ if ($sz === 0) $sz = 0x10000;
+ $res .= substr($base, $off, $sz);
+ } else { // Insert new data
+ $res .= substr($delta, $pos, $opcode);
+ $pos += $opcode;
+ }
+ }
+ return $res;
+}
+
+function readGitObject($repoPath, $hash) {
+ $path = getObjectPath($repoPath, $hash);
+ if ($path && file_exists($path)) {
+ $content = @file_get_contents($path);
+ if ($content !== false) {
+ $decompressed = @gzuncompress($content);
+ if ($decompressed !== false) {
+ $nullPos = strpos($decompressed, "\0");
+ if ($nullPos !== false) {
+ $header = substr($decompressed, 0, $nullPos);
+ $parts = explode(' ', $header, 2);
+ return [
+ 'type' => $parts[0] ?? 'unknown',
+ 'size' => $parts[1] ?? 0,
+ 'content' => substr($decompressed, $nullPos + 1)
+ ];
+ }
+ }
+ }
+ }
+
+ return readPackedObject($repoPath, $hash);
+}
+
+function parseTree($content) {
+ $entries = [];
+ $offset = 0;
+ $len = strlen($content);
+
+ while ($offset < $len) {
+ $spacePos = strpos($content, ' ', $offset);
+ if ($spacePos === false) break;
+
+ $mode = substr($content, $offset, $spacePos - $offset);
+ $offset = $spacePos + 1;
+
+ $nullPos = strpos($content, "\0", $offset);
+ if ($nullPos === false) break;
+
+ $name = substr($content, $offset, $nullPos - $offset);
+ $offset = $nullPos + 1;
+
+ if ($offset + 20 > $len) break;
+ $hash = bin2hex(substr($content, $offset, 20));
+ $offset += 20;
+
+ $isTree = in_array($mode, ['040000', '40000', '160000']);
+
+ $entries[] = [
+ 'mode' => $mode,
+ 'name' => $name,
+ 'hash' => $hash,
+ 'type' => $isTree ? 'tree' : 'blob'
+ ];
+ }
+
+ usort($entries, function($a, $b) {
+ if ($a['type'] !== $b['type']) {
+ return $a['type'] === 'tree' ? -1 : 1;
+ }
+ return strcasecmp($a['name'], $b['name']);
+ });
+
+ return $entries;
+}
+
+function parseCommit($content) {
+ $lines = explode("\n", $content);
+ $commit = [
+ 'tree' => '',
+ 'parents' => [],
+ 'author' => '',
+ 'committer' => '',
+ 'message' => ''
+ ];
+
+ $inMessage = false;
+ $messageLines = [];
+
+ foreach ($lines as $line) {
+ if ($inMessage) {
+ $messageLines[] = $line;
+ } elseif ($line === '') {
+ $inMessage = true;
+ } elseif (strpos($line, 'tree ') === 0) {
+ $commit['tree'] = substr($line, 5);
+ } elseif (strpos($line, 'parent ') === 0) {
+ $commit['parents'][] = substr($line, 7);
+ } elseif (strpos($line, 'author ') === 0) {
+ $commit['author'] = substr($line, 7);
+ } elseif (strpos($line, 'committer ') === 0) {
+ $commit['committer'] = substr($line, 10);
+ }
+ }
+
+ $commit['message'] = implode("\n", $messageLines);
+ return $commit;
+}
+
+function getHead($repoPath) {
+ $headFile = $repoPath . '/HEAD';
+ if (!file_exists($headFile)) {
+ return false;
+ }
+
+ $content = trim(file_get_contents($headFile));
+
+ if (preg_match('/^[a-f0-9]{40}$/', $content)) {
+ return ['type' => 'detached', 'hash' => $content, 'ref' => null];
+ }
+
+ if (strpos($content, 'ref: ') === 0) {
+ $ref = substr($content, 5);
+ $refFile = $repoPath . '/' . $ref;
+ if (file_exists($refFile)) {
+ $hash = trim(file_get_contents($refFile));
+ return ['type' => 'ref', 'hash' => $hash, 'ref' => $ref];
+ }
+ }
+
+ return false;
+}
+
+function listRefs($repoPath) {
+ $refs = [];
+
+ $branchesDir = $repoPath . '/refs/heads';
+ if (is_dir($branchesDir)) {
+ foreach (glob($branchesDir . '/*') as $file) {
+ if (is_file($file)) {
+ $name = basename($file);
+ $hash = trim(file_get_contents($file));
+ $refs['branches'][$name] = $hash;
+ }
+ }
+ }
+
+ $tagsDir = $repoPath . '/refs/tags';
+ if (is_dir($tagsDir)) {
+ foreach (glob($tagsDir . '/*') as $file) {
+ if (is_file($file)) {
+ $name = basename($file);
+ $content = file_get_contents($file);
+ $refs['tags'][$name] = trim($content);
+ }
+ }
+ }
+
+ return $refs;
+}
+
+function formatDate($line) {
+ if (preg_match('/(\d+)\s+([\+\-]\d{4})$/', $line, $matches)) {
+ $timestamp = $matches[1];
+ return date('Y-m-d H:i:s', $timestamp);
+ }
+ return 'Unknown';
+}
+
+function getAuthor($line) {
+ if (preg_match('/^([^<]+)/', $line, $matches)) {
+ return trim($matches[1]);
+ }
+ return $line;
+}
+
+function getLog($repoPath, $commitHash, $max = 20) {
+ $log = [];
+ $seen = [];
+ $queue = [$commitHash];
+
+ while (!empty($queue) && count($log) < $max) {
+ $hash = array_shift($queue);
+ if (isset($seen[$hash])) continue;
+ $seen[$hash] = true;
+
+ $obj = readGitObject($repoPath, $hash);
+ if (!$obj || $obj['type'] !== 'commit') continue;
+
+ $commit = parseCommit($obj['content']);
+ $commit['hash'] = $hash;
+ $log[] = $commit;
+
+ foreach ($commit['parents'] as $parent) {
+ $queue[] = $parent;
+ }
+ }
+
+ return $log;
+}
+
+function getMainBranch($repoPath) {
+ $refs = listRefs($repoPath);
+
+ $priority = ['main', 'master', 'trunk', 'develop'];
+ foreach ($priority as $branch) {
+ if (isset($refs['branches'][$branch])) {
+ return ['name' => $branch, 'hash' => $refs['branches'][$branch]];
+ }
+ }
+
+ if (!empty($refs['branches'])) {
+ $first = array_key_first($refs['branches']);
+ return ['name' => $first, 'hash' => $refs['branches'][$first]];
+ }
+
+ return null;
+}
+
+$action = $_GET['action'] ?? 'home';
+$hash = sanitizePath($_GET['hash'] ?? '');
+
+$currentRepo = getCurrentRepo();
+$repoParam = $currentRepo ? '&repo=' . urlencode($currentRepo['safe_name']) : '';
+
+$repositories = getRepositories();
+
+?>
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title><?php echo SITE_TITLE; ?><?php echo $currentRepo ? ' - ' . htmlspecialchars($currentRepo['name']) : ''; ?></title>
+ <link rel="stylesheet" href="kimi-style.css">
+</head>
+<body>
+ <div class="container">
+ <header>
+ <h1><?php echo SITE_TITLE; ?></h1>
+ <nav class="nav">
+ <a href="?">Home</a>
+ <?php if ($currentRepo): ?>
+ <a href="?repo=<?php echo urlencode($currentRepo['safe_name']); ?>">Files</a>
+ <a href="?action=commits<?php echo $repoParam; ?>">Commits</a>
+ <a href="?action=refs<?php echo $repoParam; ?>">Branches</a>
+ <?php endif; ?>
+ <?php if ($currentRepo): ?>
+ <div class="repo-selector">
+ <label>Repository:</label>
+ <select onchange="window.location.href='?repo=' + encodeURIComponent(this.value)">
+ <option value="">Select repository...</option>
+ <?php foreach ($repositories as $repo): ?>
+ <option value="<?php echo htmlspecialchars($repo['safe_name']); ?>" <?php echo $repo['safe_name'] === $currentRepo['safe_name'] ? 'selected' : ''; ?>>
+ <?php echo htmlspecialchars($repo['name']); ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <?php endif; ?>
+ </nav>
+
+ <?php if ($currentRepo): ?>
+ <div style="margin-top: 15px;">
+ <span class="current-repo">
+ Current: <strong><?php echo htmlspecialchars($currentRepo['name']); ?></strong>
+ </span>
+ </div>
+ <?php endif; ?>
+ </header>
+
+ <?php
+ if (!$currentRepo) {
+ echo '<h2>Repositories</h2>';
+
+ if (empty($repositories)) {
+ echo '<div class="empty-state">No repositories found in ' . htmlspecialchars(REPOS_PATH) . '</div>';
+ } else {
+ echo '<div class="repo-grid">';
+ foreach ($repositories as $repo) {
+ $mainBranch = getMainBranch($repo['path']);
+ $head = getHead($repo['path']);
+
+ echo '<a href="?repo=' . urlencode($repo['safe_name']) . '" class="repo-card">';
+ echo '<h3>' . htmlspecialchars($repo['name']) . '</h3>';
+
+ if ($mainBranch) {
+ echo '<p>Branch: ' . htmlspecialchars($mainBranch['name']) . '</p>';
+ }
+
+ $refs = listRefs($repo['path']);
+ $branchCount = count($refs['branches'] ?? []);
+ $tagCount = count($refs['tags'] ?? []);
+
+ echo '<p>' . $branchCount . ' branches, ' . $tagCount . ' tags</p>';
+
+ if ($head) {
+ $obj = readGitObject($repo['path'], $head['hash']);
+ if ($obj && $obj['type'] === 'commit') {
+ $commit = parseCommit($obj['content']);
+ echo '<p style="margin-top: 8px; color: #58a6ff;">' . substr($head['hash'], 0, 7) . ' - ' . htmlspecialchars(substr(trim(explode("\n", $commit['message'])[0]), 0, 50)) . '</p>';
+ }
+ }
+ echo '</a>';
+ }
+ echo '</div>';
+ }
+ } else {
+ $mainBranch = getMainBranch($currentRepo['path']);
+
+ if (!$mainBranch) {
+ echo '<div class="empty-state">';
+ echo '<h3>No branches found</h3>';
+ echo '<p>This repository appears to be empty.</p>';
+ echo '</div>';
+ } else {
+ $obj = readGitObject($currentRepo['path'], $mainBranch['hash']);
+
+ if (!$obj || $obj['type'] !== 'commit') {
+ echo '<div class="empty-state">Error reading commit</div>';
+ } else {
+ $commit = parseCommit($obj['content']);
+ $treeHash = $commit['tree'];
+
+ $viewHash = $hash ?: $treeHash;
+ $viewObj = readGitObject($currentRepo['path'], $viewHash);
+
+ if ($viewObj && $viewObj['type'] === 'blob') {
+ echo '<div class="breadcrumb">';
+ echo '<a href="?">Repositories</a>';
+ echo '<span>/</span>';
+ echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>';
+ echo '<span>/</span>';
+ echo '<span>File</span>';
+ echo '</div>';
+
+ echo '<h2>' . substr($viewHash, 0, 7) . '</h2>';
+
+ echo '<div class="blob-content">';
+ echo '<div class="blob-header">' . strlen($viewObj['content']) . ' bytes</div>';
+ echo '<div class="blob-code">' . htmlspecialchars($viewObj['content']) . '</div>';
+ echo '</div>';
+ } else {
+ $treeObj = readGitObject($currentRepo['path'], $viewHash);
+
+ if (!$treeObj || $treeObj['type'] !== 'tree') {
+ echo '<div class="empty-state">Directory not found</div>';
+ } else {
+ $entries = parseTree($treeObj['content']);
+
+ echo '<div class="breadcrumb">';
+ echo '<a href="?">Repositories</a>';
+ echo '<span>/</span>';
+ echo '<a href="?repo=' . urlencode($currentRepo['safe_name']) . '">' . htmlspecialchars($currentRepo['name']) . '</a>';
+ if ($viewHash !== $treeHash) {
+ echo '<span>/</span>';
+ echo '<span>Tree ' . substr($viewHash, 0, 7) . '</span>';
+ }
+ echo '</div>';
+
+ echo '<h2>' . htmlspecialchars($currentRepo['name']) . ' <span class="branch-badge">' . htmlspecialchars($mainBranch['name']) . '</span></h2>';
+
+ echo '<div class="commit-item" style="margin-bottom: 20px;">';
+ echo '<div><a href="?action=commit&hash=' . $mainBranch['hash'] . $repoParam . '" class="commit-hash">' . substr($mainBranch['hash'], 0, 7) . '</a></div>';
+ echo '<div class="commit-message">' . htmlspecialchars(trim($commit['message'])) . '</div>';
+ echo '<div class="commit-meta">';
+ echo '<span class="commit-author">' . htmlspecialchars(getAuthor($commit['author'])) . '</span>';
+ echo ' committed on ';
+ echo '<span class="commit-date">' . formatDate($commit['committer']) . '</span>';
+ echo '</div>';
+ echo '</div>';
+
+ echo '<h3>Files</h3>';
+
+ if (empty($entries)) {
+ echo '<div class="empty-state">Empty directory</div>';
+ } else {
+ echo '<div class="file-list">';
+ foreach ($entries as $entry) {
+ $icon = $entry['type'] === 'tree' ? '[dir]' : '[file]';
+ // CRITICAL FIX: Always include hash so directories work
+ $url = '?repo=' . urlencode($currentRepo['safe_name']) . '&hash=' . $entry['hash'];
+
+ echo '<a href="' . $url . '" class="file-item">';
+ echo '<span class="file-mode">' . $entry['mode'] . '</span>';
+ echo '<span class="file-name"><span class="' . ($entry['type'] === 'tree' ? 'dir-icon' : 'file-icon') . '">' . $icon . '</span> ' . htmlspecialchars($entry['name']) . '</span>';
+ echo '</a>';
+ }
+ echo '</div>';
+ }
+ }
+ }
+ }
+ }
+ }
+ ?>
+</div>
+</body>
+</html>
+
Delta 1194 lines added, 0 lines removed, 1194-line increase