/** * pi-git-graph — Visual git history in the terminal. * * /git-graph → ASCII commit graph (last 30 commits) * /git-graph 100 → last 100 commits * /git-stats → contributor stats, commit frequency, file churn * /git-branches → branch overview with ahead/behind * * LLM tool: git_graph — returns structured commit history */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { execSync } from "node:child_process"; const RST = "\x1b[0m"; const B = "\x1b[1m"; const D = "\x1b[2m"; const RED = "\x1b[31m"; const GREEN = "\x1b[32m"; const YELLOW = "\x1b[33m"; const CYAN = "\x1b[36m"; const MAGENTA = "\x1b[35m"; const BLUE = "\x1b[34m"; function git(cmd: string, cwd?: string): string { try { return execSync(`git ${cmd}`, { encoding: "utf-8", timeout: 15000, maxBuffer: 10 * 1024 * 1024, cwd: cwd || process.cwd(), }).trim(); } catch { return ""; } } function isGitRepo(): boolean { return git("rev-parse --is-inside-work-tree") === "true"; } // ── Graph rendering ───────────────────────────────────────────────────────── const BRANCH_COLORS = [CYAN, GREEN, MAGENTA, YELLOW, BLUE, RED]; interface CommitNode { hash: string; shortHash: string; subject: string; author: string; date: string; refs: string; parents: string[]; } function parseLog(count: number): CommitNode[] { const raw = git(`log --all --format="%H|%h|%s|%an|%cr|%D|%P" -${count}`); if (!raw) return []; return raw.split("\n").filter(Boolean).map(line => { const [hash, shortHash, subject, author, date, refs, parentStr] = line.split("|"); return { hash, shortHash, subject, author, date, refs: refs || "", parents: (parentStr || "").split(" ").filter(Boolean) }; }); } function renderGraph(commits: CommitNode[]): string[] { const lines: string[] = []; // Use git's built-in graph for accuracy const raw = git(`log --all --graph --oneline --decorate --color=never -${commits.length}`); if (!raw) return [" No commits found"]; for (const line of raw.split("\n")) { // Colorize the graph characters let colored = line; // Find where the graph ends and the hash starts const hashMatch = line.match(/^([*|/\\ _\-.]+?)\s*([a-f0-9]{7,12})\s*(.*)/); if (hashMatch) { const [, graphPart, hash, rest] = hashMatch; // Color graph chars let coloredGraph = ""; for (const ch of graphPart) { if (ch === "*") coloredGraph += `${YELLOW}${ch}${RST}`; else if (ch === "|") coloredGraph += `${CYAN}${ch}${RST}`; else if (ch === "/" || ch === "\\") coloredGraph += `${GREEN}${ch}${RST}`; else coloredGraph += ch; } // Color refs if present let coloredRest = rest; const refMatch = rest.match(/^\(([^)]+)\)\s*(.*)/); if (refMatch) { const [, refStr, msg] = refMatch; const coloredRefs = refStr.split(", ").map(r => { if (r.startsWith("HEAD")) return `${RED}${B}${r}${RST}`; if (r.includes("origin/")) return `${RED}${r}${RST}`; if (r.startsWith("tag:")) return `${YELLOW}${B}${r}${RST}`; return `${GREEN}${B}${r}${RST}`; }).join(", "); coloredRest = `(${coloredRefs}) ${msg}`; } colored = `${coloredGraph} ${D}${hash}${RST} ${coloredRest}`; } else { // Pure graph line (no commit) let coloredGraph = ""; for (const ch of line) { if (ch === "|") coloredGraph += `${CYAN}${ch}${RST}`; else if (ch === "/" || ch === "\\") coloredGraph += `${GREEN}${ch}${RST}`; else coloredGraph += ch; } colored = coloredGraph; } lines.push(colored); } return lines; } // ── Stats ─────────────────────────────────────────────────────────────────── interface AuthorStats { name: string; commits: number; insertions: number; deletions: number; firstCommit: string; lastCommit: string; } function getAuthorStats(): AuthorStats[] { const shortlog = git("shortlog -sne --all"); if (!shortlog) return []; const authors: AuthorStats[] = []; for (const line of shortlog.split("\n")) { const m = line.match(/^\s*(\d+)\s+(.+?)\s+<.*>$/); if (!m) continue; const commits = parseInt(m[1]); const name = m[2]; // Get first and last commit dates const first = git(`log --all --author="${name}" --format="%cr" --reverse | head -1`); const last = git(`log --all --author="${name}" --format="%cr" -1`); authors.push({ name, commits, insertions: 0, deletions: 0, firstCommit: first, lastCommit: last }); } return authors.sort((a, b) => b.commits - a.commits); } function getFileChurn(days: number): { file: string; changes: number }[] { const since = `${days} days ago`; const raw = git(`log --since="${since}" --format="" --name-only`); if (!raw) return []; const counts = new Map(); for (const file of raw.split("\n").filter(Boolean)) { counts.set(file, (counts.get(file) || 0) + 1); } return [...counts.entries()] .map(([file, changes]) => ({ file, changes })) .sort((a, b) => b.changes - a.changes) .slice(0, 15); } function getCommitFrequency(days: number): Map { const since = `${days} days ago`; const raw = git(`log --since="${since}" --format="%ad" --date=short`); if (!raw) return new Map(); const counts = new Map(); for (const date of raw.split("\n").filter(Boolean)) { counts.set(date, (counts.get(date) || 0) + 1); } return counts; } // ── Branches ──────────────────────────────────────────────────────────────── interface BranchInfo { name: string; current: boolean; lastCommit: string; ahead: number; behind: number; remote: string; } function getBranches(): BranchInfo[] { const raw = git("branch -vv --no-color"); if (!raw) return []; const defaultBranch = git("symbolic-ref --short HEAD 2>/dev/null") || "main"; const branches: BranchInfo[] = []; for (const line of raw.split("\n")) { const current = line.startsWith("*"); const m = line.match(/^[* ]\s+(\S+)\s+([a-f0-9]+)\s+(?:\[([^\]]+)\]\s+)?(.*)/); if (!m) continue; const [, name, , tracking, lastCommit] = m; let ahead = 0, behind = 0, remote = ""; if (tracking) { remote = tracking.split(":")[0]; const aheadMatch = tracking.match(/ahead (\d+)/); const behindMatch = tracking.match(/behind (\d+)/); if (aheadMatch) ahead = parseInt(aheadMatch[1]); if (behindMatch) behind = parseInt(behindMatch[1]); } // Get ahead/behind vs default branch if not current if (name !== defaultBranch && !ahead && !behind) { const ab = git(`rev-list --left-right --count ${defaultBranch}...${name}`); if (ab) { const parts = ab.split(/\s+/); if (parts.length === 2) { behind = parseInt(parts[0]) || 0; ahead = parseInt(parts[1]) || 0; } } } branches.push({ name, current, lastCommit: lastCommit.slice(0, 60), ahead, behind, remote }); } return branches; } export default function (api: ExtensionAPI) { // /git-graph api.registerCommand({ name: "git-graph", description: "ASCII commit graph", args: [{ name: "count", description: "Number of commits (default: 30)", required: false }], handler: async (args, ctx) => { if (!isGitRepo()) { ctx.ui.notify("Not a git repository", "error"); return; } const count = parseInt(ctx.args[0] || "30") || 30; const commits = parseLog(count); const lines = renderGraph(commits); const repoName = process.cwd().split(/[\\/]/).pop() || ""; const header = `${B}${CYAN}── ${repoName} · git graph (${commits.length} commits) ──${RST}\n`; ctx.ui.notify(header + lines.join("\n"), "info"); }, }); // /git-stats api.registerCommand({ name: "git-stats", description: "Contributor stats and file churn", args: [{ name: "days", description: "Days of history (default: 30)", required: false }], handler: async (args, ctx) => { if (!isGitRepo()) { ctx.ui.notify("Not a git repository", "error"); return; } const days = parseInt(ctx.args[0] || "30") || 30; const lines: string[] = []; lines.push(`${B}${CYAN}── Git Stats (${days}d) ──${RST}`); // Total const totalCommits = git(`rev-list --count --since="${days} days ago" HEAD`) || "0"; const totalAuthors = git(`shortlog -sne --since="${days} days ago" HEAD`).split("\n").filter(Boolean).length; lines.push(`\n ${D}Commits:${RST} ${B}${totalCommits}${RST} ${D}Contributors:${RST} ${B}${totalAuthors}${RST}`); // Commit frequency sparkline const freq = getCommitFrequency(days); if (freq.size > 0) { lines.push(`\n ${B}Commit Frequency${RST}`); const sorted = [...freq.entries()].sort((a, b) => a[0].localeCompare(b[0])); const maxFreq = Math.max(...sorted.map(s => s[1])); for (const [date, count] of sorted.slice(-14)) { // last 14 days const bar = "█".repeat(Math.max(1, Math.round(count / maxFreq * 25))); lines.push(` ${D}${date.slice(5)}${RST} ${GREEN}${bar}${RST} ${count}`); } } // Top contributors const authors = getAuthorStats().slice(0, 10); if (authors.length > 0) { lines.push(`\n ${B}Top Contributors${RST}`); const maxCommits = authors[0].commits; for (const a of authors) { const bar = "█".repeat(Math.max(1, Math.round(a.commits / maxCommits * 20))); lines.push(` ${a.name.padEnd(25)} ${GREEN}${bar}${RST} ${a.commits}`); } } // File churn const churn = getFileChurn(days); if (churn.length > 0) { lines.push(`\n ${B}Most Changed Files${RST}`); for (const { file, changes } of churn.slice(0, 10)) { const color = changes > 10 ? RED : changes > 5 ? YELLOW : GREEN; lines.push(` ${color}${String(changes).padStart(3)}${RST} ${D}${file}${RST}`); } } ctx.ui.notify(lines.join("\n"), "info"); }, }); // /git-branches api.registerCommand({ name: "git-branches", description: "Branch overview with ahead/behind status", handler: async (args, ctx) => { if (!isGitRepo()) { ctx.ui.notify("Not a git repository", "error"); return; } const branches = getBranches(); const lines: string[] = [`${B}${CYAN}── Branches (${branches.length}) ──${RST}`]; for (const b of branches) { const marker = b.current ? `${GREEN}▸${RST} ` : " "; const nameColor = b.current ? `${B}${GREEN}` : ""; let status = ""; if (b.ahead > 0) status += `${GREEN}↑${b.ahead}${RST}`; if (b.behind > 0) status += `${RED}↓${b.behind}${RST}`; if (b.ahead === 0 && b.behind === 0) status = `${D}≡${RST}`; const remote = b.remote ? ` ${D}→ ${b.remote}${RST}` : ""; lines.push(`${marker}${nameColor}${b.name.padEnd(30)}${RST} ${status.padEnd(20)} ${D}${b.lastCommit}${RST}${remote}`); } // Stale branches (no commits in 30 days) const stale = branches.filter(b => !b.current && b.behind > 20); if (stale.length > 0) { lines.push(`\n ${YELLOW}${B}Stale branches (>20 behind):${RST}`); for (const b of stale) { lines.push(` ${YELLOW}${b.name}${RST} ${D}(${b.behind} behind)${RST}`); } } ctx.ui.notify(lines.join("\n"), "info"); }, }); // LLM tool api.registerTool({ name: "git_graph", description: "Get git commit history, branch info, and contributor stats", parameters: Type.Object({ count: Type.Optional(Type.Number({ description: "Number of commits (default: 20)" })), branches: Type.Optional(Type.Boolean({ description: "Include branch info" })), stats: Type.Optional(Type.Boolean({ description: "Include contributor stats" })), }), execute: async (args) => { if (!isGitRepo()) return "Not a git repository"; const count = args.count || 20; const result: Record = {}; // Commits const commits = parseLog(count); result.commits = commits.map(c => ({ hash: c.shortHash, subject: c.subject, author: c.author, date: c.date, refs: c.refs || undefined, merge: c.parents.length > 1, })); if (args.branches) { result.branches = getBranches().map(b => ({ name: b.name, current: b.current, ahead: b.ahead, behind: b.behind, })); } if (args.stats) { result.contributors = getAuthorStats().slice(0, 10).map(a => ({ name: a.name, commits: a.commits, })); } return JSON.stringify(result, null, 2); }, }); }