A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
commit 91a8fb1237638d159a35b4180fa6f3a8dece57fb
Author: mrfox <jens.kristoffersson.se@gmail.com>
AuthorDate: Wed Aug 12 19:11:45 2026 +0200
Commit: mrfox <jens.kristoffersson.se@gmail.com>
CommitDate: Wed Aug 12 19:11:45 2026 +0200
lots of tweaks
---
assets/base.css | 6 ++--
inc/config.example.php | 1 +
inc/functions.php | 15 +++++---
index.php | 25 ++++++++++---
server/git-shell-commands/describe | 38 ++++++++++++++++++++
server/setup-server.sh | 4 ++-
views/help.php | 74 ++++++++++++++++++++++++++++++++++++++
views/partials/head.php | 11 +++++-
views/repo-index.php | 2 +-
9 files changed, 161 insertions(+), 15 deletions(-)
diff --git a/assets/base.css b/assets/base.css
index 3287f35..d0c7f18 100644
--- a/assets/base.css
+++ b/assets/base.css
@@ -42,7 +42,7 @@ header .path { color: var(--dim); display: inline-flex; align-items: center; gap
header .path a { color: var(--fg); font-weight: 600; }
header .spacer { flex: 1; }
-.theme-toggle {
+.icon-btn {
color: var(--dim);
border: 1px solid transparent;
border-radius: 6px;
@@ -50,7 +50,7 @@ header .spacer { flex: 1; }
display: inline-flex;
line-height: 0;
}
-.theme-toggle:hover { color: var(--fg); background: var(--bg); border-color: var(--line); text-decoration: none; }
+.icon-btn:hover { color: var(--fg); background: var(--bg); border-color: var(--line); text-decoration: none; }
main { padding: 24px 0 0; }
@@ -130,6 +130,7 @@ tr:hover td { background: var(--surface); }
.box td, .box th { padding-left: 16px; }
.box tr { border-top: 1px solid var(--line); }
.box tr:first-child { border-top: 0; }
+.box.about { padding: 14px 16px; color: var(--fg); }
.box thead th {
background: var(--surface);
border-bottom: 1px solid var(--line);
@@ -210,7 +211,6 @@ pre {
— keeps it a semantic table while looking like GitHub's repo list. */
#repo-list { display: block; }
-#repo-list thead { display: none; }
#repo-list tbody, #repo-list tr { display: block; }
#repo-list tr {
border: 1px solid var(--line);
diff --git a/inc/config.example.php b/inc/config.example.php
index 757815a..a282d6a 100644
--- a/inc/config.example.php
+++ b/inc/config.example.php
@@ -8,6 +8,7 @@ declare(strict_types=1);
const REPO_BASE = '/var/git/repos'; // directory holding your *.git bare repos
const LOG_COUNT = 50; // commits shown per log page
const SITE_NAME = 'foxygit';
+const SITE_DESCRIPTION = 'A personal git server.'; // shown in a box atop the repo list; '' hides it
const CLONE_BASE = 'https://git.example.com/repos/'; // shown as `clone: <CLONE_BASE><repo>.git`; leave '' to hide — matches the anonymous-clone route from server/setup-anon-clone.sh
const MAX_BLOB_BYTES = 1_000_000; // files bigger than this get a "too large" notice instead of being dumped inline
const MAX_DIFF_BYTES = 2_000_000; // commit diffs bigger than this get truncated
diff --git a/inc/functions.php b/inc/functions.php
index 044effb..a24c4fd 100644
--- a/inc/functions.php
+++ b/inc/functions.php
@@ -130,16 +130,23 @@ function repo_last_activity(string $path): int {
return max($t);
}
+/** The repo's description, straight from the standard git `description` file
+ * (the same one `git init` seeds with "Unnamed repository..." — that
+ * placeholder is treated as "no description set", not real content).
+ * Set one with `ssh git@host describe <repo> <text>` — see server/. */
+function repo_description(string $path): string {
+ $desc = @file_get_contents($path . '/description');
+ if ($desc === false || strpos($desc, 'Unnamed repository') !== false) return '';
+ return trim($desc);
+}
+
function list_repos(): array {
$out = [];
foreach (glob(REPO_BASE . '/*') ?: [] as $p) {
if (!is_dir($p)) continue;
if (git($p, ['rev-parse', '--git-dir']) === []) continue;
$name = basename($p);
- $desc = @file_get_contents($p . '/description');
- $desc = ($desc !== false && strpos($desc, 'Unnamed repository') === false)
- ? trim($desc) : '';
- $out[$name] = ['desc' => $desc, 'mtime' => repo_last_activity($p)];
+ $out[$name] = ['desc' => repo_description($p), 'mtime' => repo_last_activity($p)];
}
uasort($out, fn($a, $b) => $b['mtime'] <=> $a['mtime']);
return $out;
diff --git a/index.php b/index.php
index 76377c6..e4ebeb8 100644
--- a/index.php
+++ b/index.php
@@ -42,6 +42,13 @@ if (isset($_GET['theme']) && $_GET['theme'] === $theme) { // explicit, valid c
/* ----------------------------------------------------------------- views */
+if ($action === 'help') { // global page, works with or without ?r=
+ render('partials/head', ['title' => SITE_NAME . ' · help', 'theme' => $theme, 'repoName' => null]);
+ render('help');
+ render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
+ exit;
+}
+
if ($action === 'notfound') {
render('partials/head', ['title' => '404', 'theme' => $theme, 'repoName' => null]);
render('notfound');
@@ -50,7 +57,9 @@ if ($action === 'notfound') {
}
if ($repo === null) { // repo index
- render('partials/head', ['title' => SITE_NAME, 'theme' => $theme, 'repoName' => null]);
+ render('partials/head', [
+ 'title' => SITE_NAME, 'theme' => $theme, 'repoName' => null, 'description' => SITE_DESCRIPTION,
+ ]);
render('repo-index', ['repos' => list_repos()]);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
exit;
@@ -58,6 +67,8 @@ if ($repo === null) { // repo index
/* --- repo is valid & resolved from here on --- */
+$description = repo_description($repo);
+
if ($action === 'raw') { // raw/plain file download — no view, just bytes
$ref = $_GET['ref'] ?? 'HEAD';
$blob = $_GET['blob'] ?? '';
@@ -101,7 +112,8 @@ if ($action === 'atom' || $action === 'atom-tags') {
if ($action === 'commit') {
$hash = $_GET['h'] ?? '';
render('partials/head', [
- 'title' => repo_display_name($repoName) . ' · commit', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'log',
+ 'title' => repo_display_name($repoName) . ' · commit', 'theme' => $theme, 'repoName' => $repoName,
+ 'tab' => 'log', 'description' => $description,
]);
if (!preg_match('~^[0-9a-f]{4,64}$~i', $hash)) { // only hex object ids
@@ -127,7 +139,8 @@ if ($action === 'refs') {
"--format=%(refname:short)\x1f%(refname)", 'refs/heads', 'refs/tags']));
render('partials/head', [
- 'title' => repo_display_name($repoName) . ' · refs', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'refs',
+ 'title' => repo_display_name($repoName) . ' · refs', 'theme' => $theme, 'repoName' => $repoName,
+ 'tab' => 'refs', 'description' => $description,
]);
render('partials/repo-subnav', [
'repoName' => $repoName, 'curAction' => 'refs', 'curRef' => 'HEAD', 'branches' => [],
@@ -148,7 +161,8 @@ if ($action === 'log') {
$entries = parse_log_lines(array_slice($lines, 0, LOG_COUNT));
render('partials/head', [
- 'title' => repo_display_name($repoName) . ' · commits', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'log',
+ 'title' => repo_display_name($repoName) . ' · commits', 'theme' => $theme, 'repoName' => $repoName,
+ 'tab' => 'log', 'description' => $description,
]);
render('partials/repo-subnav', [
'repoName' => $repoName, 'curAction' => 'log', 'curRef' => $ref, 'branches' => list_branches($repo),
@@ -166,7 +180,8 @@ $ref = $_GET['ref'] ?? 'HEAD';
if (!safe_ref($ref)) $ref = 'HEAD';
render('partials/head', [
- 'title' => repo_display_name($repoName) . ' · files', 'theme' => $theme, 'repoName' => $repoName, 'tab' => 'tree',
+ 'title' => repo_display_name($repoName) . ' · files', 'theme' => $theme, 'repoName' => $repoName,
+ 'tab' => 'tree', 'description' => $description,
]);
render('partials/repo-subnav', [
'repoName' => $repoName, 'curAction' => 'tree', 'curRef' => $ref, 'branches' => list_branches($repo),
diff --git a/server/git-shell-commands/describe b/server/git-shell-commands/describe
new file mode 100644
index 0000000..41b4c05
--- /dev/null
+++ b/server/git-shell-commands/describe
@@ -0,0 +1,38 @@
+#!/bin/sh
+# usage (over ssh, as the git user): ssh git@<host> describe <reponame> <text...>
+#
+# Sets the repo's description -- the standard git `description` file inside
+# the bare repo, same mechanism cgit/gitweb/stagit all read. foxygit shows it
+# on the repo index and in an "about" box at the top of the repo's pages.
+# Paths below must match server/setup-server.sh.
+
+reposdir="/var/git/repos"
+
+name="$1"
+if [ -z "$name" ]; then
+ echo "usage: describe <reponame> <description text>" >&2
+ exit 1
+fi
+shift
+
+name=$(basename "$name" ".git")
+case "$name" in
+ */* | .* | -*)
+ echo "invalid repo name: $name" >&2
+ exit 1
+ ;;
+esac
+
+dest="${reposdir}/${name}.git"
+if [ ! -d "$dest" ]; then
+ echo "no such repo: ${name}.git" >&2
+ exit 1
+fi
+
+if [ -z "$1" ]; then
+ echo "usage: describe <reponame> <description text>" >&2
+ exit 1
+fi
+
+echo "$*" > "${dest}/description"
+echo "description set for ${name}.git"
diff --git a/server/setup-server.sh b/server/setup-server.sh
index 9a23674..bde94b1 100755
--- a/server/setup-server.sh
+++ b/server/setup-server.sh
@@ -41,7 +41,9 @@ fi
mkdir -p "$REPOS_DIR" "$TEMPLATE_DIR" "$SHELL_CMDS_DIR" "$SSH_DIR"
-install -m 0755 "$ROOT/git-shell-commands/create" "$SHELL_CMDS_DIR/create"
+for cmd in "$ROOT"/git-shell-commands/*; do
+ install -m 0755 "$cmd" "$SHELL_CMDS_DIR/$(basename "$cmd")"
+done
touch "$SSH_DIR/authorized_keys"
chmod 700 "$SSH_DIR"
diff --git a/views/help.php b/views/help.php
new file mode 100644
index 0000000..046b99c
--- /dev/null
+++ b/views/help.php
@@ -0,0 +1,74 @@
+<?php $host = parse_url(CLONE_BASE, PHP_URL_HOST) ?: 'this-server'; ?>
+<div class="box readme">
+ <div class="box-header">
+ <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4c.73 0 1.334.192 1.752.545.416.353.65.822.667 1.328.033.988-.65 1.606-1.166 2.02l-.147.117c-.363.288-.573.485-.573.86v.007a.75.75 0 0 1-1.5-.037c.03-.673.457-1.109.877-1.454l.087-.07c.529-.421.867-.723.85-1.132-.007-.174-.09-.375-.323-.564-.234-.19-.575-.32-1.024-.32-.44 0-.77.12-.996.26a1.28 1.28 0 0 0-.481.523ZM8 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>
+ Help
+ </div>
+ <div class="body">
+
+<p>Two kinds of commands below: plain <code>git</code> commands (work with any git server, nothing
+special about this one) and <code>ssh git@<?= h($host) ?> <word></code> ones — those run a small
+set of admin commands this server defines for itself (create a repo, set its description). They
+look like git subcommands but aren't; git itself has no <code>create</code> or <code>describe</code>
+built in that does this. (git *does* have its own unrelated <code>git describe</code> — it prints
+the nearest tag reachable from a commit. Different thing entirely from the SSH one below.)</p>
+
+<h2>First time on this machine? Set your identity</h2>
+<p>Every commit records who made it. Git won't guess — set this once per machine (or drop
+<code>--global</code> to set it just for the repo you're in):</p>
+<pre><code>git config --global user.name "Your Name"
+git config --global user.email "you@example.com"</code></pre>
+<p>Use the same name and email everywhere you push from, so your commits look like one person
+across repos when others are collaborating with you.</p>
+
+<h2>Cloning a repo</h2>
+<p>No account needed to read. Pick either:</p>
+<pre><code># read-only, no credentials
+git clone <?= h(CLONE_BASE) ?><repo>.git
+
+# needs an SSH key registered on the server (see below) -- required for push
+git clone git@<?= h($host) ?>:repos/<repo>.git</code></pre>
+<p>The exact command for a given repo is also on that repo's own page — click the clone box to copy it.</p>
+
+<h2>Everyday git</h2>
+<pre><code>git status # what's changed
+git add <file> # stage a file (git add -A for everything)
+git commit -m "message" # commit what's staged
+git push # send your commits to the server
+git pull # fetch + merge the server's commits into yours
+git log # history
+git diff # uncommitted changes, in detail
+git branch # list branches
+git switch -c <name> # create and switch to a new branch</code></pre>
+
+<h2>Putting a new project up here</h2>
+<p>Either direction works — start with a repo already on the server, or start with a folder you
+already have. Both need an SSH key registered here first (see below).</p>
+<p><strong>Already have a local folder?</strong> Turn it into a repo, then push it up:</p>
+<pre><code>cd my-project
+git init
+git add -A
+git commit -m "Initial commit"
+ssh git@<?= h($host) ?> create my-project
+git remote add origin git@<?= h($host) ?>:repos/my-project.git
+git push -u origin main # or "master" -- whatever git init used</code></pre>
+<p><strong>Starting from nothing?</strong> Create the repo first, clone the (empty) result, then add files:</p>
+<pre><code>ssh git@<?= h($host) ?> create my-project
+git clone git@<?= h($host) ?>:repos/my-project.git
+cd my-project
+# ...add files, then...
+git add -A
+git commit -m "Initial commit"
+git push</code></pre>
+
+<h2>Setting a repo's description</h2>
+<p>Shows up in the repo list and in the box at the top of the repo's pages here. This is the
+server's own <code>describe</code> SSH command (see the note up top) — not <code>git describe</code>.</p>
+<pre><code>ssh git@<?= h($host) ?> describe <reponame> "A short description"</code></pre>
+
+<h2>Getting push access</h2>
+<p>Push (and repo creation) needs your SSH public key added to the server. Send it to the admin,
+or if you're the admin: <code>sudo bash server/add-key.sh</code> from the foxygit checkout.</p>
+
+ </div>
+</div>
diff --git a/views/partials/head.php b/views/partials/head.php
index ee3aa35..d8ae38f 100644
--- a/views/partials/head.php
+++ b/views/partials/head.php
@@ -1,10 +1,13 @@
<?php
/** @var string $title @var string $theme @var ?string $repoName
* @var ?string $tab which nav tab is current ('log'/'refs'/'tree'), null for no tab bar
+ * @var string $description repo_description($repo), or SITE_DESCRIPTION on the repo
+ * index; '' hides the about box either way
* Owns the whole page shell — header, tab bar and the opening <main> — so the
* full-width bars and the centred column stay in one place. foot.php closes it. */
$altTheme = theme_counterpart($theme);
$tab = $tab ?? null;
+$description = $description ?? '';
?>
<!doctype html>
<html lang="en"><head>
@@ -28,7 +31,7 @@ $tab = $tab ?? null;
<?php endif; ?>
<span class="spacer"></span>
<?php if ($altTheme !== null): ?>
- <a class="theme-toggle" href="<?= h(current_query(['theme' => $altTheme])) ?>"
+ <a class="icon-btn" href="<?= h(current_query(['theme' => $altTheme])) ?>"
title="<?= theme_is_dark($theme) ? 'Byt till ljust tema' : 'Byt till mörkt tema' ?>"
aria-label="<?= theme_is_dark($theme) ? 'Byt till ljust tema' : 'Byt till mörkt tema' ?>">
<?php if (theme_is_dark($theme)): ?>
@@ -38,7 +41,13 @@ $tab = $tab ?? null;
<?php endif; ?>
</a>
<?php endif; ?>
+ <a class="icon-btn" href="?a=help" title="Help" aria-label="Help">
+ <svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4c.73 0 1.334.192 1.752.545.416.353.65.822.667 1.328.033.988-.65 1.606-1.166 2.02l-.147.117c-.363.288-.573.485-.573.86v.007a.75.75 0 0 1-1.5-.037c.03-.673.457-1.109.877-1.454l.087-.07c.529-.421.867-.723.85-1.132-.007-.174-.09-.375-.323-.564-.234-.19-.575-.32-1.024-.32-.44 0-.77.12-.996.26a1.28 1.28 0 0 0-.481.523ZM8 12a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path></svg>
+ </a>
</div>
</header>
<?php if ($tab !== null && $repoName !== null) render('partials/tabs', ['repoName' => $repoName, 'cur' => $tab]); ?>
<main><div class="inner">
+<?php if ($description !== ''): ?>
+<div class="box about"><?= h($description) ?></div>
+<?php endif; ?>
diff --git a/views/repo-index.php b/views/repo-index.php
index 5959e37..3487ee1 100644
--- a/views/repo-index.php
+++ b/views/repo-index.php
@@ -2,7 +2,7 @@
<?php if (!$repos): ?>
<p class="desc">No repositories found in <?= h(REPO_BASE) ?>.</p>
<?php else: ?>
-<table id="repo-list"><tr><th>Repository</th><th>Description</th><th>Updated</th></tr>
+<table id="repo-list">
<?php foreach ($repos as $name => $meta): ?>
<tr>
<td>