From 4dbbe70f12364d910e64dd39b1c4e749f1dd6b2e Mon Sep 17 00:00:00 2001 From: Valerii Kovalskii Date: Tue, 7 Apr 2026 18:49:10 +0300 Subject: [PATCH] feat: show git info in session detail panel - New /api/git-info?project= endpoint returning branch, remote URL, last commit (hash + message), isDirty status - Detail panel shows Branch (purple, * if dirty), Last commit (hash + msg), Remote (cleaned URL without .git/https/git@) - getProjectGitInfo() cached 30s per project path - Skipped on Windows (no git CLI guarantee) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/data.js | 39 +++++++++++++++++++++++++++++++++++++++ src/frontend/app.js | 27 +++++++++++++++++++++++++++ src/frontend/styles.css | 9 +++++++++ src/server.js | 9 ++++++++- 4 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/data.js b/src/data.js index d99a787..cb4d260 100644 --- a/src/data.js +++ b/src/data.js @@ -807,6 +807,44 @@ function resolveGitRoot(projectPath) { } } +const _gitInfoCache = {}; +const GIT_INFO_CACHE_TTL = 30000; // 30 seconds + +function getProjectGitInfo(projectPath) { + if (!projectPath || !fs.existsSync(projectPath)) return null; + if (process.platform === 'win32') return null; + + const now = Date.now(); + const cached = _gitInfoCache[projectPath]; + if (cached && (now - cached._ts) < GIT_INFO_CACHE_TTL) return cached; + + const gitRoot = resolveGitRoot(projectPath); + if (!gitRoot) return null; + + const cwd = gitRoot; + const opts = { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }; + const info = { gitRoot, branch: '', remoteUrl: '', lastCommit: '', lastCommitDate: '', isDirty: false, _ts: now }; + + try { info.branch = execSync(`git -C "${cwd}" rev-parse --abbrev-ref HEAD 2>/dev/null`, opts).trim(); } catch {} + try { info.remoteUrl = execSync(`git -C "${cwd}" config --get remote.origin.url 2>/dev/null`, opts).trim(); } catch {} + try { + const log = execSync(`git -C "${cwd}" log -1 --format="%h %s" 2>/dev/null`, opts).trim(); + if (log) { + const sp = log.indexOf(' '); + info.lastCommit = sp > 0 ? log.slice(sp + 1).slice(0, 80) : log; + info.lastCommitHash = sp > 0 ? log.slice(0, sp) : ''; + } + } catch {} + try { info.lastCommitDate = execSync(`git -C "${cwd}" log -1 --format="%ci" 2>/dev/null`, opts).trim(); } catch {} + try { + const status = execSync(`git -C "${cwd}" status --porcelain 2>/dev/null`, opts).trim(); + info.isDirty = status.length > 0; + } catch {} + + _gitInfoCache[projectPath] = info; + return info; +} + // ── Public API ───────────────────────────────────────────── let _sessionsCache = null; @@ -1958,6 +1996,7 @@ function getActiveSessions() { module.exports = { loadSessions, loadSessionDetail, + getProjectGitInfo, deleteSession, getGitCommits, exportSessionMarkdown, diff --git a/src/frontend/app.js b/src/frontend/app.js index 9f7d4f5..e43dd2c 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1488,6 +1488,7 @@ async function openDetail(s) { var detailToolLabel = s.tool === 'claude-ext' ? 'claude ext' : s.tool; infoHtml += '
Tool' + escHtml(detailToolLabel) + '
'; infoHtml += '
Project' + escHtml(s.project_short || s.project || '') + '
'; + infoHtml += '
'; infoHtml += '
Session ID' + escHtml(s.id) + '
'; infoHtml += '
First seen' + escHtml(s.first_time || '') + '
'; infoHtml += '
Last seen' + escHtml(s.last_time || '') + ' (' + timeAgo(s.last_ts) + ')
'; @@ -1583,6 +1584,32 @@ async function openDetail(s) { if (estBadge) estBadge.style.opacity = '0.5'; }); + // Load git info + if (s.project) { + fetch('/api/git-info?project=' + encodeURIComponent(s.project)) + .then(function(r) { return r.json(); }) + .then(function(git) { + var el = document.getElementById('detail-git-info'); + if (!el || git.error) return; + var html = ''; + if (git.branch) { + html += '
Branch' + escHtml(git.branch); + if (git.isDirty) html += ' *'; + html += '
'; + } + if (git.lastCommit) { + html += '
Last commit'; + if (git.lastCommitHash) html += '' + escHtml(git.lastCommitHash) + ' '; + html += escHtml(git.lastCommit) + '
'; + } + if (git.remoteUrl) { + var displayUrl = git.remoteUrl.replace(/\.git$/, '').replace(/^https?:\/\//, '').replace(/^git@([^:]+):/, '$1/'); + html += '
Remote' + escHtml(displayUrl) + '
'; + } + el.innerHTML = html; + }).catch(function() {}); + } + // Load git commits if (s.project) { var commits = await loadGitCommits(s.project, s.first_ts, s.last_ts); diff --git a/src/frontend/styles.css b/src/frontend/styles.css index 3f9ba90..9ed8b40 100644 --- a/src/frontend/styles.css +++ b/src/frontend/styles.css @@ -1377,6 +1377,15 @@ body { font-size: 12px; } +.git-branch { + font-weight: 600; + color: var(--accent-purple); +} +.git-dirty { + color: var(--accent-orange); + font-weight: 700; +} + .detail-messages { margin-top: 16px; } diff --git a/src/server.js b/src/server.js index 44978ae..690189d 100644 --- a/src/server.js +++ b/src/server.js @@ -3,7 +3,7 @@ const http = require('http'); const https = require('https'); const { URL } = require('url'); const { exec } = require('child_process'); -const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost } = require('./data'); +const { loadSessions, loadSessionDetail, deleteSession, getGitCommits, exportSessionMarkdown, getSessionPreview, searchFullText, getActiveSessions, getSessionReplay, getCostAnalytics, computeSessionCost, getProjectGitInfo } = require('./data'); const { detectTerminals, openInTerminal, focusTerminalByPid } = require('./terminals'); const { convertSession } = require('./convert'); const { generateHandoff } = require('./handoff'); @@ -155,6 +155,13 @@ function startServer(host, port, openBrowser = true) { json(res, commits); } + // ── Project git info ──────────────────── + else if (req.method === 'GET' && pathname === '/api/git-info') { + const project = parsed.searchParams.get('project') || ''; + const info = getProjectGitInfo(project); + json(res, info || { error: 'No git repo found' }); + } + // ── Active sessions ───────────────────── else if (req.method === 'GET' && pathname === '/api/active') { const active = getActiveSessions();