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 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-13 17:35:11 +02:00
parent 4821d29670
commit 9e08a7c31f
5 changed files with 513 additions and 0 deletions

View file

@ -0,0 +1,141 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
async function fetchDiff(repoPath: string): Promise<string> {
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 <span className="text-muted-foreground">{line}{'\n'}</span>
}
if (line.startsWith('+')) {
return (
<span className="bg-green-500/10 text-green-700 dark:text-green-400 block">
{line}{'\n'}
</span>
)
}
if (line.startsWith('-')) {
return (
<span className="bg-red-500/10 text-red-700 dark:text-red-400 block">
{line}{'\n'}
</span>
)
}
if (line.startsWith('@@')) {
return <span className="text-blue-600 dark:text-blue-400">{line}{'\n'}</span>
}
if (line.startsWith('diff ') || line.startsWith('index ')) {
return <span className="text-muted-foreground font-semibold">{line}{'\n'}</span>
}
return <span>{line}{'\n'}</span>
}
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 (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">git diff HEAD</span>
<div className="flex items-center gap-2">
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
<button
onClick={refresh}
disabled={refreshing}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Refresh
</button>
</div>
</div>
{diff.trim() === '' ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">
No uncommitted changes
</div>
) : (
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed">
{diff.split('\n').map((line, i) => (
<DiffLine key={i} line={line} />
))}
</pre>
)}
</div>
)
}

89
app/git/[repo]/page.tsx Normal file
View file

@ -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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/git" className="text-sm text-muted-foreground hover:text-foreground">
Repositories
</Link>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Repo &quot;{repoName}&quot; not found in REPO_PATHS.
</div>
</div>
</div>
)
}
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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/git" className="text-sm text-muted-foreground hover:text-foreground">
Repositories
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight font-mono">{repoName}</h1>
</div>
{statusSummary && (
<div className="flex items-center gap-2 text-sm text-muted-foreground font-mono">
<span>{repoPath}</span>
<span className="text-border">·</span>
<span>{statusSummary}</span>
</div>
)}
<div>
<h2 className="text-base font-semibold mb-3">Uncommitted changes</h2>
<DiffViewer repoPath={repoPath} initialDiff={initialDiff} initialError={initialError} />
</div>
</div>
</div>
)
}

View file

@ -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<RepoStatus> {
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 (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">
<span className="size-1.5 rounded-full bg-orange-500 dark:bg-orange-400" />
dirty
</span>
)
}
if (status.behind && status.behind > 0) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
{status.behind} behind
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500 dark:bg-green-400" />
clean
</span>
)
}
type Props = {
initialRepos: RepoEntry[]
}
export default function GitReposList({ initialRepos }: Props) {
const [repos, setRepos] = useState<RepoEntry[]>(initialRepos)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{repos.length} repo{repos.length !== 1 ? 's' : ''}
</span>
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()}
</span>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Repo</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Branch</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Ahead</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Path</th>
</tr>
</thead>
<tbody>
{repos.map((r) => (
<tr
key={r.path}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-medium">
<Link
href={`/git/${encodeURIComponent(r.name)}`}
className="hover:underline text-foreground"
>
{r.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{r.status?.branch ?? '—'}
</td>
<td className="px-4 py-3">
{r.error ? (
<span className="text-xs text-destructive">{r.error}</span>
) : r.status ? (
statusBadge(r.status)
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{r.status?.ahead !== undefined && r.status.ahead > 0 ? (
<span className="text-amber-600 dark:text-amber-400">{r.status.ahead}</span>
) : (
'—'
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{r.path}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

54
app/git/page.tsx Normal file
View file

@ -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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Git Repositories</h1>
<p className="text-sm text-muted-foreground">Auto-refreshes every 30 seconds</p>
</div>
{repoPaths.length === 0 ? (
<div className="rounded-lg border border-border p-6 text-sm text-muted-foreground">
No repos configured. Set <code className="font-mono">REPO_PATHS</code> in your
environment (comma-separated absolute paths).
</div>
) : (
<GitReposList initialRepos={initialRepos} />
)}
</div>
</div>
)
}

46
lib/parse-git.ts Normal file
View file

@ -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 }
}