foxygit / foxygit Log in
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; ?>