foxygit / foxygit Log in
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) ?> &lt;word&gt;</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) ?>&lt;repo&gt;.git
+
+# needs an SSH key registered on the server (see below) -- required for push
+git clone git@<?= h($host) ?>:repos/&lt;repo&gt;.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 &lt;file&gt;          # 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 &lt;name&gt;    # 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 &lt;reponame&gt; "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>