/** * git-metadata — Pi extension for git repository metadata * * Provides a `git_metadata` tool that retrieves branch info, remotes, * recent commits, tags, file status, and top contributors. * * Install: pi install npm:git-metadata * Test: pi -e ./extensions/git-metadata.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Text, Box, Container, Spacer } from "@mariozechner/pi-tui"; import { Type, type Static } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import { execSync } from "child_process"; // ─── Types ──────────────────────────────────────────────────────────────── const GitMetadataParams = Type.Object({ action: StringEnum([ "summary", "branch", "remotes", "log", "tags", "status", "contributors", "diff_stat", ] as const), count: Type.Optional( Type.Number({ description: "Number of items to return (for log, tags, contributors). Default: 10", }) ), ref: Type.Optional( Type.String({ description: "Git ref for diff_stat (e.g. HEAD~5, main, a commit SHA). Default: HEAD~1", }) ), }); type GitMetadataInput = Static; interface CommitInfo { hash: string; author: string; date: string; message: string; } interface FileStatus { status: string; file: string; } interface ContributorInfo { name: string; commits: number; } interface DiffStatEntry { file: string; insertions: number; deletions: number; } interface GitMetadataDetails { action: string; branch?: string; remoteBranch?: string; ahead?: number; behind?: number; remotes?: Array<{ name: string; url: string }>; commits?: CommitInfo[]; tags?: string[]; status?: FileStatus[]; contributors?: ContributorInfo[]; diffStat?: DiffStatEntry[]; diffRef?: string; summary?: { branch: string; remoteBranch: string; ahead: number; behind: number; dirty: number; staged: number; untracked: number; lastCommit: CommitInfo | null; remoteUrl: string; }; error?: string; } // ─── Helpers ────────────────────────────────────────────────────────────── function git(cmd: string, cwd?: string): string { try { return execSync(`git ${cmd}`, { cwd: cwd ?? process.cwd(), encoding: "utf-8", timeout: 10_000, stdio: ["pipe", "pipe", "pipe"], }).trim(); } catch { return ""; } } function isGitRepo(cwd?: string): boolean { return git("rev-parse --is-inside-work-tree", cwd) === "true"; } function getCurrentBranch(cwd?: string): string { return git("rev-parse --abbrev-ref HEAD", cwd) || "detached"; } function getTrackingBranch(cwd?: string): string { return git("rev-parse --abbrev-ref @{upstream}", cwd); } function getAheadBehind(cwd?: string): { ahead: number; behind: number } { const raw = git("rev-list --left-right --count @{upstream}...HEAD", cwd); if (!raw) return { ahead: 0, behind: 0 }; const [behind, ahead] = raw.split(/\s+/).map(Number); return { ahead: ahead || 0, behind: behind || 0 }; } function getRemotes(cwd?: string): Array<{ name: string; url: string }> { const raw = git("remote -v", cwd); if (!raw) return []; const seen = new Map(); for (const line of raw.split("\n")) { const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)/); if (match && !seen.has(match[1])) { seen.set(match[1], match[2]); } } return Array.from(seen.entries()).map(([name, url]) => ({ name, url })); } function getLog(count: number, cwd?: string): CommitInfo[] { const raw = git( `log -${count} --pretty=format:"%H|||%an|||%ad|||%s" --date=short`, cwd ); if (!raw) return []; return raw.split("\n").map((line) => { const [hash, author, date, ...rest] = line.replace(/^"|"$/g, "").split("|||"); return { hash: hash?.slice(0, 8) ?? "", author: author ?? "", date: date ?? "", message: rest.join("|||") }; }); } function getTags(count: number, cwd?: string): string[] { const raw = git(`tag --sort=-creatordate`, cwd); if (!raw) return []; return raw.split("\n").slice(0, count); } function getStatus(cwd?: string): FileStatus[] { const raw = git("status --porcelain", cwd); if (!raw) return []; return raw.split("\n").map((line) => ({ status: line.slice(0, 2).trim(), file: line.slice(3), })); } function getContributors(count: number, cwd?: string): ContributorInfo[] { const raw = git("shortlog -sn --no-merges HEAD", cwd); if (!raw) return []; return raw .split("\n") .slice(0, count) .map((line) => { const match = line.trim().match(/^(\d+)\s+(.+)$/); return match ? { name: match[2], commits: parseInt(match[1], 10) } : { name: line.trim(), commits: 0 }; }); } function getDiffStat(ref: string, cwd?: string): DiffStatEntry[] { const raw = git(`diff --numstat ${ref}`, cwd); if (!raw) return []; return raw.split("\n").filter(Boolean).map((line) => { const [ins, del, file] = line.split("\t"); return { file: file ?? "", insertions: ins === "-" ? 0 : parseInt(ins ?? "0", 10), deletions: del === "-" ? 0 : parseInt(del ?? "0", 10), }; }); } // ─── Extension ──────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { pi.registerTool({ name: "git_metadata", label: "Git Metadata", description: "Retrieve git repository metadata. Actions: summary (overview), branch, remotes, log (recent commits), tags, status (working tree), contributors, diff_stat (changed files vs a ref)", promptSnippet: "Query git repo metadata: summary, branch, remotes, log, tags, status, contributors, diff_stat", parameters: GitMetadataParams, // ─── Execute ──────────────────────────────────────────────────────── async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const action = params.action; const count = params.count ?? 10; const ref = params.ref ?? "HEAD~1"; if (!isGitRepo()) { throw new Error("Not inside a git repository"); } const makeResult = (text: string, details: GitMetadataDetails) => ({ content: [{ type: "text" as const, text }], details, }); switch (action) { // ── SUMMARY ── case "summary": { const branch = getCurrentBranch(); const remoteBranch = getTrackingBranch(); const { ahead, behind } = getAheadBehind(); const st = getStatus(); const dirty = st.filter((s) => !s.status.includes("?")).length; const staged = st.filter((s) => s.status[0] !== " " && s.status[0] !== "?").length; const untracked = st.filter((s) => s.status.includes("?")).length; const commits = getLog(1); const remotes = getRemotes(); const summary = { branch, remoteBranch: remoteBranch || "none", ahead, behind, dirty, staged, untracked, lastCommit: commits[0] ?? null, remoteUrl: remotes[0]?.url ?? "none", }; const lines = [ `Branch: ${branch}${remoteBranch ? ` → ${remoteBranch}` : ""}`, `Remote: ${summary.remoteUrl}`, `Ahead: ${ahead}, Behind: ${behind}`, `Dirty: ${dirty}, Staged: ${staged}, Untracked: ${untracked}`, ]; if (summary.lastCommit) { lines.push( `Last commit: ${summary.lastCommit.hash} ${summary.lastCommit.message} (${summary.lastCommit.author}, ${summary.lastCommit.date})` ); } return makeResult(lines.join("\n"), { action, summary }); } // ── BRANCH ── case "branch": { const branch = getCurrentBranch(); const remoteBranch = getTrackingBranch(); const { ahead, behind } = getAheadBehind(); return makeResult( `${branch}${remoteBranch ? ` → ${remoteBranch}` : ""} (ahead: ${ahead}, behind: ${behind})`, { action, branch, remoteBranch, ahead, behind } ); } // ── REMOTES ── case "remotes": { const remotes = getRemotes(); if (remotes.length === 0) return makeResult("No remotes configured", { action, remotes: [] }); const lines = remotes.map((r) => `${r.name}: ${r.url}`); return makeResult(lines.join("\n"), { action, remotes }); } // ── LOG ── case "log": { const commits = getLog(count); if (commits.length === 0) return makeResult("No commits found", { action, commits: [] }); const lines = commits.map((c) => `${c.hash} ${c.date} ${c.author}: ${c.message}`); return makeResult(lines.join("\n"), { action, commits }); } // ── TAGS ── case "tags": { const tags = getTags(count); if (tags.length === 0) return makeResult("No tags found", { action, tags: [] }); return makeResult(tags.join("\n"), { action, tags }); } // ── STATUS ── case "status": { const status = getStatus(); if (status.length === 0) return makeResult("Working tree clean", { action, status: [] }); const lines = status.map((s) => `${s.status} ${s.file}`); return makeResult(lines.join("\n"), { action, status }); } // ── CONTRIBUTORS ── case "contributors": { const contributors = getContributors(count); if (contributors.length === 0) return makeResult("No contributors found", { action, contributors: [] }); const lines = contributors.map((c) => `${c.name}: ${c.commits} commits`); return makeResult(lines.join("\n"), { action, contributors }); } // ── DIFF_STAT ── case "diff_stat": { const diffStat = getDiffStat(ref); if (diffStat.length === 0) return makeResult(`No changes vs ${ref}`, { action, diffStat: [], diffRef: ref }); const lines = diffStat.map((d) => `+${d.insertions} -${d.deletions} ${d.file}`); const totalIns = diffStat.reduce((s, d) => s + d.insertions, 0); const totalDel = diffStat.reduce((s, d) => s + d.deletions, 0); lines.push(`Total: +${totalIns} -${totalDel} in ${diffStat.length} file(s)`); return makeResult(lines.join("\n"), { action, diffStat, diffRef: ref }); } default: throw new Error(`Unknown action: ${action}`); } }, // ─── renderCall ───────────────────────────────────────────────────── renderCall(args, theme, context) { const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0); let content = theme.fg("toolTitle", theme.bold("git_metadata ")); content += theme.fg("accent", args.action ?? "..."); if (args.count) content += " " + theme.fg("dim", `(${args.count})`); if (args.ref) content += " " + theme.fg("muted", args.ref); text.setText(content); return text; }, // ─── renderResult ─────────────────────────────────────────────────── renderResult(result, { expanded, isPartial }, theme, _context) { if (isPartial) { return new Text(theme.fg("warning", "⏳ Querying git..."), 0, 0); } if (result.isError) { const msg = result.content[0]?.type === "text" ? result.content[0].text : "Unknown error"; return new Text(theme.fg("error", `✗ ${msg}`), 0, 0); } const d = result.details as GitMetadataDetails | undefined; if (!d) { const raw = result.content[0]; return new Text(raw?.type === "text" ? raw.text : "", 0, 0); } switch (d.action) { // ── SUMMARY ── case "summary": { const s = d.summary!; let text = theme.fg("success", "✓ ") + theme.fg("accent", s.branch) + (s.remoteBranch !== "none" ? theme.fg("dim", ` → ${s.remoteBranch}`) : ""); const badges: string[] = []; if (s.ahead > 0) badges.push(theme.fg("warning", `↑${s.ahead}`)); if (s.behind > 0) badges.push(theme.fg("warning", `↓${s.behind}`)); if (s.dirty > 0) badges.push(theme.fg("error", `~${s.dirty}`)); if (s.staged > 0) badges.push(theme.fg("success", `+${s.staged}`)); if (s.untracked > 0) badges.push(theme.fg("dim", `?${s.untracked}`)); if (badges.length > 0) text += " " + badges.join(" "); if (expanded) { text += "\n" + theme.fg("dim", ` Remote: ${s.remoteUrl}`); if (s.lastCommit) { text += "\n" + theme.fg("dim", ` Last: `) + theme.fg("muted", `${s.lastCommit.hash} ${s.lastCommit.message}`) + theme.fg("dim", ` (${s.lastCommit.author}, ${s.lastCommit.date})`); } } return new Text(text, 0, 0); } // ── BRANCH ── case "branch": { let text = theme.fg("success", "✓ ") + theme.fg("accent", d.branch ?? ""); if (d.remoteBranch) text += theme.fg("dim", ` → ${d.remoteBranch}`); const parts: string[] = []; if (d.ahead) parts.push(theme.fg("warning", `↑${d.ahead}`)); if (d.behind) parts.push(theme.fg("warning", `↓${d.behind}`)); if (parts.length) text += " " + parts.join(" "); return new Text(text, 0, 0); } // ── REMOTES ── case "remotes": { if (!d.remotes?.length) return new Text(theme.fg("dim", "No remotes"), 0, 0); let text = theme.fg("success", "✓ ") + theme.fg("muted", `${d.remotes.length} remote(s)`); if (expanded) { for (const r of d.remotes) { text += "\n " + theme.fg("accent", r.name) + " " + theme.fg("dim", r.url); } } return new Text(text, 0, 0); } // ── LOG ── case "log": { if (!d.commits?.length) return new Text(theme.fg("dim", "No commits"), 0, 0); let text = theme.fg("success", "✓ ") + theme.fg("muted", `${d.commits.length} commit(s)`); if (expanded) { for (const c of d.commits) { text += "\n " + theme.fg("accent", c.hash) + " " + theme.fg("dim", c.date) + " " + theme.fg("muted", c.author) + theme.fg("dim", ": ") + theme.fg("text", c.message); } } return new Text(text, 0, 0); } // ── TAGS ── case "tags": { if (!d.tags?.length) return new Text(theme.fg("dim", "No tags"), 0, 0); let text = theme.fg("success", "✓ ") + theme.fg("muted", `${d.tags.length} tag(s)`); if (expanded) { for (const t of d.tags) { text += "\n " + theme.fg("accent", t); } } return new Text(text, 0, 0); } // ── STATUS ── case "status": { if (!d.status?.length) return new Text(theme.fg("success", "✓ ") + theme.fg("dim", "Clean"), 0, 0); const modified = d.status.filter((s) => s.status === "M" || s.status === "MM").length; const added = d.status.filter((s) => s.status === "A" || s.status === "AM").length; const deleted = d.status.filter((s) => s.status === "D").length; const untracked = d.status.filter((s) => s.status === "??").length; const parts: string[] = []; if (modified) parts.push(theme.fg("warning", `~${modified}`)); if (added) parts.push(theme.fg("success", `+${added}`)); if (deleted) parts.push(theme.fg("error", `-${deleted}`)); if (untracked) parts.push(theme.fg("dim", `?${untracked}`)); let text = theme.fg("success", "✓ ") + theme.fg("muted", `${d.status.length} file(s) `) + parts.join(" "); if (expanded) { for (const s of d.status) { const color = s.status === "??" ? "dim" : s.status.includes("M") ? "warning" : s.status.includes("D") ? "error" : s.status.includes("A") ? "success" : "muted"; text += "\n " + theme.fg(color, s.status.padEnd(3)) + theme.fg("muted", s.file); } } return new Text(text, 0, 0); } // ── CONTRIBUTORS ── case "contributors": { if (!d.contributors?.length) return new Text(theme.fg("dim", "No contributors"), 0, 0); let text = theme.fg("success", "✓ ") + theme.fg("muted", `${d.contributors.length} contributor(s)`); if (expanded) { const maxNameLen = Math.max(...d.contributors.map((c) => c.name.length)); for (const c of d.contributors) { const bar = theme.fg("accent", "█".repeat(Math.max(1, Math.round(c.commits / 10)))); text += "\n " + theme.fg("muted", c.name.padEnd(maxNameLen + 1)) + theme.fg("dim", `${String(c.commits).padStart(5)} `) + bar; } } return new Text(text, 0, 0); } // ── DIFF_STAT ── case "diff_stat": { if (!d.diffStat?.length) { return new Text( theme.fg("success", "✓ ") + theme.fg("dim", `No changes vs ${d.diffRef ?? "HEAD~1"}`), 0, 0 ); } const totalIns = d.diffStat.reduce((s, f) => s + f.insertions, 0); const totalDel = d.diffStat.reduce((s, f) => s + f.deletions, 0); let text = theme.fg("success", "✓ ") + theme.fg("muted", `${d.diffStat.length} file(s) vs ${d.diffRef ?? "HEAD~1"} `) + theme.fg("success", `+${totalIns} `) + theme.fg("error", `-${totalDel}`); if (expanded) { for (const f of d.diffStat) { text += "\n " + theme.fg("success", `+${String(f.insertions).padStart(4)}`) + " " + theme.fg("error", `-${String(f.deletions).padStart(4)}`) + " " + theme.fg("muted", f.file); } } return new Text(text, 0, 0); } default: return new Text(theme.fg("dim", "Done"), 0, 0); } }, }); }