#!/usr/bin/env bun /** * Graphite gatherer for status * * Collects stack and branch data via `gt` CLI: * - Stack structure and hierarchy * - Branch PR status * - Restack/submit needs * - Recent commits via git */ import { parseArgs } from "node:util"; import { parseTimeConstraint, toGitSince } from "../lib/time"; import type { GathererResult, GraphiteBranch, GraphiteData, } from "../lib/types"; const { values } = parseArgs({ args: Bun.argv.slice(2), options: { time: { type: "string", short: "t", default: "24h" }, help: { type: "boolean", short: "h" }, }, }); if (values.help) { console.log(` graphite-gatherer.ts - Gather Graphite stack data Usage: ./graphite-gatherer.ts [options] Options: -t, --time Time constraint for commits (24h, 7d, 2w) [default: 24h] -h, --help Show this help Output: JSON GathererResult with GraphiteData `); process.exit(0); } /** * Result of running a shell command. */ interface CmdOutput { success: boolean; stdout: string; stderr: string; } /** * Runs a shell command and captures output. * @param cmd - Command to run * @param args - Arguments to pass * @returns Command output */ async function runCmd(cmd: string, args: string[]): Promise { const proc = Bun.spawn([cmd, ...args], { stdout: "pipe", stderr: "pipe", }); const stdout = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); const exitCode = await proc.exited; return { success: exitCode === 0, stdout, stderr }; } /** * Checks if Graphite CLI is installed. * @returns True if gt is available */ async function checkGtAvailable(): Promise { const result = await runCmd("which", ["gt"]); return result.success; } /** * Checks if current directory is a git repository. * @returns True if in a git repo */ async function checkGitRepo(): Promise { const result = await runCmd("git", ["rev-parse", "--git-dir"]); return result.success; } /** * Gets Graphite stack state from gt CLI. * @returns Stack state or null on failure */ async function getGtState(): Promise<{ branches: GraphiteBranch[]; stacks: string[][]; currentBranch: string; trunk: string; } | null> { // Get structured state from gt const result = await runCmd("gt", ["log", "--json"]); if (!result.success) { // Try alternate: gt state const stateResult = await runCmd("gt", ["state"]); if (!stateResult.success) return null; // Parse text output as fallback return parseGtStateText(stateResult.stdout); } try { const data = JSON.parse(result.stdout); return parseGtLogJson(data); } catch { return null; } } /** * Parses JSON output from gt log command. * @param data - Raw JSON data * @returns Structured Graphite state */ function parseGtLogJson(data: unknown): { branches: GraphiteBranch[]; stacks: string[][]; currentBranch: string; trunk: string; } { // gt log --json returns array of branch entries const entries = Array.isArray(data) ? data : []; const branchMap = new Map(); let currentBranch = "main"; const trunk = "main"; for (const entry of entries) { const branch: GraphiteBranch = { name: entry.branch || entry.name || "", prNumber: entry.pr?.number, prStatus: mapPrState(entry.pr?.state, entry.pr?.isDraft), prUrl: entry.pr?.url, parent: entry.parent, children: [], isCurrent: entry.isCurrent || entry.current || false, needsRestack: entry.needsRestack || false, needsSubmit: entry.needsSubmit || false, commitCount: entry.commitCount || entry.commits?.length || 0, }; if (branch.isCurrent) { currentBranch = branch.name; } branchMap.set(branch.name, branch); } // Build children relationships for (const branch of branchMap.values()) { if (branch.parent && branchMap.has(branch.parent)) { branchMap.get(branch.parent)?.children.push(branch.name); } } // Build stacks (branches that share a root) const stacks = buildStacks(branchMap, trunk); return { branches: Array.from(branchMap.values()), stacks, currentBranch, trunk, }; } /** * Parses text output from gt state command (fallback). * @param text - Raw text output * @returns Structured Graphite state */ function parseGtStateText(text: string): { branches: GraphiteBranch[]; stacks: string[][]; currentBranch: string; trunk: string; } { // Fallback parser for text output const lines = text.split("\n").filter((l) => l.trim()); const branches: GraphiteBranch[] = []; let currentBranch = "main"; for (const line of lines) { // Look for branch indicators like "◉ branch-name" or "○ branch-name" const match = line.match(/[◉○●◐]\s+(\S+)/); if (match) { const name = match[1]; const isCurrent = line.includes("◉") || line.includes("●"); if (isCurrent) currentBranch = name; branches.push({ name, children: [], isCurrent, needsRestack: line.includes("restack"), needsSubmit: line.includes("submit"), commitCount: 0, }); } } return { branches, stacks: branches.length > 0 ? [branches.map((b) => b.name)] : [], currentBranch, trunk: "main", }; } /** * Maps PR state from API to internal status. * @param state - API state string * @param isDraft - Whether PR is a draft * @returns Normalized status */ function mapPrState( state?: string, isDraft?: boolean, ): "draft" | "open" | "ready" | "merged" | "closed" | undefined { if (!state) return undefined; if (isDraft) return "draft"; switch (state.toLowerCase()) { case "open": return "open"; case "merged": return "merged"; case "closed": return "closed"; default: return "open"; } } /** * Builds stack arrays from branch relationships. * @param branchMap - Map of branch names to branch data * @param trunk - Trunk branch name * @returns Array of stacks (each stack is array of branch names) */ function buildStacks( branchMap: Map, trunk: string, ): string[][] { const stacks: string[][] = []; const visited = new Set(); // Find root branches (parent is trunk or undefined) const roots = Array.from(branchMap.values()).filter( (b) => !b.parent || b.parent === trunk || !branchMap.has(b.parent), ); for (const root of roots) { if (visited.has(root.name)) continue; const stack: string[] = []; const queue = [root.name]; while (queue.length > 0) { const name = queue.shift(); if (!name || visited.has(name)) continue; visited.add(name); stack.push(name); const branch = branchMap.get(name); if (branch) { queue.push(...branch.children); } } if (stack.length > 0) { stacks.push(stack); } } return stacks; } /** * Gets count of recent commits within time window. * @param timeMs - Time window in milliseconds * @returns Number of commits */ async function getRecentCommits(timeMs: number): Promise { const since = toGitSince(timeMs); const result = await runCmd("git", ["log", `--since=${since}`, "--oneline"]); if (!result.success) return 0; return result.stdout.split("\n").filter((l) => l.trim()).length; } /** * Gathers Graphite stack and branch data. * @returns Gatherer result with Graphite data */ async function gatherGraphiteData(): Promise> { const timestamp = new Date().toISOString(); // Check prerequisites const gtAvailable = await checkGtAvailable(); if (!gtAvailable) { return { source: "graphite", status: "unavailable", reason: "gt CLI not installed", timestamp, }; } const isGitRepo = await checkGitRepo(); if (!isGitRepo) { return { source: "graphite", status: "unavailable", reason: "Not in a git repository", timestamp, }; } // Parse time constraint const timeValue = values.time ?? "24h"; let timeMs: number; try { timeMs = parseTimeConstraint(timeValue); } catch (e) { return { source: "graphite", status: "error", error: e instanceof Error ? e.message : "Invalid time constraint", timestamp, }; } // Get graphite state const state = await getGtState(); if (!state) { return { source: "graphite", status: "error", error: "Failed to parse gt output", timestamp, }; } // Get recent commit count (informational) const _recentCommits = await getRecentCommits(timeMs); return { source: "graphite", status: "success", data: { currentBranch: state.currentBranch, trunk: state.trunk, branches: state.branches, stacks: state.stacks, }, timestamp, }; } // Main execution const result = await gatherGraphiteData(); console.log(JSON.stringify(result, null, 2));