A tiny read-only git web frontend — browse bare repos with just PHP and git, no database, no framework.
commit 8d5e29a4ec1806e1c45f02e152775b545912d4a2
Author: mrfox <jens.kristoffersson.se@gmail.com>
AuthorDate: Wed Aug 12 19:27:23 2026 +0200
Commit: mrfox <jens.kristoffersson.se@gmail.com>
CommitDate: Wed Aug 12 19:27:23 2026 +0200
batch updates frpm git insted of N updates per file
---
assets/base.css | 3 +++
inc/functions.php | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
index.php | 2 +-
views/tree.php | 12 +++++++--
4 files changed, 93 insertions(+), 3 deletions(-)
diff --git a/assets/base.css b/assets/base.css
index d0c7f18..77dd20c 100644
--- a/assets/base.css
+++ b/assets/base.css
@@ -146,6 +146,9 @@ tr:hover td { background: var(--surface); }
.tree td.mode { width: 1%; padding-right: 0; color: var(--dim); }
.tree td.mode .icon { display: block; }
.tree td.num { width: 1%; color: var(--dim); font-size: 12px; }
+.tree td.commit-msg { max-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 13px; }
+.tree td.commit-msg a { color: inherit; }
+.tree td.commit-msg a:hover { color: var(--acc); }
.tree tbody a { color: var(--fg); }
.tree tbody a:hover { color: var(--acc); }
.box-header {
diff --git a/inc/functions.php b/inc/functions.php
index a24c4fd..b94e119 100644
--- a/inc/functions.php
+++ b/inc/functions.php
@@ -401,6 +401,85 @@ function format_size(string $bytes): string {
return rtrim(rtrim(number_format($v, 2, '.', ''), '0'), '.') . ' ' . $units[$i];
}
+/** For each tree entry, the most recent commit that touched its path -- the
+ * "last commit message / updated 4 months ago" columns in views/tree.php.
+ *
+ * One `git log` call total, not one per entry: git log accepts several
+ * pathspecs at once and walks history a single time checking commits
+ * against all of them, versus N separate walks for N single-path calls.
+ * --name-only lists, per commit, which of our target paths it touched;
+ * we scan newest-first and take each entry's first (= most recent) match.
+ * A directory entry's "touched" files are anything under it (prefix match)
+ * since git tracks blobs, never bare directory paths, in that list. */
+function annotate_last_commits(string $repo, string $ref, array $entries): array {
+ if (!$entries) return $entries;
+
+ $paths = array_map(fn($e) => $e['child'], $entries);
+ $raw = git_raw($repo, array_merge(
+ ['log', $ref, '--name-only', '--pretty=format:%x1e%H%x1f%s%x1f%at'],
+ ['--'], $paths
+ ));
+
+ $answers = []; // child path -> ['hash', 'subject', 'at']
+ $remaining = count($entries);
+
+ foreach ($raw === '' ? [] : explode("\x1e", $raw) as $record) {
+ if ($remaining === 0) break;
+ if ($record === '') continue;
+
+ $lines = explode("\n", $record);
+ $header = array_shift($lines);
+ [$hash, $subject, $at] = array_pad(explode("\x1f", $header), 3, '');
+
+ foreach ($entries as $e) {
+ $child = $e['child'];
+ if (isset($answers[$child])) continue;
+ foreach ($lines as $file) {
+ if ($file === $child || ($e['isTree'] && str_starts_with($file, $child . '/'))) {
+ $answers[$child] = ['hash' => $hash, 'subject' => $subject, 'at' => (int) $at];
+ $remaining--;
+ break;
+ }
+ }
+ }
+ }
+
+ foreach ($entries as &$e) {
+ $info = $answers[$e['child']] ?? null;
+ $e['lastHash'] = $info['hash'] ?? '';
+ $e['lastSubject'] = $info['subject'] ?? '';
+ $e['lastAt'] = $info['at'] ?? null;
+ }
+ unset($e);
+ return $entries;
+}
+
+/** "4 months ago", "2 days ago", "just now" -- coarse, GitHub-style relative time. */
+function format_relative_time(int $timestamp): string {
+ $diff = time() - $timestamp;
+ if ($diff < 60) return 'just now';
+
+ foreach ([
+ 31536000 => 'year', 2592000 => 'month', 604800 => 'week',
+ 86400 => 'day', 3600 => 'hour', 60 => 'minute',
+ ] as $secs => $unit) {
+ $n = intdiv($diff, $secs);
+ if ($n >= 1) return $n . ' ' . $unit . ($n === 1 ? '' : 's') . ' ago';
+ }
+ return 'just now';
+}
+
+/** Truncate to $max chars with a trailing ellipsis; multibyte-safe without
+ * needing the mbstring extension (not guaranteed installed) -- PCRE's `u`
+ * modifier is built into PHP core, so splitting into UTF-8 characters this
+ * way needs nothing extra. Full text belongs in a title="" attribute
+ * wherever this is used for display. */
+function truncate(string $s, int $max): string {
+ $chars = preg_split('//u', $s, -1, PREG_SPLIT_NO_EMPTY);
+ if ($chars === false || count($chars) <= $max) return $s;
+ return implode('', array_slice($chars, 0, $max - 1)) . '…';
+}
+
/** Repo name as shown to a reader. Bare repos are directories called `name.git`,
* but that suffix is just noise in headings and listings — so it's dropped for
* display only. URLs and clone_url() keep using the real directory name. */
diff --git a/index.php b/index.php
index e4ebeb8..05e7c55 100644
--- a/index.php
+++ b/index.php
@@ -226,7 +226,7 @@ $docs = $path === '' ? root_docs($repo, $ref) : ['readmeName' => null, 'readmeHt
render('tree', [
'repoName' => $repoName, 'ref' => $ref, 'path' => $path,
- 'entries' => parse_tree($rawTree, $path),
+ 'entries' => annotate_last_commits($repo, $ref, parse_tree($rawTree, $path)),
] + $docs);
render('partials/foot', ['theme' => $theme, 'themes' => $themes]);
diff --git a/views/tree.php b/views/tree.php
index 1bea92a..6ae4d1c 100644
--- a/views/tree.php
+++ b/views/tree.php
@@ -1,5 +1,7 @@
<?php
-/** @var string $repoName @var string $ref @var string $path @var array $entries
+/** @var string $repoName @var string $ref @var string $path
+ * @var array $entries parse_tree() rows plus annotate_last_commits()'s
+ * lastHash/lastSubject/lastAt (lastAt null if somehow no commit found)
* @var ?string $readmeName @var ?string $readmeHtml @var ?string $licenseName */
$folderIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" style="color:var(--acc)"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"></path></svg>';
$fileIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" style="color:var(--dim)"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"></path></svg>';
@@ -11,7 +13,7 @@ $readmeIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria
<div class="box">
<table class="tree">
-<thead><tr><th>Mode</th><th>Name</th><th class="num">Size</th></tr></thead>
+<thead><tr><th>Mode</th><th>Name</th><th>Last commit</th><th class="num">Updated</th><th class="num">Size</th></tr></thead>
<tbody>
<?php foreach ($entries as $e): ?>
<tr>
@@ -23,6 +25,12 @@ $readmeIcon = '<svg class="icon" width="16" height="16" viewBox="0 0 16 16" aria
<a href="?r=<?= h($repoName) ?>&a=tree&ref=<?= h($ref) ?>&blob=<?= h($e['child']) ?>"><?= h($e['name']) ?></a>
<?php endif; ?>
</td>
+ <td class="commit-msg desc">
+ <?php if ($e['lastHash'] !== ''): ?>
+ <a href="?r=<?= h($repoName) ?>&a=commit&h=<?= h($e['lastHash']) ?>" title="<?= h($e['lastSubject']) ?>"><?= h(truncate($e['lastSubject'], 72)) ?></a>
+ <?php endif; ?>
+ </td>
+ <td class="num desc"><?= $e['lastAt'] !== null ? h(format_relative_time($e['lastAt'])) : '' ?></td>
<td class="num hash"><?= h(format_size($e['size'])) ?></td>
</tr>
<?php endforeach; ?>