import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; interface GitState { isRepo: boolean; seenDirty: Set; startHead: string | null; } export default function (pi: ExtensionAPI) { const state: GitState = { isRepo: false, seenDirty: new Set(), startHead: null }; const STATUS_KEY = "git-status"; let timer: ReturnType | undefined; async function run(cwd: string, args: string[]) { const r = await pi.exec("git", args, { cwd, timeout: 5000 }); return r; } function parsePorcelain(stdout: string) { const lines = stdout.split("\n").filter(Boolean); let ahead = 0; let behind = 0; let hasUpstream = false; let staged = 0; let unstaged = 0; let untracked = 0; const dirtyFiles = new Set(); for (const line of lines) { if (line.startsWith("# branch.ab")) { const m = line.match(/# branch\.ab \+(-?\d+) -(-?\d+)/); if (m) { ahead = parseInt(m[1], 10); behind = parseInt(m[2], 10); hasUpstream = true; } continue; } if (line.startsWith("#")) continue; if (line.startsWith("?")) { // untracked: "? " untracked++; const path = line.slice(2); dirtyFiles.add(path); continue; } if (line.startsWith("u")) { // unmerged staged++; unstaged++; const parts = line.split(" "); const path = parts[parts.length - 1]; dirtyFiles.add(path); continue; } if (line.startsWith("1") || line.startsWith("2")) { // "1 ... " or "2 ... \t" const parts = line.split(" "); const xy = parts[1] || ".."; const x = xy[0]; const y = xy[1]; if (x !== ".") staged++; if (y !== ".") unstaged++; const path = parts[parts.length - 1].split("\t")[0]; if (x !== "." || y !== ".") dirtyFiles.add(path); continue; } } return { ahead, behind, hasUpstream, staged, unstaged, untracked, dirtyFiles }; } async function refresh(ctx: any) { const cwd = ctx.cwd as string; if (!state.isRepo) { ctx.ui.setStatus(STATUS_KEY, undefined); return; } const statusRes = await run(cwd, ["status", "--porcelain=v2", "--branch"]); if (statusRes.code !== 0) { ctx.ui.setStatus(STATUS_KEY, undefined); return; } const parsed = parsePorcelain(statusRes.stdout); // new dirty files since session start let newDirty = 0; for (const f of parsed.dirtyFiles) { if (!state.seenDirty.has(f)) newDirty++; state.seenDirty.add(f); } // commits made this session let commitsThisSession = 0; if (state.startHead) { const headRes = await run(cwd, ["rev-parse", "HEAD"]); const head = headRes.code === 0 ? headRes.stdout.trim() : null; if (head && head !== state.startHead) { const countRes = await run(cwd, ["rev-list", "--count", state.startHead + ".." + head]); if (countRes.code === 0) { commitsThisSession = parseInt(countRes.stdout.trim(), 10) || 0; } } } const theme = ctx.ui.theme; const dirtyTotal = parsed.dirtyFiles.size; const parts: string[] = []; if (dirtyTotal === 0 && parsed.ahead === 0 && parsed.behind === 0) { parts.push(theme.fg("success", "✓ clean")); } else { if (parsed.staged > 0) parts.push(theme.fg("accent", `staged:${parsed.staged}`)); if (parsed.unstaged > 0) parts.push(theme.fg("warning", `unstaged:${parsed.unstaged}`)); if (parsed.untracked > 0) parts.push(theme.fg("dim", `untracked:${parsed.untracked}`)); } if (newDirty > 0) parts.push(theme.fg("warning", `new:${newDirty}`)); if (parsed.hasUpstream) { if (parsed.ahead > 0) parts.push(theme.fg("accent", `↑${parsed.ahead}`)); if (parsed.behind > 0) parts.push(theme.fg("error", `↓${parsed.behind}`)); if (parsed.ahead === 0 && parsed.behind === 0 && dirtyTotal === 0) { // already covered by "clean" } else if (parsed.ahead === 0 && parsed.behind === 0) { parts.push(theme.fg("success", "pushed")); } } else { parts.push(theme.fg("dim", "no upstream")); } if (commitsThisSession > 0) parts.push(theme.fg("success", `commits:${commitsThisSession}`)); ctx.ui.setStatus(STATUS_KEY, "⎇ " + parts.join(" ")); } async function init(ctx: any) { const cwd = ctx.cwd as string; const check = await run(cwd, ["rev-parse", "--is-inside-work-tree"]); state.isRepo = check.code === 0 && check.stdout.trim() === "true"; state.seenDirty = new Set(); state.startHead = null; if (state.isRepo) { const headRes = await run(cwd, ["rev-parse", "HEAD"]); state.startHead = headRes.code === 0 ? headRes.stdout.trim() : null; const statusRes = await run(cwd, ["status", "--porcelain=v2"]); if (statusRes.code === 0) { const parsed = parsePorcelain(statusRes.stdout); for (const f of parsed.dirtyFiles) state.seenDirty.add(f); } } await refresh(ctx); } pi.on("session_start", async (_event, ctx) => { await init(ctx); if (timer) clearInterval(timer); timer = setInterval(() => { refresh(ctx).catch(() => {}); }, 10000); }); pi.on("session_shutdown", async (_event, ctx) => { if (timer) clearInterval(timer); ctx.ui.setStatus(STATUS_KEY, undefined); }); pi.on("turn_end", async (_event, ctx) => { await refresh(ctx); }); pi.on("tool_execution_end", async (event: any, ctx) => { if (event.toolName === "bash") { await refresh(ctx); } }); }