shelley: add git commit info to version command

Philip Zeyliger and Claude created

Prompt: bin/shelley version doesn't seem to print anything. we have a variety of ways things get built, but i want to see version information

The version command now shows commit hash, timestamp, and modified status.
Build script captures git info into build-info.json, which the version
package reads as a fallback when runtime/debug doesn't have vcs info.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Change summary

ui/scripts/build.js | 18 ++++++++++++++++++
version/version.go  | 41 ++++++++++++++++++++++++++++++-----------
2 files changed, 48 insertions(+), 11 deletions(-)

Detailed changes

ui/scripts/build.js 🔗

@@ -2,6 +2,7 @@ import * as esbuild from 'esbuild';
 import * as fs from 'fs';
 import * as zlib from 'zlib';
 import * as crypto from 'crypto';
+import { execSync } from 'child_process';
 
 const isWatch = process.argv.includes('--watch');
 const isProd = !isWatch;
@@ -72,10 +73,27 @@ async function build() {
     // Write build info
     // Get the absolute path to the src directory for staleness checking
     const srcDir = new URL('../src', import.meta.url).pathname;
+
+    // Get git commit info
+    let commit = '';
+    let commitTime = '';
+    let modified = false;
+    try {
+      commit = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
+      commitTime = execSync('git log -1 --format=%cI', { encoding: 'utf8' }).trim();
+      const status = execSync('git status --porcelain', { encoding: 'utf8' });
+      modified = status.length > 0;
+    } catch (e) {
+      // Git not available or not a git repo
+    }
+
     const buildInfo = {
       timestamp: Date.now(),
       date: new Date().toISOString(),
       srcDir: srcDir,
+      commit: commit,
+      commitTime: commitTime,
+      modified: modified,
     };
     fs.writeFileSync('dist/build-info.json', JSON.stringify(buildInfo, null, 2));
 

version/version.go 🔗

@@ -1,7 +1,11 @@
 package version
 
 import (
+	"encoding/json"
+	"io/fs"
 	"runtime/debug"
+
+	"shelley.exe.dev/ui"
 )
 
 // Info holds build information from runtime/debug.ReadBuildInfo
@@ -11,23 +15,38 @@ type Info struct {
 	Modified   bool   `json:"modified,omitempty"`
 }
 
-// GetInfo returns build information using runtime/debug.ReadBuildInfo
+// GetInfo returns build information using runtime/debug.ReadBuildInfo,
+// falling back to the embedded build-info.json from the UI build.
 func GetInfo() Info {
 	var info Info
 
 	buildInfo, ok := debug.ReadBuildInfo()
-	if !ok {
-		return info
+	if ok {
+		for _, setting := range buildInfo.Settings {
+			switch setting.Key {
+			case "vcs.revision":
+				info.Commit = setting.Value
+			case "vcs.time":
+				info.CommitTime = setting.Value
+			case "vcs.modified":
+				info.Modified = setting.Value == "true"
+			}
+		}
 	}
 
-	for _, setting := range buildInfo.Settings {
-		switch setting.Key {
-		case "vcs.revision":
-			info.Commit = setting.Value
-		case "vcs.time":
-			info.CommitTime = setting.Value
-		case "vcs.modified":
-			info.Modified = setting.Value == "true"
+	// If we didn't get vcs info from debug.ReadBuildInfo, try the embedded build-info.json
+	if info.Commit == "" {
+		if data, err := fs.ReadFile(ui.Dist, "dist/build-info.json"); err == nil {
+			var buildJSON struct {
+				Commit     string `json:"commit"`
+				CommitTime string `json:"commitTime"`
+				Modified   bool   `json:"modified"`
+			}
+			if json.Unmarshal(data, &buildJSON) == nil {
+				info.Commit = buildJSON.Commit
+				info.CommitTime = buildJSON.CommitTime
+				info.Modified = buildJSON.Modified
+			}
 		}
 	}