From 9e08a7c31f5dde5a48e21f460a46391dbdf674ee Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 17:35:11 +0200 Subject: [PATCH] feat(git): /git overview page and diff viewer Add git module with repo status overview (/git) and per-repo detail page (/git/[repo]) featuring a color-coded diff viewer (+ green, - red). Reads repo paths from REPO_PATHS env var, calls ops-agent git_status and git_diff commands. Status badges: clean (green), dirty (orange), behind-origin (blue). Co-Authored-By: Claude Sonnet 4.6 --- app/git/[repo]/_components/diff-viewer.tsx | 141 ++++++++++++++++ app/git/[repo]/page.tsx | 89 ++++++++++ app/git/_components/git-repos-list.tsx | 183 +++++++++++++++++++++ app/git/page.tsx | 54 ++++++ lib/parse-git.ts | 46 ++++++ 5 files changed, 513 insertions(+) create mode 100644 app/git/[repo]/_components/diff-viewer.tsx create mode 100644 app/git/[repo]/page.tsx create mode 100644 app/git/_components/git-repos-list.tsx create mode 100644 app/git/page.tsx create mode 100644 lib/parse-git.ts diff --git a/app/git/[repo]/_components/diff-viewer.tsx b/app/git/[repo]/_components/diff-viewer.tsx new file mode 100644 index 0000000..ca06d1a --- /dev/null +++ b/app/git/[repo]/_components/diff-viewer.tsx @@ -0,0 +1,141 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' + +async function fetchDiff(repoPath: string): Promise { + const res = await fetch('/api/agent/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_key: 'git_diff', args: [repoPath] }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`agent ${res.status}: ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('no response body') + + const decoder = new TextDecoder() + let buffer = '' + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as { data?: string } + if (parsed.data !== undefined) output += parsed.data + } catch { + // ignore malformed + } + } + } + } + + return output +} + +function DiffLine({ line }: { line: string }) { + if (line.startsWith('+++') || line.startsWith('---')) { + return {line}{'\n'} + } + if (line.startsWith('+')) { + return ( + + {line}{'\n'} + + ) + } + if (line.startsWith('-')) { + return ( + + {line}{'\n'} + + ) + } + if (line.startsWith('@@')) { + return {line}{'\n'} + } + if (line.startsWith('diff ') || line.startsWith('index ')) { + return {line}{'\n'} + } + return {line}{'\n'} +} + +type Props = { + repoPath: string + initialDiff: string + initialError: string | null +} + +export default function DiffViewer({ repoPath, initialDiff, initialError }: Props) { + const [diff, setDiff] = useState(initialDiff) + const [error, setError] = useState(initialError) + const [refreshing, setRefreshing] = useState(false) + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const data = await fetchDiff(repoPath) + setDiff(data) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'failed') + } finally { + setRefreshing(false) + } + }, [repoPath]) + + useEffect(() => { + const id = setInterval(refresh, 30000) + return () => clearInterval(id) + }, [refresh]) + + if (error) { + return ( +
+ {error} +
+ ) + } + + return ( +
+
+ git diff HEAD +
+ {refreshing && ( + refreshing… + )} + +
+
+ {diff.trim() === '' ? ( +
+ No uncommitted changes +
+ ) : ( +
+          {diff.split('\n').map((line, i) => (
+            
+          ))}
+        
+ )} +
+ ) +} diff --git a/app/git/[repo]/page.tsx b/app/git/[repo]/page.tsx new file mode 100644 index 0000000..9a78121 --- /dev/null +++ b/app/git/[repo]/page.tsx @@ -0,0 +1,89 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { execAgent } from '@/lib/agent-client' +import { parseGitStatus } from '@/lib/parse-git' +import DiffViewer from './_components/diff-viewer' + +export const dynamic = 'force-dynamic' + +type Props = { + params: Promise<{ repo: string }> +} + +function findRepoPath(repoName: string): string | null { + const paths = (process.env.REPO_PATHS ?? '') + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + return paths.find((p) => p.split('/').filter(Boolean).pop() === repoName) ?? null +} + +export default async function GitRepoPage({ params }: Props) { + const user = await getCurrentUser() + if (!user) redirect('/login') + + const { repo } = await params + const repoName = decodeURIComponent(repo) + const repoPath = findRepoPath(repoName) + + if (!repoPath) { + return ( +
+
+
+ + ← Repositories + +
+
+ Repo "{repoName}" not found in REPO_PATHS. +
+
+
+ ) + } + + let statusSummary = '' + let initialDiff = '' + let initialError: string | null = null + + try { + const [statusOut, diffOut] = await Promise.all([ + execAgent('git_status', [repoPath]), + execAgent('git_diff', [repoPath]), + ]) + const status = parseGitStatus(statusOut) + statusSummary = `${status.branch}${status.dirty ? ' · dirty' : ' · clean'}${status.ahead ? ` · ↑${status.ahead} ahead` : ''}${status.behind ? ` · ↓${status.behind} behind` : ''}` + initialDiff = diffOut + } catch (err) { + initialError = err instanceof Error ? err.message : 'Failed to fetch repo data' + } + + return ( +
+
+
+ + ← Repositories + + / +

{repoName}

+
+ + {statusSummary && ( +
+ {repoPath} + · + {statusSummary} +
+ )} + +
+

Uncommitted changes

+ +
+
+
+ ) +} diff --git a/app/git/_components/git-repos-list.tsx b/app/git/_components/git-repos-list.tsx new file mode 100644 index 0000000..0268927 --- /dev/null +++ b/app/git/_components/git-repos-list.tsx @@ -0,0 +1,183 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { type RepoStatus, parseGitStatus } from '@/lib/parse-git' + +interface RepoEntry { + path: string + name: string + status: RepoStatus | null + error: string | null +} + +async function fetchRepoStatus(repoPath: string): Promise { + const res = await fetch('/api/agent/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_key: 'git_status', args: [repoPath] }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`agent ${res.status}: ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('no response body') + + const decoder = new TextDecoder() + let buffer = '' + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as { data?: string } + if (parsed.data !== undefined) output += parsed.data + } catch { + // ignore malformed + } + } + } + } + + return parseGitStatus(output) +} + +function statusBadge(status: RepoStatus) { + if (status.dirty) { + return ( + + + dirty + + ) + } + if (status.behind && status.behind > 0) { + return ( + + + {status.behind} behind + + ) + } + return ( + + + clean + + ) +} + +type Props = { + initialRepos: RepoEntry[] +} + +export default function GitReposList({ initialRepos }: Props) { + const [repos, setRepos] = useState(initialRepos) + const [refreshing, setRefreshing] = useState(false) + const [lastUpdated, setLastUpdated] = useState(new Date()) + + const refresh = useCallback(async () => { + setRefreshing(true) + try { + const updated = await Promise.all( + initialRepos.map(async (r) => { + try { + const status = await fetchRepoStatus(r.path) + return { ...r, status, error: null } + } catch (err) { + return { ...r, status: null, error: err instanceof Error ? err.message : 'failed' } + } + }), + ) + setRepos(updated) + setLastUpdated(new Date()) + } finally { + setRefreshing(false) + } + }, [initialRepos]) + + useEffect(() => { + const id = setInterval(refresh, 30000) + return () => clearInterval(id) + }, [refresh]) + + return ( +
+
+
+ + {repos.length} repo{repos.length !== 1 ? 's' : ''} + + {refreshing && ( + refreshing… + )} +
+ + updated {lastUpdated.toLocaleTimeString()} + +
+ +
+ + + + + + + + + + + + {repos.map((r) => ( + + + + + + + + ))} + +
RepoBranchStatusAheadPath
+ + {r.name} + + + {r.status?.branch ?? '—'} + + {r.error ? ( + {r.error} + ) : r.status ? ( + statusBadge(r.status) + ) : ( + + )} + + {r.status?.ahead !== undefined && r.status.ahead > 0 ? ( + ↑{r.status.ahead} + ) : ( + '—' + )} + {r.path}
+
+
+ ) +} diff --git a/app/git/page.tsx b/app/git/page.tsx new file mode 100644 index 0000000..21aab28 --- /dev/null +++ b/app/git/page.tsx @@ -0,0 +1,54 @@ +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { execAgent } from '@/lib/agent-client' +import { parseGitStatus, type RepoStatus } from '@/lib/parse-git' +import GitReposList from './_components/git-repos-list' + +export const dynamic = 'force-dynamic' + +function repoName(repoPath: string): string { + return repoPath.split('/').filter(Boolean).pop() ?? repoPath +} + +export default async function GitPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + const repoPaths = (process.env.REPO_PATHS ?? '') + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + + const initialRepos = await Promise.all( + repoPaths.map(async (path) => { + let status: RepoStatus | null = null + let error: string | null = null + try { + const output = await execAgent('git_status', [path]) + status = parseGitStatus(output) + } catch (err) { + error = err instanceof Error ? err.message : 'failed' + } + return { path, name: repoName(path), status, error } + }), + ) + + return ( +
+
+
+

Git Repositories

+

Auto-refreshes every 30 seconds

+
+ {repoPaths.length === 0 ? ( +
+ No repos configured. Set REPO_PATHS in your + environment (comma-separated absolute paths). +
+ ) : ( + + )} +
+
+ ) +} diff --git a/lib/parse-git.ts b/lib/parse-git.ts new file mode 100644 index 0000000..633273e --- /dev/null +++ b/lib/parse-git.ts @@ -0,0 +1,46 @@ +export interface RepoStatus { + branch: string + /** Number of commits ahead of upstream (undefined if no upstream) */ + ahead?: number + /** Number of commits behind upstream (undefined if no upstream) */ + behind?: number + /** True when there are uncommitted changes */ + dirty: boolean +} + +/** + * Parses `git status --short --branch` output into a RepoStatus. + * + * First line format: ## main...origin/main [ahead N, behind M] + * Remaining lines: XY path (presence means dirty) + */ +export function parseGitStatus(output: string): RepoStatus { + const lines = output.trim().split('\n').filter(Boolean) + + let branch = 'unknown' + let ahead: number | undefined + let behind: number | undefined + let dirty = false + + for (const line of lines) { + if (line.startsWith('## ')) { + const rest = line.slice(3) + // "No commits yet on main" or "HEAD (no branch)" + const trackMatch = rest.match(/^([^.]+)\.\.\.(\S+)/) + if (trackMatch) { + branch = trackMatch[1] + } else { + branch = rest.split(' ')[0] + } + const aheadMatch = rest.match(/ahead (\d+)/) + const behindMatch = rest.match(/behind (\d+)/) + if (aheadMatch) ahead = parseInt(aheadMatch[1], 10) + if (behindMatch) behind = parseInt(behindMatch[1], 10) + } else { + // Any non-header line means there are changes + dirty = true + } + } + + return { branch, ahead, behind, dirty } +}