diff --git a/AGENTS.md b/AGENTS.md index 721ea4cbd..ff4dd8fe9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,5 @@ # Development Rules -## First Message - -If the user did not give you a concrete task in their first message, -read README.md, then ask which module(s) to work on. Based on the answer, read the relevant README.md files in parallel. - -- packages/ai/README.md -- packages/tui/README.md -- packages/agent/README.md -- packages/coding-agent/README.md -- packages/mom/README.md -- packages/web-ui/README.md - ## Code Quality - No `any` types unless absolutely necessary diff --git a/bun.lock b/bun.lock index 6308dd1f2..1b96e8fd8 100644 --- a/bun.lock +++ b/bun.lock @@ -63,6 +63,7 @@ "dependencies": { "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-ai": "workspace:*", + "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", "@openai/agents": "^0.3.7", "@sinclair/typebox": "^0.34.46", @@ -93,6 +94,14 @@ "vitest": "^3.2.4", }, }, + "packages/git-tool": { + "name": "@oh-my-pi/pi-git-tool", + "version": "3.15.1", + "devDependencies": { + "@types/node": "^24.3.0", + "vitest": "^3.2.4", + }, + }, "packages/mom": { "name": "@oh-my-pi/pi-mom", "version": "3.15.1", @@ -373,6 +382,8 @@ "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@workspace:packages/coding-agent"], + "@oh-my-pi/pi-git-tool": ["@oh-my-pi/pi-git-tool@workspace:packages/git-tool"], + "@oh-my-pi/pi-mom": ["@oh-my-pi/pi-mom@workspace:packages/mom"], "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@workspace:packages/tui"], @@ -1333,6 +1344,8 @@ "@oh-my-pi/pi-coding-agent/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + "@oh-my-pi/pi-git-tool/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], + "@oh-my-pi/pi-mom/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="], "@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], @@ -1433,6 +1446,8 @@ "@oh-my-pi/pi-coding-agent/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@oh-my-pi/pi-git-tool/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@oh-my-pi/pi-mom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@slack/logger/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 030947db3..dd2df9343 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -20,6 +20,10 @@ - Added completion notification system with configurable methods (bell, osc99, osc9, auto, off) when agent finishes - Added `completionNotification` setting to configure how the agent notifies on completion - Added `OMP_NOTIFICATIONS` environment variable to suppress notifications globally +- Added `/wt` slash command for git worktree management with create, list, merge, remove, status, spawn, and parallel operations +- Added worktree library with collapse strategies (simple, merge-base, rebase) for merging changes between worktrees +- Added worktree session tracking for managing agent tasks across isolated worktrees +- Added structured git tool with safety guards, caching, and GitHub operations - Added `cycleRoleModels()` method to cycle through configured role-based models in a fixed order with deduplication - Added language-specific file icons to LSP diagnostics output showing file locations - Added language-specific file icon to edit tool header display diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index fa621691b..30bc3ce86 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -41,6 +41,7 @@ "dependencies": { "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-ai": "workspace:*", + "@oh-my-pi/pi-git-tool": "workspace:*", "@oh-my-pi/pi-tui": "workspace:*", "@openai/agents": "^0.3.7", "@sinclair/typebox": "^0.34.46", diff --git a/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts new file mode 100644 index 000000000..5d6f6b630 --- /dev/null +++ b/packages/coding-agent/src/core/custom-commands/bundled/wt/index.ts @@ -0,0 +1,430 @@ +import * as path from "node:path"; +import { nanoid } from "nanoid"; +import { type CollapseStrategy, collapse } from "../../../../lib/worktree/collapse"; +import { WorktreeError, WorktreeErrorCode } from "../../../../lib/worktree/errors"; +import { getRepoRoot, git } from "../../../../lib/worktree/git"; +import * as worktree from "../../../../lib/worktree/index"; +import { createSession, updateSession } from "../../../../lib/worktree/session"; +import { formatStats, getStats } from "../../../../lib/worktree/stats"; +import type { HookCommandContext } from "../../../hooks/types"; +import { discoverAgents, getAgent } from "../../../tools/task/discovery"; +import { runSubprocess } from "../../../tools/task/executor"; +import type { AgentDefinition } from "../../../tools/task/types"; +import type { CustomCommand, CustomCommandAPI } from "../../types"; + +interface FlagParseResult { + positionals: string[]; + flags: Map; +} + +interface NewArgs { + branch: string; + base?: string; +} + +interface MergeArgs { + source: string; + target?: string; + strategy?: CollapseStrategy; + keep?: boolean; +} + +interface RmArgs { + name: string; + force?: boolean; +} + +interface SpawnArgs { + task: string; + scope?: string; + name?: string; +} + +interface ParallelTask { + task: string; + scope: string; +} + +function parseFlags(args: string[]): FlagParseResult { + const flags = new Map(); + const positionals: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith("--")) { + const name = arg.slice(2); + const next = args[i + 1]; + if (next && !next.startsWith("--")) { + flags.set(name, next); + i += 1; + } else { + flags.set(name, true); + } + } else { + positionals.push(arg); + } + } + + return { positionals, flags }; +} + +function getFlagValue(flags: Map, name: string): string | undefined { + const value = flags.get(name); + if (typeof value === "string") return value; + return undefined; +} + +function getFlagBoolean(flags: Map, name: string): boolean { + return flags.get(name) === true; +} + +function formatUsage(): string { + return [ + "Usage:", + " /wt new [--base ]", + " /wt list", + " /wt merge [dst] [--strategy simple|merge-base|rebase] [--keep]", + " /wt rm [--force]", + " /wt status", + ' /wt spawn "" [--scope ] [--name ]', + " /wt parallel --task --scope [--task --scope ]...", + ].join("\n"); +} + +function formatError(err: unknown): string { + if (err instanceof WorktreeError) { + return `${err.code}: ${err.message}`; + } + if (err instanceof Error) return err.message; + return String(err); +} + +function pickAgent(cwd: string): AgentDefinition { + const { agents } = discoverAgents(cwd); + // Use the bundled "task" agent as the general-purpose default. + const agent = getAgent(agents, "task") ?? agents[0]; + if (!agent) { + throw new Error("No agents available"); + } + return agent; +} + +function parseParallelTasks(args: string[]): ParallelTask[] { + const tasks: ParallelTask[] = []; + let current: Partial = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--task") { + const value = args[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error("Missing value for --task"); + } + current.task = value; + i += 1; + } else if (arg === "--scope") { + const value = args[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error("Missing value for --scope"); + } + current.scope = value; + i += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + + if (current.task && current.scope) { + tasks.push({ task: current.task, scope: current.scope }); + current = {}; + } + } + + if (current.task || current.scope) { + throw new Error("Each --task must be paired with a --scope"); + } + + return tasks; +} + +function validateDisjointScopes(scopes: string[]): void { + for (let i = 0; i < scopes.length; i++) { + for (let j = i + 1; j < scopes.length; j++) { + const a = scopes[i].replace(/\*.*$/, ""); + const b = scopes[j].replace(/\*.*$/, ""); + if (a.startsWith(b) || b.startsWith(a)) { + throw new WorktreeError( + `Overlapping scopes: "${scopes[i]}" and "${scopes[j]}"`, + WorktreeErrorCode.OVERLAPPING_SCOPES, + ); + } + } + } +} + +async function handleNew(args: NewArgs): Promise { + const wt = await worktree.create(args.branch, { base: args.base }); + + return [`Created worktree: ${wt.path}`, `Branch: ${wt.branch ?? "detached"}`, "", `To switch: cd ${wt.path}`].join( + "\n", + ); +} + +async function handleList(ctx: HookCommandContext): Promise { + const worktrees = await worktree.list(); + const cwd = path.resolve(ctx.cwd); + const mainPath = await getRepoRoot(); + + const lines: string[] = []; + + for (const wt of worktrees) { + const stats = await getStats(wt.path); + const isCurrent = cwd === wt.path || cwd.startsWith(wt.path + path.sep); + const isMain = wt.path === mainPath; + + const marker = isCurrent ? "->" : " "; + const mainTag = isMain ? " [main]" : ""; + const branch = wt.branch ?? "detached"; + const statsStr = formatStats(stats); + + lines.push(`${marker} ${branch}${mainTag} (${statsStr})`); + } + + return lines.join("\n") || "No worktrees found"; +} + +async function handleMerge(args: MergeArgs): Promise { + const target = args.target ?? "main"; + const strategy = args.strategy ?? "rebase"; + + const result = await collapse(args.source, target, { + strategy, + keepSource: args.keep, + }); + + const lines = [ + `Collapsed ${args.source} -> ${target}`, + `Strategy: ${strategy}`, + `Changes: +${result.insertions} -${result.deletions} in ${result.filesChanged} files`, + ]; + + if (!args.keep) { + lines.push("Source worktree removed"); + } + + return lines.join("\n"); +} + +async function handleRm(args: RmArgs): Promise { + const wt = await worktree.find(args.name); + await worktree.remove(args.name, { force: args.force }); + + const mainPath = await getRepoRoot(); + if (wt.branch) { + await git(["branch", "-D", wt.branch], mainPath); + return `Removed worktree and branch: ${wt.branch}`; + } + + return `Removed worktree: ${wt.path}`; +} + +async function handleStatus(): Promise { + const worktrees = await worktree.list(); + const sections: string[] = []; + + for (const wt of worktrees) { + const branch = wt.branch ?? "detached"; + const name = path.basename(wt.path); + + const statusResult = await git(["status", "--short"], wt.path); + const status = statusResult.stdout.trim() || "(clean)"; + + sections.push(`${name} (${branch})\n${"-".repeat(40)}\n${status}`); + } + + return sections.join("\n\n"); +} + +async function handleSpawn(args: SpawnArgs, ctx: HookCommandContext): Promise { + const branch = args.name ?? `wt-agent-${nanoid(6)}`; + const wt = await worktree.create(branch); + + const session = await createSession({ + branch, + path: wt.path, + scope: args.scope ? [args.scope] : undefined, + task: args.task, + }); + await updateSession(session.id, { status: "active" }); + + const agent = pickAgent(ctx.cwd); + const context = args.scope ? `Scope: ${args.scope}` : undefined; + + // Command context doesn't expose a spawn API, so run the task subprocess directly. + const result = await runSubprocess({ + cwd: wt.path, + agent, + task: args.task, + index: 0, + context, + }); + + await updateSession(session.id, { + status: result.exitCode === 0 ? "completed" : "failed", + completedAt: Date.now(), + }); + + if (result.exitCode !== 0) { + return [ + `Agent failed in worktree: ${branch}`, + result.stderr.trim() ? `Error: ${result.stderr.trim()}` : "Error: agent execution failed", + "", + "Actions:", + ` /wt merge ${branch} - Apply changes to main`, + " /wt status - Inspect changes", + ` /wt rm ${branch} - Discard changes`, + ].join("\n"); + } + + return [ + `Agent completed in worktree: ${branch}`, + "", + "Actions:", + ` /wt merge ${branch} - Apply changes to main`, + " /wt status - Inspect changes", + ` /wt rm ${branch} - Discard changes`, + ].join("\n"); +} + +async function handleParallel(args: ParallelTask[], ctx: HookCommandContext): Promise { + validateDisjointScopes(args.map((t) => t.scope)); + + const sessionId = `parallel-${Date.now()}`; + const agent = pickAgent(ctx.cwd); + + const worktrees: Array<{ task: ParallelTask; wt: worktree.Worktree; session: worktree.WorktreeSession }> = []; + for (let i = 0; i < args.length; i++) { + const task = args[i]; + const branch = `wt-parallel-${sessionId}-${i}`; + const wt = await worktree.create(branch); + const session = await createSession({ + branch, + path: wt.path, + scope: [task.scope], + task: task.task, + }); + worktrees.push({ task, wt, session }); + } + + const agentPromises = worktrees.map(async ({ task, wt, session }, index) => { + await updateSession(session.id, { status: "active" }); + const result = await runSubprocess({ + cwd: wt.path, + agent, + task: task.task, + index, + context: `Scope: ${task.scope}`, + }); + await updateSession(session.id, { + status: result.exitCode === 0 ? "completed" : "failed", + completedAt: Date.now(), + }); + return { wt, session, result }; + }); + + const results = await Promise.all(agentPromises); + + const mergeResults: string[] = []; + + for (const { wt, session } of results) { + try { + await updateSession(session.id, { status: "merging" }); + const collapseResult = await collapse(wt.branch ?? wt.path, "main", { + strategy: "simple", + keepSource: false, + }); + await updateSession(session.id, { status: "merged" }); + mergeResults.push( + `ok ${wt.branch ?? path.basename(wt.path)}: +${collapseResult.insertions} -${collapseResult.deletions}`, + ); + } catch (err) { + await updateSession(session.id, { status: "failed" }); + mergeResults.push(`err ${wt.branch ?? path.basename(wt.path)}: ${formatError(err)}`); + } + } + + return [`Parallel execution complete (${args.length} agents)`, "", "Results:", ...mergeResults].join("\n"); +} + +export function createWorktreeCommand(_api: CustomCommandAPI): CustomCommand { + return { + name: "wt", + description: "Git worktree management", + async execute(args: string[], ctx: HookCommandContext): Promise { + if (args.length === 0) return formatUsage(); + + const subcommand = args[0]; + const rest = args.slice(1); + + try { + switch (subcommand) { + case "new": { + const parsed = parseFlags(rest); + const branch = parsed.positionals[0]; + if (!branch) return formatUsage(); + const base = getFlagValue(parsed.flags, "base"); + if (parsed.flags.get("base") === true) { + return "Missing value for --base"; + } + return await handleNew({ branch, base }); + } + case "list": + return await handleList(ctx); + case "merge": { + const parsed = parseFlags(rest); + const source = parsed.positionals[0]; + const target = parsed.positionals[1]; + if (!source) return formatUsage(); + const strategyRaw = getFlagValue(parsed.flags, "strategy"); + if (parsed.flags.get("strategy") === true) { + return "Missing value for --strategy"; + } + const strategy = strategyRaw as CollapseStrategy | undefined; + const keep = getFlagBoolean(parsed.flags, "keep"); + return await handleMerge({ source, target, strategy, keep }); + } + case "rm": { + const parsed = parseFlags(rest); + const name = parsed.positionals[0]; + if (!name) return formatUsage(); + const force = getFlagBoolean(parsed.flags, "force"); + return await handleRm({ name, force }); + } + case "status": + return await handleStatus(); + case "spawn": { + const parsed = parseFlags(rest); + const task = parsed.positionals[0]; + if (!task) return formatUsage(); + const scope = getFlagValue(parsed.flags, "scope"); + if (parsed.flags.get("scope") === true) { + return "Missing value for --scope"; + } + const name = getFlagValue(parsed.flags, "name"); + return await handleSpawn({ task, scope, name }, ctx); + } + case "parallel": { + const tasks = parseParallelTasks(rest); + if (tasks.length === 0) return formatUsage(); + return await handleParallel(tasks, ctx); + } + default: + return formatUsage(); + } + } catch (err) { + return formatError(err); + } + }, + }; +} + +export default createWorktreeCommand; diff --git a/packages/coding-agent/src/core/custom-commands/loader.ts b/packages/coding-agent/src/core/custom-commands/loader.ts index f75d0d8f0..b87b28082 100644 --- a/packages/coding-agent/src/core/custom-commands/loader.ts +++ b/packages/coding-agent/src/core/custom-commands/loader.ts @@ -12,6 +12,7 @@ import { getAgentDir, getConfigDirs } from "../../config"; import * as piCodingAgent from "../../index"; import { execCommand } from "../exec"; import { createReviewCommand } from "./bundled/review"; +import { createWorktreeCommand } from "./bundled/wt"; import type { CustomCommand, CustomCommandAPI, @@ -151,6 +152,14 @@ function loadBundledCommands(sharedApi: CustomCommandAPI): LoadedCustomCommand[] source: "bundled", }); + const worktreeCommand = createWorktreeCommand(sharedApi); + bundled.push({ + path: "bundled:wt", + resolvedPath: "bundled:wt", + command: worktreeCommand, + source: "bundled", + }); + return bundled; } diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index f29757b05..baf50c9c8 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -77,6 +77,7 @@ import { createCodingTools, createEditTool, createFindTool, + createGitTool, createGrepTool, createLsTool, createReadOnlyTools, @@ -86,6 +87,7 @@ import { editTool, filterRulebookRules, findTool, + gitTool, grepTool, lsTool, readOnlyTools, @@ -187,6 +189,7 @@ export { writeTool, grepTool, findTool, + gitTool, lsTool, codingTools, readOnlyTools, @@ -200,6 +203,7 @@ export { createWriteTool, createGrepTool, createFindTool, + createGitTool, createLsTool, }; diff --git a/packages/coding-agent/src/core/tools/git.ts b/packages/coding-agent/src/core/tools/git.ts new file mode 100644 index 000000000..b42df53c5 --- /dev/null +++ b/packages/coding-agent/src/core/tools/git.ts @@ -0,0 +1,216 @@ +import type { AgentTool } from "@oh-my-pi/pi-agent-core"; +import { type GitParams, gitTool as gitToolCore, type ToolResponse } from "@oh-my-pi/pi-git-tool"; +import { type Static, Type } from "@sinclair/typebox"; +import gitDescription from "../../prompts/tools/git.md" with { type: "text" }; + +const gitSchema = Type.Object({ + operation: Type.Union([ + Type.Literal("status"), + Type.Literal("diff"), + Type.Literal("log"), + Type.Literal("show"), + Type.Literal("blame"), + Type.Literal("branch"), + Type.Literal("add"), + Type.Literal("restore"), + Type.Literal("commit"), + Type.Literal("checkout"), + Type.Literal("merge"), + Type.Literal("rebase"), + Type.Literal("stash"), + Type.Literal("cherry-pick"), + Type.Literal("fetch"), + Type.Literal("pull"), + Type.Literal("push"), + Type.Literal("tag"), + Type.Literal("pr"), + Type.Literal("issue"), + Type.Literal("ci"), + Type.Literal("release"), + ]), + + // Status + only: Type.Optional( + Type.Union([ + Type.Literal("branch"), + Type.Literal("modified"), + Type.Literal("staged"), + Type.Literal("untracked"), + Type.Literal("conflicts"), + Type.Literal("sync"), + ]), + ), + ignored: Type.Optional(Type.Boolean()), + + // Diff + target: Type.Optional( + Type.Union([ + Type.Literal("unstaged"), + Type.Literal("staged"), + Type.Literal("head"), + Type.Object({ + from: Type.String(), + to: Type.Optional(Type.String()), + }), + Type.String(), + ]), + ), + paths: Type.Optional(Type.Array(Type.String())), + stat_only: Type.Optional(Type.Boolean()), + name_only: Type.Optional(Type.Boolean()), + context: Type.Optional(Type.Number()), + max_lines: Type.Optional(Type.Number()), + ignore_whitespace: Type.Optional(Type.Boolean()), + + // Log + limit: Type.Optional(Type.Number()), + ref: Type.Optional(Type.String()), + author: Type.Optional(Type.String()), + since: Type.Optional(Type.String()), + until: Type.Optional(Type.String()), + grep: Type.Optional(Type.String()), + format: Type.Optional(Type.Union([Type.Literal("oneline"), Type.Literal("short"), Type.Literal("full")])), + stat: Type.Optional(Type.Boolean()), + merges: Type.Optional(Type.Boolean()), + first_parent: Type.Optional(Type.Boolean()), + + // Show + path: Type.Optional(Type.String()), + diff: Type.Optional(Type.Boolean()), + lines: Type.Optional( + Type.Object({ + start: Type.Number(), + end: Type.Number(), + }), + ), + + // Blame + root: Type.Optional(Type.Boolean()), + + // Branch + action: Type.Optional( + Type.Union([ + Type.Literal("list"), + Type.Literal("create"), + Type.Literal("delete"), + Type.Literal("rename"), + Type.Literal("current"), + ]), + ), + name: Type.Optional(Type.String()), + newName: Type.Optional(Type.String()), + startPoint: Type.Optional(Type.String()), + remotes: Type.Optional(Type.Boolean()), + force: Type.Optional(Type.Boolean()), + + // Add/Restore + update: Type.Optional(Type.Boolean()), + all: Type.Optional(Type.Boolean()), + dry_run: Type.Optional(Type.Boolean()), + staged: Type.Optional(Type.Boolean()), + worktree: Type.Optional(Type.Boolean()), + source: Type.Optional(Type.String()), + + // Commit + message: Type.Optional(Type.String()), + allow_empty: Type.Optional(Type.Boolean()), + sign: Type.Optional(Type.Boolean()), + no_verify: Type.Optional(Type.Boolean()), + amend: Type.Optional(Type.Boolean()), + + // Checkout + create: Type.Optional(Type.Boolean()), + + // Merge + no_ff: Type.Optional(Type.Boolean()), + ff_only: Type.Optional(Type.Boolean()), + squash: Type.Optional(Type.Boolean()), + abort: Type.Optional(Type.Boolean()), + continue: Type.Optional(Type.Boolean()), + + // Rebase + onto: Type.Optional(Type.String()), + upstream: Type.Optional(Type.String()), + skip: Type.Optional(Type.Boolean()), + + // Stash + include_untracked: Type.Optional(Type.Boolean()), + index: Type.Optional(Type.Number()), + keep_index: Type.Optional(Type.Boolean()), + + // Cherry-pick + commits: Type.Optional(Type.Array(Type.String())), + no_commit: Type.Optional(Type.Boolean()), + + // Fetch/Pull/Push/Tag + remote: Type.Optional(Type.String()), + branch: Type.Optional(Type.String()), + prune: Type.Optional(Type.Boolean()), + tags: Type.Optional(Type.Boolean()), + rebase: Type.Optional(Type.Boolean()), + set_upstream: Type.Optional(Type.Boolean()), + force_with_lease: Type.Optional(Type.Boolean()), + delete: Type.Optional(Type.Boolean()), + force_override: Type.Optional(Type.Boolean()), + + // Tag + // (name/message/ref already covered) + + // PR + number: Type.Optional(Type.Number()), + title: Type.Optional(Type.String()), + body: Type.Optional(Type.String()), + base: Type.Optional(Type.String()), + head: Type.Optional(Type.String()), + draft: Type.Optional(Type.Boolean()), + state: Type.Optional( + Type.Union([Type.Literal("open"), Type.Literal("closed"), Type.Literal("merged"), Type.Literal("all")]), + ), + merge_method: Type.Optional(Type.Union([Type.Literal("merge"), Type.Literal("squash"), Type.Literal("rebase")])), + review_action: Type.Optional( + Type.Union([Type.Literal("approve"), Type.Literal("request-changes"), Type.Literal("comment")]), + ), + review_body: Type.Optional(Type.String()), + + // Issue + labels: Type.Optional(Type.Array(Type.String())), + assignee: Type.Optional(Type.String()), + comment_body: Type.Optional(Type.String()), + + // CI + workflow: Type.Optional(Type.String()), + run_id: Type.Optional(Type.Number()), + inputs: Type.Optional(Type.Record(Type.String(), Type.String())), + logs_failed: Type.Optional(Type.Boolean()), + + // Release + notes: Type.Optional(Type.String()), + generate_notes: Type.Optional(Type.Boolean()), + prerelease: Type.Optional(Type.Boolean()), + assets: Type.Optional(Type.Array(Type.String())), +}); + +export type GitToolDetails = ToolResponse; + +export function createGitTool(cwd: string): AgentTool { + return { + name: "git", + label: "Git", + description: gitDescription, + parameters: gitSchema, + execute: async (_toolCallId, params: Static, _signal?: AbortSignal) => { + const result = await gitToolCore(params as GitParams, cwd); + if ("error" in result) { + const message = result._rendered ?? result.error; + return { content: [{ type: "text", text: message }], details: result }; + } + if ("confirm" in result) { + const message = result._rendered ?? result.confirm; + return { content: [{ type: "text", text: message }], details: result }; + } + return { content: [{ type: "text", text: result._rendered }], details: result }; + }, + }; +} + +export const gitTool = createGitTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 7ae0a22f9..bcbda3c82 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -5,6 +5,7 @@ export { createEditTool, type EditToolOptions, editTool } from "./edit"; export { exaTools } from "./exa/index"; export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types"; export { createFindTool, type FindToolDetails, findTool } from "./find"; +export { createGitTool, type GitToolDetails, gitTool } from "./git"; export { createGrepTool, type GrepToolDetails, grepTool } from "./grep"; export { createLsTool, type LsToolDetails, lsTool } from "./ls"; export { @@ -57,6 +58,7 @@ import { bashTool, createBashTool } from "./bash"; import { checkBashInterception, checkSimpleLsInterception } from "./bash-interceptor"; import { createEditTool, editTool } from "./edit"; import { createFindTool, findTool } from "./find"; +import { createGitTool, gitTool } from "./git"; import { createGrepTool, grepTool } from "./grep"; import { createLsTool, lsTool } from "./ls"; import { createLspTool, createLspWritethrough, lspTool } from "./lsp/index"; @@ -125,6 +127,7 @@ const toolDefs: Record = { }, grep: { tool: grepTool, create: createGrepTool }, find: { tool: findTool, create: createFindTool }, + git: { tool: gitTool, create: createGitTool }, ls: { tool: lsTool, create: createLsTool }, lsp: { tool: lspTool, create: createLspTool }, notebook: { tool: notebookTool, create: createNotebookTool }, @@ -149,6 +152,7 @@ const baseCodingToolNames: ToolName[] = [ "write", "grep", "find", + "git", "ls", "lsp", "notebook", diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 85f14cf98..5243f9534 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -160,7 +160,9 @@ export { editTool, type FindToolDetails, findTool, + type GitToolDetails, type GrepToolDetails, + gitTool, grepTool, type LsToolDetails, lsTool, diff --git a/packages/coding-agent/src/lib/worktree/collapse.ts b/packages/coding-agent/src/lib/worktree/collapse.ts new file mode 100644 index 000000000..c864bb184 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/collapse.ts @@ -0,0 +1,179 @@ +import { WorktreeError, WorktreeErrorCode } from "./errors"; +import { git, gitWithStdin } from "./git"; +import { find, remove, type Worktree } from "./operations"; + +export type CollapseStrategy = "simple" | "merge-base" | "rebase"; + +export interface CollapseOptions { + strategy?: CollapseStrategy; + keepSource?: boolean; +} + +export interface CollapseResult { + filesChanged: number; + insertions: number; + deletions: number; +} + +function diffStats(diff: string): CollapseResult { + let filesChanged = 0; + let insertions = 0; + let deletions = 0; + + for (const line of diff.split("\n")) { + if (line.startsWith("diff --git ")) { + filesChanged += 1; + continue; + } + if (line.startsWith("+++") || line.startsWith("---")) continue; + if (line.startsWith("+")) { + insertions += 1; + continue; + } + if (line.startsWith("-")) { + deletions += 1; + } + } + + return { filesChanged, insertions, deletions }; +} + +async function requireGitSuccess(result: { code: number; stderr: string }, message: string): Promise { + if (result.code !== 0) { + throw new WorktreeError( + message + (result.stderr ? `\n${result.stderr.trim()}` : ""), + WorktreeErrorCode.COLLAPSE_FAILED, + ); + } +} + +async function ensureHasChanges(result: { stdout: string }): Promise { + const diff = result.stdout; + if (!diff.trim()) { + throw new WorktreeError("No changes to collapse", WorktreeErrorCode.NO_CHANGES); + } + return diff; +} + +async function collapseSimple(src: Worktree): Promise { + await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes"); + return ensureHasChanges(await git(["diff", "HEAD"], src.path)); +} + +async function collapseMergeBase(src: Worktree, dst: Worktree): Promise { + await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes"); + + const baseResult = await git(["merge-base", "HEAD", dst.branch ?? "HEAD"], src.path); + if (baseResult.code !== 0) { + throw new WorktreeError("Could not find merge base", WorktreeErrorCode.COLLAPSE_FAILED); + } + + const base = baseResult.stdout.trim(); + if (!base) { + throw new WorktreeError("Could not find merge base", WorktreeErrorCode.COLLAPSE_FAILED); + } + + return ensureHasChanges(await git(["diff", base], src.path)); +} + +async function collapseRebase(src: Worktree, dst: Worktree): Promise { + await requireGitSuccess(await git(["add", "-A"], src.path), "Failed to stage changes"); + + const stagedResult = await git(["diff", "--cached", "--name-only"], src.path); + if (!stagedResult.stdout.trim()) { + throw new WorktreeError("No changes to collapse", WorktreeErrorCode.NO_CHANGES); + } + + const headResult = await git(["rev-parse", "HEAD"], src.path); + if (headResult.code !== 0) { + throw new WorktreeError("Failed to resolve HEAD", WorktreeErrorCode.COLLAPSE_FAILED); + } + const originalHead = headResult.stdout.trim(); + const tempBranch = `wt-collapse-${Date.now()}`; + + await requireGitSuccess(await git(["checkout", "-b", tempBranch], src.path), "Failed to create temp branch"); + + const commitResult = await git(["commit", "--allow-empty-message", "-m", ""], src.path); + if (commitResult.code !== 0) { + await git(["checkout", originalHead], src.path); + await git(["branch", "-D", tempBranch], src.path); + throw new WorktreeError("Failed to commit changes", WorktreeErrorCode.COLLAPSE_FAILED); + } + + const rebaseResult = await git(["rebase", dst.branch ?? "HEAD"], src.path); + if (rebaseResult.code !== 0) { + await git(["rebase", "--abort"], src.path); + await git(["checkout", originalHead], src.path); + await git(["branch", "-D", tempBranch], src.path); + throw new WorktreeError( + `Rebase conflicts:${rebaseResult.stderr ? `\n${rebaseResult.stderr.trim()}` : ""}`, + WorktreeErrorCode.REBASE_CONFLICTS, + ); + } + + const diffResult = await git(["diff", `${dst.branch ?? "HEAD"}..HEAD`], src.path); + + await git(["checkout", originalHead], src.path); + await git(["branch", "-D", tempBranch], src.path); + + return ensureHasChanges(diffResult); +} + +async function applyDiff(diff: string, targetPath: string): Promise { + let result = await gitWithStdin(["apply"], diff, targetPath); + if (result.code === 0) return; + + result = await gitWithStdin(["apply", "--3way"], diff, targetPath); + if (result.code === 0) return; + + throw new WorktreeError( + `Failed to apply diff:${result.stderr ? `\n${result.stderr.trim()}` : ""}`, + WorktreeErrorCode.APPLY_FAILED, + ); +} + +/** + * Collapse changes from source worktree into destination. + */ +export async function collapse( + source: string, + destination: string, + options?: CollapseOptions, +): Promise { + const src = await find(source); + const dst = await find(destination); + + if (src.path === dst.path) { + throw new WorktreeError("Source and destination are the same", WorktreeErrorCode.COLLAPSE_FAILED); + } + + if (!options?.keepSource && src.isMain) { + throw new WorktreeError("Cannot remove main worktree", WorktreeErrorCode.CANNOT_MODIFY_MAIN); + } + + const strategy = options?.strategy ?? "rebase"; + let diff: string; + + switch (strategy) { + case "simple": + diff = await collapseSimple(src); + break; + case "merge-base": + diff = await collapseMergeBase(src, dst); + break; + case "rebase": + diff = await collapseRebase(src, dst); + break; + default: + throw new WorktreeError(`Unknown strategy: ${strategy}`, WorktreeErrorCode.COLLAPSE_FAILED); + } + + const stats = diffStats(diff); + await applyDiff(diff, dst.path); + + if (!options?.keepSource) { + await remove(src.path); + } + + return stats; +} diff --git a/packages/coding-agent/src/lib/worktree/constants.ts b/packages/coding-agent/src/lib/worktree/constants.ts new file mode 100644 index 000000000..0ba0a78f3 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/constants.ts @@ -0,0 +1 @@ +export const WORKTREE_BASE = "/work/.tree"; diff --git a/packages/coding-agent/src/lib/worktree/errors.ts b/packages/coding-agent/src/lib/worktree/errors.ts new file mode 100644 index 000000000..cad53a2d7 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/errors.ts @@ -0,0 +1,23 @@ +export enum WorktreeErrorCode { + NOT_GIT_REPO = "NOT_GIT_REPO", + WORKTREE_NOT_FOUND = "WORKTREE_NOT_FOUND", + WORKTREE_EXISTS = "WORKTREE_EXISTS", + CANNOT_MODIFY_MAIN = "CANNOT_MODIFY_MAIN", + NO_CHANGES = "NO_CHANGES", + COLLAPSE_FAILED = "COLLAPSE_FAILED", + REBASE_CONFLICTS = "REBASE_CONFLICTS", + APPLY_FAILED = "APPLY_FAILED", + OVERLAPPING_SCOPES = "OVERLAPPING_SCOPES", +} + +export class WorktreeError extends Error { + readonly code: WorktreeErrorCode; + readonly cause?: Error; + + constructor(message: string, code: WorktreeErrorCode, cause?: Error) { + super(message); + this.name = "WorktreeError"; + this.code = code; + this.cause = cause; + } +} diff --git a/packages/coding-agent/src/lib/worktree/git.ts b/packages/coding-agent/src/lib/worktree/git.ts new file mode 100644 index 000000000..3b10492ee --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/git.ts @@ -0,0 +1,110 @@ +import * as path from "node:path"; +import type { Subprocess } from "bun"; +import { execCommand } from "../../core/exec"; +import { WorktreeError, WorktreeErrorCode } from "./errors"; + +export interface GitResult { + code: number; + stdout: string; + stderr: string; +} + +type WritableLike = { + write: (chunk: string | Uint8Array) => unknown; + flush?: () => unknown; + end?: () => unknown; +}; + +const textEncoder = new TextEncoder(); + +async function readStream(stream: ReadableStream | undefined): Promise { + if (!stream) return ""; + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks).toString(); +} + +async function writeStdin(handle: unknown, stdin: string): Promise { + if (!handle || typeof handle === "number") return; + if (typeof (handle as WritableStream).getWriter === "function") { + const writer = (handle as WritableStream).getWriter(); + try { + await writer.write(textEncoder.encode(stdin)); + } finally { + await writer.close(); + } + return; + } + + const sink = handle as WritableLike; + sink.write(stdin); + if (sink.flush) sink.flush(); + if (sink.end) sink.end(); +} + +/** + * Execute a git command. + * @param args - Command arguments (excluding 'git') + * @param cwd - Working directory (optional) + * @returns Promise + */ +export async function git(args: string[], cwd?: string): Promise { + const result = await execCommand("git", args, cwd ?? process.cwd()); + return { code: result.code, stdout: result.stdout, stderr: result.stderr }; +} + +/** + * Execute git command with stdin input. + * Used for piping diffs to `git apply`. + */ +export async function gitWithStdin(args: string[], stdin: string, cwd?: string): Promise { + const proc: Subprocess = Bun.spawn(["git", ...args], { + cwd: cwd ?? process.cwd(), + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + await writeStdin(proc.stdin, stdin); + + const [stdout, stderr, exitCode] = await Promise.all([ + readStream(proc.stdout as ReadableStream), + readStream(proc.stderr as ReadableStream), + proc.exited, + ]); + + return { code: exitCode ?? 0, stdout, stderr }; +} + +/** + * Get repository root directory. + * @throws Error if not in a git repository + */ +export async function getRepoRoot(cwd?: string): Promise { + const result = await git(["rev-parse", "--show-toplevel"], cwd ?? process.cwd()); + if (result.code !== 0) { + throw new WorktreeError("Not a git repository", WorktreeErrorCode.NOT_GIT_REPO); + } + const root = result.stdout.trim(); + if (!root) { + throw new WorktreeError("Not a git repository", WorktreeErrorCode.NOT_GIT_REPO); + } + return path.resolve(root); +} + +/** + * Get repository name (directory basename of repo root). + */ +export async function getRepoName(cwd?: string): Promise { + const root = await getRepoRoot(cwd); + return path.basename(root); +} diff --git a/packages/coding-agent/src/lib/worktree/index.ts b/packages/coding-agent/src/lib/worktree/index.ts new file mode 100644 index 000000000..a4c7509c5 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/index.ts @@ -0,0 +1,23 @@ +export { type CollapseOptions, type CollapseResult, type CollapseStrategy, collapse } from "./collapse"; +export { WORKTREE_BASE } from "./constants"; +export { WorktreeError, WorktreeErrorCode } from "./errors"; +export { getRepoName, getRepoRoot, git, gitWithStdin } from "./git"; +export { + create, + find, + list, + prune, + remove, + type Worktree, + which, +} from "./operations"; +export { + cleanupSessions, + createSession, + getSession, + listSessions, + type SessionStatus, + updateSession, + type WorktreeSession, +} from "./session"; +export { formatStats, getStats, type WorktreeStats } from "./stats"; diff --git a/packages/coding-agent/src/lib/worktree/operations.ts b/packages/coding-agent/src/lib/worktree/operations.ts new file mode 100644 index 000000000..d5aa2cd40 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/operations.ts @@ -0,0 +1,216 @@ +import { mkdir } from "node:fs/promises"; +import * as path from "node:path"; +import { WORKTREE_BASE } from "./constants"; +import { WorktreeError, WorktreeErrorCode } from "./errors"; +import { getRepoName, getRepoRoot, git } from "./git"; + +export interface Worktree { + path: string; + branch: string | null; + head: string; + isMain: boolean; + isDetached: boolean; +} + +type WorktreePartial = Partial & { isDetached?: boolean }; + +function finalizeWorktree(entry: WorktreePartial, repoRoot: string): Worktree { + const wtPath = entry.path?.trim(); + if (!wtPath) { + throw new Error("Invalid worktree entry"); + } + const branch = entry.isDetached ? null : (entry.branch ?? null); + const isDetached = entry.isDetached ?? branch === null; + return { + path: wtPath, + branch, + head: entry.head ?? "", + isMain: path.resolve(wtPath) === path.resolve(repoRoot), + isDetached, + }; +} + +function parseWorktreeList(output: string, repoRoot: string): Worktree[] { + const worktrees: Worktree[] = []; + let current: WorktreePartial = {}; + + for (const line of output.split("\n")) { + if (line.startsWith("worktree ")) { + if (current.path) { + worktrees.push(finalizeWorktree(current, repoRoot)); + } + current = { path: line.slice(9) }; + continue; + } + + if (line.startsWith("HEAD ")) { + current.head = line.slice(5); + continue; + } + + if (line.startsWith("branch ")) { + const raw = line.slice(7); + current.branch = raw.startsWith("refs/heads/") ? raw.slice("refs/heads/".length) : raw; + continue; + } + + if (line === "detached") { + current.isDetached = true; + } + } + + if (current.path) { + worktrees.push(finalizeWorktree(current, repoRoot)); + } + + return worktrees; +} + +/** + * Create a new worktree. + */ +export async function create(branch: string, options?: { base?: string; path?: string }): Promise { + const repoRoot = await getRepoRoot(); + const repoName = await getRepoName(); + const targetPath = options?.path ?? path.join(WORKTREE_BASE, repoName, branch); + const resolvedTarget = path.resolve(targetPath); + + const existing = await list(); + const conflict = existing.find((wt) => wt.branch === branch || path.resolve(wt.path) === resolvedTarget); + if (conflict) { + throw new WorktreeError(`Worktree already exists: ${conflict.path}`, WorktreeErrorCode.WORKTREE_EXISTS); + } + + await mkdir(path.dirname(resolvedTarget), { recursive: true }); + + const branchExists = (await git(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot)).code === 0; + + const args = branchExists + ? ["worktree", "add", resolvedTarget, branch] + : ["worktree", "add", "-b", branch, resolvedTarget, options?.base ?? "HEAD"]; + + const result = await git(args, repoRoot); + if (result.code !== 0) { + const stderr = result.stderr.trim(); + if (stderr.includes("already exists") || stderr.includes("already checked out")) { + throw new WorktreeError(stderr || "Worktree already exists", WorktreeErrorCode.WORKTREE_EXISTS); + } + throw new Error(stderr || "Failed to create worktree"); + } + + const updated = await list(); + const created = updated.find((wt) => path.resolve(wt.path) === resolvedTarget); + if (!created) { + throw new Error("Worktree created but not found in list"); + } + + return created; +} + +/** + * List all worktrees for current repository. + */ +export async function list(): Promise { + const repoRoot = await getRepoRoot(); + const result = await git(["worktree", "list", "--porcelain"], repoRoot); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "Failed to list worktrees"); + } + return parseWorktreeList(result.stdout, repoRoot); +} + +/** + * Find a worktree by pattern. + */ +export async function find(pattern: string): Promise { + const worktrees = await list(); + + const exactBranch = worktrees.filter((wt) => wt.branch === pattern); + if (exactBranch.length === 1) return exactBranch[0]; + if (exactBranch.length > 1) { + throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); + } + + const exactDir = worktrees.filter((wt) => path.basename(wt.path) === pattern); + if (exactDir.length === 1) return exactDir[0]; + if (exactDir.length > 1) { + throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); + } + + const partialBranch = worktrees.filter((wt) => wt.branch?.includes(pattern)); + if (partialBranch.length === 1) return partialBranch[0]; + if (partialBranch.length > 1) { + throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); + } + + const partialPath = worktrees.filter((wt) => wt.path.includes(pattern)); + if (partialPath.length === 1) return partialPath[0]; + if (partialPath.length > 1) { + throw new WorktreeError(`Ambiguous worktree: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); + } + + throw new WorktreeError(`Worktree not found: ${pattern}`, WorktreeErrorCode.WORKTREE_NOT_FOUND); +} + +/** + * Remove a worktree. + */ +export async function remove(nameOrPath: string, options?: { force?: boolean }): Promise { + const wt = await find(nameOrPath); + if (wt.isMain) { + throw new WorktreeError("Cannot remove main worktree", WorktreeErrorCode.CANNOT_MODIFY_MAIN); + } + + const repoRoot = await getRepoRoot(); + const args = ["worktree", "remove", wt.path]; + if (options?.force) args.push("--force"); + + const result = await git(args, repoRoot); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || "Failed to remove worktree"); + } +} + +/** + * Remove worktrees for branches that no longer exist. + */ +export async function prune(): Promise { + const repoRoot = await getRepoRoot(); + const worktrees = await list(); + let removed = 0; + + for (const wt of worktrees) { + if (wt.isMain || !wt.branch) continue; + const existsResult = await git(["rev-parse", "--verify", `refs/heads/${wt.branch}`], repoRoot); + if (existsResult.code === 0) continue; + + const result = await git(["worktree", "remove", wt.path], repoRoot); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `Failed to remove worktree: ${wt.path}`); + } + removed += 1; + } + + return removed; +} + +/** + * Get the worktree containing the given path. + * Returns null if path is not in any worktree. + */ +export async function which(targetPath?: string): Promise { + const worktrees = await list(); + const resolved = path.resolve(targetPath ?? process.cwd()); + + let best: Worktree | null = null; + for (const wt of worktrees) { + const wtPath = path.resolve(wt.path); + if (resolved === wtPath || resolved.startsWith(wtPath + path.sep)) { + if (!best || wtPath.length > best.path.length) { + best = wt; + } + } + } + + return best; +} diff --git a/packages/coding-agent/src/lib/worktree/session.ts b/packages/coding-agent/src/lib/worktree/session.ts new file mode 100644 index 000000000..804a92884 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/session.ts @@ -0,0 +1,114 @@ +import { mkdir } from "node:fs/promises"; +import * as path from "node:path"; +import { nanoid } from "nanoid"; +import { getRepoRoot, git } from "./git"; + +export interface WorktreeSession { + id: string; + branch: string; + path: string; + scope?: string[]; + agentId?: string; + task?: string; + status: SessionStatus; + createdAt: number; + completedAt?: number; +} + +export type SessionStatus = "creating" | "active" | "completed" | "merging" | "merged" | "failed" | "abandoned"; + +async function getSessionsFile(): Promise { + const repoRoot = await getRepoRoot(); + const result = await git(["rev-parse", "--git-common-dir"], repoRoot); + let gitDir = result.code === 0 ? result.stdout.trim() : ""; + if (!gitDir) { + gitDir = path.join(repoRoot, ".git"); + } + if (!path.isAbsolute(gitDir)) { + // Resolve relative git dir from repo root to keep sessions in the common dir. + gitDir = path.resolve(repoRoot, gitDir); + } + await mkdir(gitDir, { recursive: true }); + return path.join(gitDir, "worktree-sessions.json"); +} + +async function loadSessions(): Promise { + const filePath = await getSessionsFile(); + const file = Bun.file(filePath); + if (!(await file.exists())) return []; + try { + const data = await file.json(); + if (Array.isArray(data)) { + return data as WorktreeSession[]; + } + } catch { + return []; + } + return []; +} + +async function saveSessions(sessions: WorktreeSession[]): Promise { + const filePath = await getSessionsFile(); + await Bun.write(filePath, JSON.stringify(sessions, null, 2)); +} + +export async function createSession(params: { + branch: string; + path: string; + scope?: string[]; + task?: string; +}): Promise { + const sessions = await loadSessions(); + const session: WorktreeSession = { + id: nanoid(10), + branch: params.branch, + path: params.path, + scope: params.scope, + task: params.task, + status: "creating", + createdAt: Date.now(), + }; + + sessions.push(session); + await saveSessions(sessions); + return session; +} + +export async function updateSession(id: string, updates: Partial): Promise { + const sessions = await loadSessions(); + const idx = sessions.findIndex((s) => s.id === id); + if (idx === -1) return; + const current = sessions[idx]; + sessions[idx] = { ...current, ...updates, id: current.id }; + await saveSessions(sessions); +} + +export async function getSession(id: string): Promise { + const sessions = await loadSessions(); + return sessions.find((s) => s.id === id) ?? null; +} + +export async function listSessions(): Promise { + return loadSessions(); +} + +export async function cleanupSessions(): Promise { + const sessions = await loadSessions(); + let removed = 0; + + const remaining: WorktreeSession[] = []; + for (const session of sessions) { + const exists = await Bun.file(session.path).exists(); + if (!exists) { + removed += 1; + continue; + } + remaining.push(session); + } + + if (removed > 0) { + await saveSessions(remaining); + } + + return removed; +} diff --git a/packages/coding-agent/src/lib/worktree/stats.ts b/packages/coding-agent/src/lib/worktree/stats.ts new file mode 100644 index 000000000..dc94cfc45 --- /dev/null +++ b/packages/coding-agent/src/lib/worktree/stats.ts @@ -0,0 +1,67 @@ +import { git } from "./git"; + +export interface WorktreeStats { + additions: number; + deletions: number; + untracked: number; + modified: number; + staged: number; +} + +/** + * Get diff statistics for a worktree. + */ +export async function getStats(worktreePath: string): Promise { + const diffResult = await git(["diff", "HEAD", "--shortstat"], worktreePath); + + let additions = 0; + let deletions = 0; + + const statsLine = diffResult.stdout.trim(); + if (statsLine) { + const insertMatch = statsLine.match(/(\d+) insertion/); + const deleteMatch = statsLine.match(/(\d+) deletion/); + if (insertMatch) additions = parseInt(insertMatch[1], 10); + if (deleteMatch) deletions = parseInt(deleteMatch[1], 10); + } + + const untrackedResult = await git(["ls-files", "--others", "--exclude-standard"], worktreePath); + const untracked = untrackedResult.stdout.trim() ? untrackedResult.stdout.trim().split("\n").length : 0; + + const statusResult = await git(["status", "--porcelain"], worktreePath); + let modified = 0; + let staged = 0; + + for (const line of statusResult.stdout.split("\n")) { + if (!line) continue; + const index = line[0]; + const worktree = line[1]; + if (index !== " " && index !== "?") staged += 1; + if (worktree !== " " && worktree !== "?") modified += 1; + } + + return { additions, deletions, untracked, modified, staged }; +} + +/** + * Format stats for display. + * Returns "clean" or "+N -M ?U" format. + */ +export function formatStats(stats: WorktreeStats): string { + if ( + stats.additions === 0 && + stats.deletions === 0 && + stats.untracked === 0 && + stats.modified === 0 && + stats.staged === 0 + ) { + return "clean"; + } + + const parts: string[] = []; + if (stats.additions > 0) parts.push(`+${stats.additions}`); + if (stats.deletions > 0) parts.push(`-${stats.deletions}`); + if (stats.untracked > 0) parts.push(`?${stats.untracked}`); + + return parts.join(" ") || "clean"; +} diff --git a/packages/coding-agent/src/prompts/tools/git.md b/packages/coding-agent/src/prompts/tools/git.md new file mode 100644 index 000000000..0bb2972d8 --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/git.md @@ -0,0 +1,9 @@ +Structured Git operations with safety guards and typed output. Use this tool instead of raw git commands. + +Operations: +- READ: status, diff, log, show, blame, branch +- WRITE: add, restore, commit, checkout, merge, rebase, stash, cherry-pick +- REMOTE: fetch, pull, push, tag +- GITHUB: pr, issue, ci, release + +Returns structured data plus a rendered summary for display. Safety checks may block or require confirmation for destructive actions. diff --git a/packages/coding-agent/test/worktree/collapse.test.ts b/packages/coding-agent/test/worktree/collapse.test.ts new file mode 100644 index 000000000..ebc23f73f --- /dev/null +++ b/packages/coding-agent/test/worktree/collapse.test.ts @@ -0,0 +1,71 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { collapse } from "../../src/lib/worktree/collapse"; +import { WORKTREE_BASE } from "../../src/lib/worktree/constants"; +import { getRepoRoot, git } from "../../src/lib/worktree/git"; +import { create } from "../../src/lib/worktree/operations"; + +let repoPath: string; +let originalCwd: string; + +async function createTestRepo(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "wt-test-")); + await git(["init", "-b", "main"], dir); + await git(["config", "user.email", "test@example.com"], dir); + await git(["config", "user.name", "Test User"], dir); + await Bun.write(path.join(dir, "README.md"), "init"); + await git(["add", "README.md"], dir); + await git(["commit", "-m", "init"], dir); + return dir; +} + +async function cleanupRepo(repoRoot: string): Promise { + const repoName = path.basename(repoRoot); + await rm(path.join(WORKTREE_BASE, repoName), { recursive: true, force: true }); + await rm(repoRoot, { recursive: true, force: true }); +} + +describe("collapse strategies", () => { + beforeEach(async () => { + originalCwd = process.cwd(); + repoPath = await createTestRepo(); + process.chdir(repoPath); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await cleanupRepo(repoPath); + }); + + test("simple strategy applies uncommitted changes", async () => { + const src = await create("source"); + + await Bun.write(path.join(src.path, "new-file.txt"), "content"); + + await collapse("source", "main", { strategy: "simple" }); + + const mainPath = await getRepoRoot(); + const content = await Bun.file(path.join(mainPath, "new-file.txt")).text(); + expect(content).toBe("content"); + }); + + test("rebase strategy handles divergent history", async () => { + const src = await create("source"); + + await Bun.write(path.join(src.path, "feature.txt"), "feature"); + await git(["add", "feature.txt"], src.path); + await git(["commit", "-m", "add feature"], src.path); + + const mainPath = await getRepoRoot(); + await Bun.write(path.join(mainPath, "main-change.txt"), "main"); + await git(["add", "main-change.txt"], mainPath); + await git(["commit", "-m", "main change"], mainPath); + + await collapse("source", "main", { strategy: "rebase" }); + + expect(await Bun.file(path.join(mainPath, "feature.txt")).exists()).toBe(true); + expect(await Bun.file(path.join(mainPath, "main-change.txt")).exists()).toBe(true); + }); +}); diff --git a/packages/coding-agent/test/worktree/operations.test.ts b/packages/coding-agent/test/worktree/operations.test.ts new file mode 100644 index 000000000..edfa0bdff --- /dev/null +++ b/packages/coding-agent/test/worktree/operations.test.ts @@ -0,0 +1,82 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { WORKTREE_BASE } from "../../src/lib/worktree/constants"; +import { WorktreeError } from "../../src/lib/worktree/errors"; +import { git } from "../../src/lib/worktree/git"; +import { create, find, list, remove } from "../../src/lib/worktree/operations"; + +let repoPath: string; +let originalCwd: string; + +async function createTestRepo(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "wt-test-")); + await git(["init", "-b", "main"], dir); + await git(["config", "user.email", "test@example.com"], dir); + await git(["config", "user.name", "Test User"], dir); + await Bun.write(path.join(dir, "README.md"), "init"); + await git(["add", "README.md"], dir); + await git(["commit", "-m", "init"], dir); + return dir; +} + +async function cleanupRepo(repoRoot: string): Promise { + const repoName = path.basename(repoRoot); + await rm(path.join(WORKTREE_BASE, repoName), { recursive: true, force: true }); + await rm(repoRoot, { recursive: true, force: true }); +} + +describe("worktree operations", () => { + beforeEach(async () => { + originalCwd = process.cwd(); + repoPath = await createTestRepo(); + process.chdir(repoPath); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await cleanupRepo(repoPath); + }); + + test("create worktree with new branch", async () => { + const wt = await create("feature-test"); + expect(wt.branch).toBe("feature-test"); + expect(await Bun.file(wt.path).exists()).toBe(true); + }); + + test("create worktree with existing branch", async () => { + await git(["branch", "existing-branch"], repoPath); + const wt = await create("existing-branch"); + expect(wt.branch).toBe("existing-branch"); + }); + + test("list worktrees", async () => { + await create("feature-1"); + await create("feature-2"); + const worktrees = await list(); + expect(worktrees.length).toBe(3); + }); + + test("find worktree by branch", async () => { + await create("my-feature"); + const wt = await find("my-feature"); + expect(wt.branch).toBe("my-feature"); + }); + + test("find worktree by partial match", async () => { + await create("feature-authentication"); + const wt = await find("auth"); + expect(wt.branch).toBe("feature-authentication"); + }); + + test("remove worktree", async () => { + const wt = await create("to-remove"); + await remove("to-remove"); + expect(await Bun.file(wt.path).exists()).toBe(false); + }); + + test("cannot remove main worktree", async () => { + await expect(remove("main")).rejects.toThrow(WorktreeError); + }); +}); diff --git a/packages/git-tool/CHANGELOG.md b/packages/git-tool/CHANGELOG.md new file mode 100644 index 000000000..1378ef51e --- /dev/null +++ b/packages/git-tool/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +## [Unreleased] + +### Breaking Changes + +### Added + +- Added structured git tool with safety guards, caching, and GitHub operations + +### Changed + +### Fixed + +### Removed diff --git a/packages/git-tool/package.json b/packages/git-tool/package.json new file mode 100644 index 000000000..fb0fd8c8b --- /dev/null +++ b/packages/git-tool/package.json @@ -0,0 +1,42 @@ +{ + "name": "@oh-my-pi/pi-git-tool", + "version": "3.15.1", + "description": "Structured Git tool with safety guards and typed output", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "files": [ + "src", + "CHANGELOG.md" + ], + "scripts": { + "check": "tsgo --noEmit", + "build": "tsgo -p tsconfig.build.json", + "test": "vitest --run" + }, + "devDependencies": { + "@types/node": "^24.3.0", + "vitest": "^3.2.4" + }, + "keywords": [ + "git", + "tool", + "agent" + ], + "author": "Mario Zechner", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/can1357/oh-my-pi.git", + "directory": "packages/git-tool" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/packages/git-tool/src/cache/git-cache.ts b/packages/git-tool/src/cache/git-cache.ts new file mode 100644 index 000000000..0982791b0 --- /dev/null +++ b/packages/git-tool/src/cache/git-cache.ts @@ -0,0 +1,35 @@ +import type { BranchListResult, Commit, StatusResult } from "../types"; + +export interface CacheEntry { + value: T; + timestamp: number; + ttl: number; + cwd: string; +} + +export interface GitCache { + branch: CacheEntry | null; + status: CacheEntry | null; + remotes: CacheEntry> | null; + commits: Map>; +} + +export const DEFAULT_TTL = { + branch: 30_000, + status: 5_000, + remotes: 60_000, + commits: 300_000, +}; + +export function createCache(): GitCache { + return { + branch: null, + status: null, + remotes: null, + commits: new Map(), + }; +} + +export function isExpired(entry: CacheEntry): boolean { + return Date.now() - entry.timestamp > entry.ttl; +} diff --git a/packages/git-tool/src/errors.ts b/packages/git-tool/src/errors.ts new file mode 100644 index 000000000..d94c7bad5 --- /dev/null +++ b/packages/git-tool/src/errors.ts @@ -0,0 +1,84 @@ +export enum GitErrorCode { + NOT_A_REPO = "NOT_A_REPO", + CONFLICT = "CONFLICT", + UNCOMMITTED_CHANGES = "UNCOMMITTED_CHANGES", + BRANCH_NOT_FOUND = "BRANCH_NOT_FOUND", + REF_NOT_FOUND = "REF_NOT_FOUND", + REMOTE_REJECTED = "REMOTE_REJECTED", + AUTH_FAILED = "AUTH_FAILED", + MERGE_CONFLICT = "MERGE_CONFLICT", + REBASE_CONFLICT = "REBASE_CONFLICT", + NOTHING_TO_COMMIT = "NOTHING_TO_COMMIT", + DETACHED_HEAD = "DETACHED_HEAD", + HOOK_FAILED = "HOOK_FAILED", + GH_NOT_INSTALLED = "GH_NOT_INSTALLED", + GH_AUTH_REQUIRED = "GH_AUTH_REQUIRED", + UNKNOWN = "UNKNOWN", +} + +export class GitError extends Error { + code: GitErrorCode; + details?: Record; + + constructor(message: string, code: GitErrorCode, details?: Record) { + super(message); + this.name = "GitError"; + this.code = code; + this.details = details; + } +} + +export function detectGitError(stderr: string, exitCode: number): GitError | null { + if (exitCode === 0) return null; + + const normalized = stderr.toLowerCase(); + if (normalized.includes("not a git repository")) { + return new GitError("Not a git repository", GitErrorCode.NOT_A_REPO); + } + if (normalized.includes("authentication failed") || normalized.includes("fatal: authentication")) { + return new GitError("Authentication failed", GitErrorCode.AUTH_FAILED); + } + if (normalized.includes("permission denied") || normalized.includes("access denied")) { + return new GitError("Authentication failed", GitErrorCode.AUTH_FAILED); + } + if (normalized.includes("nothing to commit")) { + return new GitError("Nothing to commit", GitErrorCode.NOTHING_TO_COMMIT); + } + if (normalized.includes("detached head")) { + return new GitError("Detached HEAD", GitErrorCode.DETACHED_HEAD); + } + if (normalized.includes("merge conflict") || normalized.includes("conflict")) { + return new GitError("Merge conflict", GitErrorCode.MERGE_CONFLICT); + } + if (normalized.includes("rebase")) { + return new GitError("Rebase conflict", GitErrorCode.REBASE_CONFLICT); + } + if (normalized.includes("unknown revision") || normalized.includes("bad revision")) { + return new GitError("Ref not found", GitErrorCode.REF_NOT_FOUND); + } + if (normalized.includes("pathspec") && normalized.includes("did not match")) { + return new GitError("Ref not found", GitErrorCode.REF_NOT_FOUND); + } + if (normalized.includes("hook") && normalized.includes("failed")) { + return new GitError("Hook failed", GitErrorCode.HOOK_FAILED); + } + if (normalized.includes("remote rejected") || normalized.includes("rejected")) { + return new GitError("Remote rejected", GitErrorCode.REMOTE_REJECTED); + } + + return new GitError(stderr.trim() || "Unknown git error", GitErrorCode.UNKNOWN); +} + +export function detectGhError(stderr: string, exitCode: number): GitError | null { + if (exitCode === 0) return null; + + const normalized = stderr.toLowerCase(); + if (normalized.includes("not logged") || normalized.includes("authentication required")) { + return new GitError("GitHub CLI authentication required", GitErrorCode.GH_AUTH_REQUIRED); + } + if (normalized.includes("gh: not found") || normalized.includes("gh: command not found")) { + return new GitError("GitHub CLI is not installed", GitErrorCode.GH_NOT_INSTALLED); + } + + return new GitError(stderr.trim() || "GitHub CLI error", GitErrorCode.UNKNOWN); +} diff --git a/packages/git-tool/src/git-tool.ts b/packages/git-tool/src/git-tool.ts new file mode 100644 index 000000000..f02a455ea --- /dev/null +++ b/packages/git-tool/src/git-tool.ts @@ -0,0 +1,169 @@ +import { createCache, DEFAULT_TTL, isExpired } from "./cache/git-cache"; +import { add } from "./operations/add"; +import { blame } from "./operations/blame"; +import { branch } from "./operations/branch"; +import { checkout } from "./operations/checkout"; +import { cherryPick } from "./operations/cherry-pick"; +import { commit } from "./operations/commit"; +import { diff } from "./operations/diff"; +import { fetch } from "./operations/fetch"; +import { ci } from "./operations/github/ci"; +import { issue } from "./operations/github/issue"; +import { pr } from "./operations/github/pr"; +import { release } from "./operations/github/release"; +import { log } from "./operations/log"; +import { merge } from "./operations/merge"; +import { pull } from "./operations/pull"; +import { push } from "./operations/push"; +import { rebase } from "./operations/rebase"; +import { restore } from "./operations/restore"; +import { show } from "./operations/show"; +import { stash } from "./operations/stash"; +import { status } from "./operations/status"; +import { tag } from "./operations/tag"; +import { renderBranchList, renderStatus } from "./render"; +import { checkSafety } from "./safety/guards"; +import type { BranchListResult, GitParams, Operation, StatusResult, ToolResponse, ToolResult } from "./types"; +import { isTruthy } from "./utils"; + +const cache = createCache(); + +type OperationHandler = (params: GitParams, cwd?: string) => Promise>; + +const operations: Record = { + status: status as OperationHandler, + diff: diff as OperationHandler, + log: log as OperationHandler, + show: show as OperationHandler, + blame: blame as OperationHandler, + branch: branch as OperationHandler, + add: add as OperationHandler, + restore: restore as OperationHandler, + commit: commit as OperationHandler, + checkout: checkout as OperationHandler, + merge: merge as OperationHandler, + rebase: rebase as OperationHandler, + stash: stash as OperationHandler, + "cherry-pick": cherryPick as OperationHandler, + fetch: fetch as OperationHandler, + pull: pull as OperationHandler, + push: push as OperationHandler, + tag: tag as OperationHandler, + pr: pr as OperationHandler, + issue: issue as OperationHandler, + ci: ci as OperationHandler, + release: release as OperationHandler, +}; + +const READ_OPERATIONS: Operation[] = ["status", "diff", "log", "show", "blame", "branch"]; +const WRITE_OPERATIONS: Operation[] = [ + "add", + "restore", + "commit", + "checkout", + "merge", + "rebase", + "stash", + "cherry-pick", + "pull", + "fetch", +]; + +function invalidateOnWrite(operation: Operation): void { + if (WRITE_OPERATIONS.includes(operation)) { + cache.status = null; + if (["checkout", "merge", "rebase"].includes(operation)) { + cache.branch = null; + } + } +} + +function cacheStatus(result: ToolResult, cwd: string): void { + cache.status = { value: result.data, timestamp: Date.now(), ttl: DEFAULT_TTL.status, cwd }; +} + +function cacheBranch(result: ToolResult, cwd: string): void { + cache.branch = { value: result.data, timestamp: Date.now(), ttl: DEFAULT_TTL.branch, cwd }; +} + +function getCachedStatus(cwd: string): ToolResult | null { + if (!cache.status) return null; + if (isExpired(cache.status)) { + cache.status = null; + return null; + } + if (cache.status.cwd !== cwd) return null; + return { + data: cache.status.value, + _rendered: renderStatus(cache.status.value), + }; +} + +function getCachedBranch(cwd: string): ToolResult | null { + if (!cache.branch) return null; + if (isExpired(cache.branch)) { + cache.branch = null; + return null; + } + if (cache.branch.cwd !== cwd) return null; + return { + data: cache.branch.value, + _rendered: renderBranchList(cache.branch.value), + }; +} + +export async function gitTool(params: GitParams, cwd?: string): Promise> { + const resolvedCwd = cwd ?? process.cwd(); + const operation = params.operation as Operation; + const handler = operations[operation]; + if (!handler) { + return { error: `Unknown operation: ${operation}` }; + } + + const paramRecord = params as unknown as Record; + const safety = await checkSafety(operation, paramRecord, resolvedCwd); + if (safety.blocked) { + const overrideValue = safety.override ? paramRecord[safety.override] : undefined; + if (!safety.override || !isTruthy(String(overrideValue ?? ""))) { + return { error: safety.message ?? "Operation blocked", suggestion: safety.suggestion, code: "SAFETY_BLOCK" }; + } + } + if (safety.confirm) { + const overrideValue = safety.override ? paramRecord[safety.override] : undefined; + if (!safety.override || !isTruthy(String(overrideValue ?? ""))) { + return { + confirm: safety.message ?? "Confirmation required", + override: safety.override ?? "confirm", + _rendered: safety.message, + }; + } + } + + if (READ_OPERATIONS.includes(operation)) { + if (operation === "status") { + const cached = getCachedStatus(resolvedCwd); + const statusParams = params as { only?: string; ignored?: boolean }; + if (cached && !statusParams.only && !statusParams.ignored) return cached; + } + if (operation === "branch") { + const cached = getCachedBranch(resolvedCwd); + const branchParams = params as { action?: string; remotes?: boolean }; + if (cached && (!branchParams.action || branchParams.action === "list") && !branchParams.remotes) return cached; + } + } + + const result = await handler(params, resolvedCwd); + + invalidateOnWrite(operation); + if ("data" in result && READ_OPERATIONS.includes(operation)) { + if (operation === "status") cacheStatus(result as ToolResult, resolvedCwd); + if (operation === "branch") cacheBranch(result as ToolResult, resolvedCwd); + } + + if ("data" in result && safety.warnings.length > 0) { + const suffix = `\n\nWarnings:\n${safety.warnings.map((warn) => `- ${warn}`).join("\n")}`; + result._rendered = `${result._rendered ?? ""}${suffix}`; + } + + return result; +} diff --git a/packages/git-tool/src/index.ts b/packages/git-tool/src/index.ts new file mode 100644 index 000000000..10b57a2b7 --- /dev/null +++ b/packages/git-tool/src/index.ts @@ -0,0 +1,3 @@ +export * from "./errors"; +export { gitTool } from "./git-tool"; +export * from "./types"; diff --git a/packages/git-tool/src/operations/add.ts b/packages/git-tool/src/operations/add.ts new file mode 100644 index 000000000..d26ad3ef5 --- /dev/null +++ b/packages/git-tool/src/operations/add.ts @@ -0,0 +1,42 @@ +import { renderAdd } from "../render"; +import type { AddParams, AddResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +function parseDryRun(output: string): string[] { + const files: string[] = []; + for (const line of output.split("\n")) { + const match = line.match(/add ['"]?(.*?)['"]?$/i); + if (match) { + files.push(match[1]); + } + } + return files; +} + +export async function add(params: AddParams, cwd?: string): Promise | ToolError> { + const args = ["add"]; + if (params.dry_run) args.push("--dry-run"); + if (params.update) args.push("-u"); + if (params.all) args.push("-A"); + if (params.paths && params.paths.length > 0) { + args.push("--", ...params.paths); + } + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + let staged: string[] = []; + if (params.dry_run) { + staged = parseDryRun(result.stdout); + } else { + const stagedResult = await git(["diff", "--name-only", "--cached"], { cwd }); + if (!stagedResult.error) { + staged = stagedResult.stdout.split("\n").filter(Boolean); + } + } + + const data: AddResult = { staged }; + return { data, _rendered: renderAdd(data) }; +} diff --git a/packages/git-tool/src/operations/blame.ts b/packages/git-tool/src/operations/blame.ts new file mode 100644 index 000000000..af93f0fdb --- /dev/null +++ b/packages/git-tool/src/operations/blame.ts @@ -0,0 +1,23 @@ +import { parseBlame } from "../parsers/blame-parser"; +import { renderBlame } from "../render"; +import type { BlameParams, BlameResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +export async function blame(params: BlameParams, cwd?: string): Promise | ToolError> { + const args = ["blame", "--porcelain"]; + if (params.root) args.push("--root"); + if (params.ignore_whitespace) args.push("-w"); + if (params.lines) { + args.push("-L", `${params.lines.start},${params.lines.end}`); + } + args.push(params.path); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const parsed = parseBlame(result.stdout); + const data: BlameResult = { lines: parsed }; + return { data, _rendered: renderBlame(data) }; +} diff --git a/packages/git-tool/src/operations/branch.ts b/packages/git-tool/src/operations/branch.ts new file mode 100644 index 000000000..4c93cebbd --- /dev/null +++ b/packages/git-tool/src/operations/branch.ts @@ -0,0 +1,115 @@ +import { renderBranchList } from "../render"; +import type { BranchInfo, BranchListResult, BranchParams, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +function parseTrack(track: string): { ahead?: number; behind?: number; gone?: boolean } { + const result: { ahead?: number; behind?: number; gone?: boolean } = {}; + if (!track) return result; + if (track.includes("gone")) result.gone = true; + const aheadMatch = track.match(/ahead (\d+)/); + const behindMatch = track.match(/behind (\d+)/); + if (aheadMatch) result.ahead = Number.parseInt(aheadMatch[1], 10); + if (behindMatch) result.behind = Number.parseInt(behindMatch[1], 10); + return result; +} + +function parseBranchLines(output: string): BranchInfo[] { + const branches: BranchInfo[] = []; + for (const line of output.split("\n")) { + if (!line) continue; + const parts = line.split("\x00"); + if (parts.length < 2) continue; + const name = parts[0]; + const sha = parts[1]; + const upstream = parts[2] || undefined; + const track = parts[3] || ""; + const { ahead, behind, gone } = parseTrack(track); + branches.push({ name, sha, upstream, ahead, behind, gone }); + } + return branches; +} + +export async function branch(params: BranchParams, cwd?: string): Promise | ToolError> { + const action = params.action ?? "list"; + + if (action === "current") { + const currentResult = await git(["branch", "--show-current"], { cwd }); + if (currentResult.error) { + return { error: currentResult.error.message, code: currentResult.error.code }; + } + const data: BranchListResult = { current: currentResult.stdout.trim(), local: [] }; + return { data, _rendered: renderBranchList(data) }; + } + + if (action === "create") { + if (!params.name) { + return { error: "Branch name required" }; + } + const args = ["branch", params.name]; + if (params.startPoint) args.push(params.startPoint); + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + } + + if (action === "delete") { + if (!params.name) { + return { error: "Branch name required" }; + } + const args = ["branch", params.force ? "-D" : "-d", params.name]; + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + } + + if (action === "rename") { + if (!params.name || !params.newName) { + return { error: "Branch name and newName required" }; + } + const result = await git(["branch", "-m", params.name, params.newName], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + } + + const currentResult = await git(["branch", "--show-current"], { cwd }); + if (currentResult.error) { + return { error: currentResult.error.message, code: currentResult.error.code }; + } + const current = currentResult.stdout.trim(); + + const listResult = await git( + [ + "branch", + "-vv", + "--format=%(refname:short)%x00%(objectname:short)%x00%(upstream:short)%x00%(upstream:track,nobracket)", + ], + { cwd }, + ); + if (listResult.error) { + return { error: listResult.error.message, code: listResult.error.code }; + } + const local = parseBranchLines(listResult.stdout); + + let remote: BranchInfo[] | undefined; + if (params.remotes) { + const remoteResult = await git( + [ + "branch", + "-r", + "-vv", + "--format=%(refname:short)%x00%(objectname:short)%x00%(upstream:short)%x00%(upstream:track,nobracket)", + ], + { cwd }, + ); + if (remoteResult.error) { + return { error: remoteResult.error.message, code: remoteResult.error.code }; + } + remote = parseBranchLines(remoteResult.stdout); + } + + const data: BranchListResult = { current, local, ...(remote ? { remote } : {}) }; + return { data, _rendered: renderBranchList(data) }; +} diff --git a/packages/git-tool/src/operations/checkout.ts b/packages/git-tool/src/operations/checkout.ts new file mode 100644 index 000000000..375371310 --- /dev/null +++ b/packages/git-tool/src/operations/checkout.ts @@ -0,0 +1,37 @@ +import type { CheckoutParams, CheckoutResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +export async function checkout(params: CheckoutParams, cwd?: string): Promise | ToolError> { + const currentResult = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); + const previous = currentResult.error ? undefined : currentResult.stdout.trim(); + + if (params.paths && params.paths.length > 0) { + const args = ["checkout"]; + if (params.ref) args.push(params.ref); + if (params.force) args.push("--force"); + args.push("--", ...params.paths); + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: CheckoutResult = { previous, restoredFiles: params.paths }; + return { data, _rendered: `Restored ${params.paths.length} files` }; + } + + if (!params.ref) { + return { error: "Ref is required for checkout" }; + } + + const args = ["checkout"]; + if (params.create) args.push("-b"); + if (params.force) args.push("--force"); + args.push(params.ref); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const data: CheckoutResult = { branch: params.ref, previous }; + return { data, _rendered: `Checked out ${params.ref}` }; +} diff --git a/packages/git-tool/src/operations/cherry-pick.ts b/packages/git-tool/src/operations/cherry-pick.ts new file mode 100644 index 000000000..e0d4c584b --- /dev/null +++ b/packages/git-tool/src/operations/cherry-pick.ts @@ -0,0 +1,39 @@ +import { GitErrorCode } from "../errors"; +import { renderCherryPick } from "../render"; +import type { CherryPickParams, CherryPickResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +async function getConflicts(cwd?: string): Promise { + const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); + if (result.error) return []; + return result.stdout.split("\n").filter(Boolean); +} + +export async function cherryPick( + params: CherryPickParams, + cwd?: string, +): Promise | ToolError> { + const args = ["cherry-pick"]; + if (params.abort) args.push("--abort"); + if (params.continue) args.push("--continue"); + if (params.no_commit) args.push("--no-commit"); + if (!params.abort && !params.continue) { + if (!params.commits || params.commits.length === 0) { + return { error: "Commits are required for cherry-pick" }; + } + args.push(...params.commits); + } + + const result = await git(args, { cwd }); + if (result.error) { + if (result.error.code === GitErrorCode.MERGE_CONFLICT) { + const conflicts = await getConflicts(cwd); + const data: CherryPickResult = { status: "conflict", conflicts }; + return { data, _rendered: renderCherryPick(data) }; + } + return { error: result.error.message, code: result.error.code }; + } + + const data: CherryPickResult = { status: "success", appliedCommits: params.commits }; + return { data, _rendered: renderCherryPick(data) }; +} diff --git a/packages/git-tool/src/operations/commit.ts b/packages/git-tool/src/operations/commit.ts new file mode 100644 index 000000000..d2391204f --- /dev/null +++ b/packages/git-tool/src/operations/commit.ts @@ -0,0 +1,52 @@ +import { renderCommit } from "../render"; +import { markCommitCreated } from "../safety/guards"; +import type { CommitParams, CommitResult, ToolError, ToolResult } from "../types"; +import { git, parseShortstat } from "../utils"; + +export async function commit(params: CommitParams, cwd?: string): Promise | ToolError> { + if (!params.message || params.message.trim().length === 0) { + return { error: "Commit message is required" }; + } + const args = ["commit", "-m", params.message]; + if (params.all) args.push("--all"); + if (params.allow_empty) args.push("--allow-empty"); + if (params.sign) args.push("-S"); + if (params.no_verify) args.push("--no-verify"); + if (params.amend) args.push("--amend"); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const shaResult = await git(["rev-parse", "HEAD"], { cwd }); + if (shaResult.error) { + return { error: shaResult.error.message, code: shaResult.error.code }; + } + const sha = shaResult.stdout.trim(); + markCommitCreated(sha); + + const subjectResult = await git(["show", "-s", "--format=%s", "HEAD"], { cwd }); + if (subjectResult.error) { + return { error: subjectResult.error.message, code: subjectResult.error.code }; + } + const subject = subjectResult.stdout.trim(); + + const statResult = await git(["show", "-s", "--shortstat", "HEAD"], { cwd }); + let stats = { additions: 0, deletions: 0, files: 0 }; + if (!statResult.error) { + const line = statResult.stdout.split("\n").find((statLine) => statLine.includes("files changed")); + const parsed = line ? parseShortstat(line) : null; + if (parsed) { + stats = { files: parsed.files, additions: parsed.additions, deletions: parsed.deletions }; + } + } + + const data: CommitResult = { + sha, + shortSha: sha.slice(0, 7), + subject, + stats, + }; + return { data, _rendered: renderCommit(data) }; +} diff --git a/packages/git-tool/src/operations/diff.ts b/packages/git-tool/src/operations/diff.ts new file mode 100644 index 000000000..a25169126 --- /dev/null +++ b/packages/git-tool/src/operations/diff.ts @@ -0,0 +1,206 @@ +import { parseDiff } from "../parsers/diff-parser"; +import { renderDiff } from "../render"; +import type { DiffParams, DiffResult, FileDiff, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +interface StatusInfo { + status: FileDiff["status"]; + oldPath?: string; +} + +function parseNameStatus(output: string): Map { + const map = new Map(); + for (const line of output.split("\n")) { + if (!line) continue; + const parts = line.split("\t"); + const code = parts[0]; + if (!code) continue; + const status = code[0]; + if (status === "R" || status === "C") { + const oldPath = parts[1]; + const newPath = parts[2]; + if (newPath) { + map.set(newPath, { + status: status === "R" ? "renamed" : "copied", + oldPath, + }); + } + continue; + } + const path = parts[1]; + if (!path) continue; + const statusMap: Record = { + A: "added", + M: "modified", + D: "deleted", + }; + map.set(path, { status: statusMap[status] ?? "modified" }); + } + return map; +} + +function parseRenamePath(path: string): { oldPath: string; newPath: string } | null { + if (!path.includes("=>")) return null; + const braceMatch = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/); + if (braceMatch) { + const prefix = braceMatch[1]; + const oldMid = braceMatch[2]; + const newMid = braceMatch[3]; + const suffix = braceMatch[4]; + return { + oldPath: `${prefix}${oldMid}${suffix}`, + newPath: `${prefix}${newMid}${suffix}`, + }; + } + const parts = path.split("=>").map((part) => part.trim()); + if (parts.length !== 2) return null; + return { oldPath: parts[0], newPath: parts[1] }; +} + +function parseNumstat( + output: string, +): Map { + const map = new Map(); + for (const line of output.split("\n")) { + if (!line) continue; + const parts = line.split("\t"); + if (parts.length < 3) continue; + const additionsRaw = parts[0]; + const deletionsRaw = parts[1]; + const pathRaw = parts.slice(2).join("\t"); + const additions = additionsRaw === "-" ? 0 : Number.parseInt(additionsRaw, 10); + const deletions = deletionsRaw === "-" ? 0 : Number.parseInt(deletionsRaw, 10); + const binary = additionsRaw === "-" || deletionsRaw === "-"; + const rename = parseRenamePath(pathRaw); + if (rename) { + map.set(rename.newPath, { additions, deletions, binary, oldPath: rename.oldPath }); + continue; + } + map.set(pathRaw, { additions, deletions, binary }); + } + return map; +} + +function buildDiffArgs(params: DiffParams): string[] { + const args: string[] = ["diff"]; + if (params.target === "staged") { + args.push("--cached"); + } else if (params.target === "head") { + args.push("HEAD"); + } else if (params.target && typeof params.target === "object") { + // Ambiguity: when `to` is omitted, default to diffing against HEAD. + const toRef = params.target.to ?? "HEAD"; + args.push(`${params.target.from}..${toRef}`); + } + + if (params.ignore_whitespace) { + args.push("--ignore-all-space"); + } + return args; +} + +export async function diff(params: DiffParams, cwd?: string): Promise | ToolError> { + const baseArgs = buildDiffArgs(params); + const paths = params.paths ?? []; + + const nameStatusArgs = [...baseArgs, "--name-status"]; + if (paths.length > 0) nameStatusArgs.push("--", ...paths); + const nameStatusResult = await git(nameStatusArgs, { cwd }); + if (nameStatusResult.error) { + return { error: nameStatusResult.error.message, code: nameStatusResult.error.code }; + } + const statusMap = parseNameStatus(nameStatusResult.stdout); + + if (params.name_only || params.stat_only) { + const numstatArgs = [...baseArgs, "--numstat"]; + if (paths.length > 0) numstatArgs.push("--", ...paths); + const numstatResult = await git(numstatArgs, { cwd }); + if (numstatResult.error) { + return { error: numstatResult.error.message, code: numstatResult.error.code }; + } + const statsMap = parseNumstat(numstatResult.stdout); + const files: FileDiff[] = []; + const seen = new Set(); + + for (const [path, info] of statusMap.entries()) { + const stats = statsMap.get(path); + files.push({ + path, + oldPath: info.oldPath ?? stats?.oldPath, + status: info.status, + binary: stats?.binary ?? false, + additions: stats?.additions ?? 0, + deletions: stats?.deletions ?? 0, + }); + seen.add(path); + } + + for (const [path, stats] of statsMap.entries()) { + if (seen.has(path)) continue; + files.push({ + path, + oldPath: stats.oldPath, + status: "modified", + binary: stats.binary, + additions: stats.additions, + deletions: stats.deletions, + }); + } + + const summary = files.reduce( + (acc, file) => { + acc.filesChanged += 1; + acc.insertions += file.additions; + acc.deletions += file.deletions; + return acc; + }, + { filesChanged: 0, insertions: 0, deletions: 0 }, + ); + + const result: DiffResult = { + files, + stats: summary, + truncated: false, + }; + return { data: result, _rendered: renderDiff(result) }; + } + + const diffArgs = [...baseArgs]; + if (params.context !== undefined) { + diffArgs.push(`--unified=${params.context}`); + } + if (paths.length > 0) diffArgs.push("--", ...paths); + + const diffResult = await git(diffArgs, { cwd }); + if (diffResult.error) { + return { error: diffResult.error.message, code: diffResult.error.code }; + } + + const parsed = parseDiff(diffResult.stdout, { maxLines: params.max_lines }); + for (const file of parsed.files) { + const info = statusMap.get(file.path); + if (info) { + file.status = info.status; + file.oldPath = info.oldPath ?? file.oldPath; + } + } + + const summary = parsed.files.reduce( + (acc, file) => { + acc.filesChanged += 1; + acc.insertions += file.additions; + acc.deletions += file.deletions; + return acc; + }, + { filesChanged: 0, insertions: 0, deletions: 0 }, + ); + + const result: DiffResult = { + files: parsed.files, + stats: summary, + truncated: parsed.truncated, + truncatedFiles: parsed.truncatedFiles.length > 0 ? parsed.truncatedFiles : undefined, + }; + + return { data: result, _rendered: renderDiff(result) }; +} diff --git a/packages/git-tool/src/operations/fetch.ts b/packages/git-tool/src/operations/fetch.ts new file mode 100644 index 000000000..4e8b176dc --- /dev/null +++ b/packages/git-tool/src/operations/fetch.ts @@ -0,0 +1,49 @@ +import { renderFetch } from "../render"; +import type { FetchParams, FetchResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +async function getRemoteRefs(remote: string, branch?: string, cwd?: string): Promise> { + const refPrefix = branch ? `refs/remotes/${remote}/${branch}` : `refs/remotes/${remote}`; + const result = await git(["for-each-ref", refPrefix, "--format=%(refname)\t%(objectname)"], { cwd }); + if (result.error) return new Map(); + const map = new Map(); + for (const line of result.stdout.split("\n")) { + if (!line) continue; + const [ref, sha] = line.split("\t"); + if (ref && sha) map.set(ref, sha); + } + return map; +} + +export async function fetch(params: FetchParams, cwd?: string): Promise | ToolError> { + const remote = params.remote ?? "origin"; + const before = await getRemoteRefs(remote, params.branch, cwd); + + const args = ["fetch"]; + if (params.all) { + args.push("--all"); + } else { + args.push(remote); + if (params.branch) args.push(params.branch); + } + if (params.prune) args.push("--prune"); + if (params.tags) args.push("--tags"); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const after = await getRemoteRefs(remote, params.branch, cwd); + const updated: FetchResult["updated"] = []; + for (const [ref, newSha] of after.entries()) { + const oldSha = before.get(ref); + if (!oldSha || oldSha !== newSha) { + updated.push({ ref, oldSha: oldSha ?? "", newSha }); + } + } + const pruned = params.prune ? Array.from(before.keys()).filter((ref) => !after.has(ref)) : undefined; + + const data: FetchResult = { updated, ...(pruned && pruned.length > 0 ? { pruned } : {}) }; + return { data, _rendered: renderFetch(data) }; +} diff --git a/packages/git-tool/src/operations/github/ci.ts b/packages/git-tool/src/operations/github/ci.ts new file mode 100644 index 000000000..ae08d8ba8 --- /dev/null +++ b/packages/git-tool/src/operations/github/ci.ts @@ -0,0 +1,155 @@ +import type { + CIActionResult, + CIParams, + CIResult, + JobInfo, + RunInfo, + RunListResult, + RunViewResult, + ToolError, + ToolResult, +} from "../../types"; +import { gh } from "../../utils"; + +type GhRun = { + databaseId?: number; + id?: number; + displayTitle?: string; + name?: string; + status: RunInfo["status"]; + conclusion?: RunInfo["conclusion"]; + headBranch?: string; + branch?: string; + headSha?: string; + sha?: string; + url: string; + createdAt: string; + updatedAt: string; + jobs?: GhJob[]; +}; + +type GhJob = { + databaseId?: number; + id?: number; + name: string; + status: string; + conclusion?: string; + steps?: Array<{ name: string; status: string; conclusion?: string }>; +}; + +function mapRunInfo(raw: GhRun): RunInfo { + return { + id: raw.databaseId ?? raw.id ?? 0, + name: raw.displayTitle ?? raw.name ?? "", + status: raw.status, + conclusion: raw.conclusion ?? undefined, + branch: raw.headBranch ?? raw.branch ?? "", + sha: raw.headSha ?? raw.sha ?? "", + url: raw.url, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + }; +} + +function mapJobInfo(raw: GhJob): JobInfo { + return { + id: raw.databaseId ?? raw.id ?? 0, + name: raw.name, + status: raw.status, + conclusion: raw.conclusion ?? undefined, + steps: (raw.steps ?? []).map((step) => ({ + name: step.name, + status: step.status, + conclusion: step.conclusion ?? undefined, + })), + }; +} + +export async function ci(params: CIParams, cwd?: string): Promise | ToolError> { + if (params.action === "list") { + const args = [ + "run", + "list", + "--json", + "databaseId,displayTitle,status,conclusion,headBranch,headSha,url,createdAt,updatedAt", + ]; + if (params.limit) args.push("--limit", String(params.limit)); + if (params.branch) args.push("--branch", params.branch); + if (params.workflow) args.push("--workflow", params.workflow); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const raw = JSON.parse(result.stdout) as GhRun[]; + const runs = raw.map((item) => mapRunInfo(item)); + const data: RunListResult = { runs }; + return { data, _rendered: `Runs: ${runs.length}` }; + } + + if (params.action === "view") { + if (!params.run_id) { + return { error: "run_id required" }; + } + const result = await gh( + [ + "run", + "view", + String(params.run_id), + "--json", + "databaseId,name,status,conclusion,headBranch,headSha,url,createdAt,updatedAt,jobs", + ], + { cwd }, + ); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const raw = JSON.parse(result.stdout) as GhRun; + const run = mapRunInfo(raw); + const jobs: JobInfo[] = (raw.jobs ?? []).map((job) => mapJobInfo(job)); + let logs: string | undefined; + if (params.logs_failed) { + const logsResult = await gh(["run", "view", String(params.run_id), "--log-failed"], { cwd }); + if (!logsResult.error) logs = logsResult.stdout; + } + const data: RunViewResult = { run, jobs, ...(logs ? { logs } : {}) }; + return { data, _rendered: `Run ${run.id} ${run.status}` }; + } + + if (params.action === "watch") { + if (!params.run_id) return { error: "run_id required" }; + const result = await gh(["run", "watch", String(params.run_id), "--exit-status"], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: CIActionResult = { status: "success" }; + return { data, _rendered: `Watched run ${params.run_id}` }; + } + + if (params.action === "run") { + if (!params.workflow) return { error: "workflow required" }; + const args = ["workflow", "run", params.workflow]; + if (params.inputs) { + for (const [key, value] of Object.entries(params.inputs)) { + args.push("--field", `${key}=${value}`); + } + } + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: CIActionResult = { status: "success" }; + return { data, _rendered: `Triggered workflow ${params.workflow}` }; + } + + if (params.action === "cancel" || params.action === "rerun") { + if (!params.run_id) return { error: "run_id required" }; + const result = await gh(["run", params.action, String(params.run_id)], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: CIActionResult = { status: "success" }; + return { data, _rendered: `${params.action} run ${params.run_id}` }; + } + + return { error: `Unknown CI action: ${params.action}` }; +} diff --git a/packages/git-tool/src/operations/github/issue.ts b/packages/git-tool/src/operations/github/issue.ts new file mode 100644 index 000000000..f0a8bf18e --- /dev/null +++ b/packages/git-tool/src/operations/github/issue.ts @@ -0,0 +1,132 @@ +import type { + IssueCreateResult, + IssueInfo, + IssueListResult, + IssueParams, + IssueResult, + ToolError, + ToolResult, +} from "../../types"; +import { gh } from "../../utils"; + +type GhIssue = { + number: number; + title: string; + state: string; + author?: { login?: string } | string; + body?: string | null; + labels?: Array<{ name?: string } | string>; + assignees?: Array<{ login?: string } | string>; + url: string; + createdAt: string; + comments?: number; +}; + +function mapIssueInfo(raw: GhIssue): IssueInfo { + const author = typeof raw.author === "string" ? raw.author : (raw.author?.login ?? ""); + return { + number: raw.number, + title: raw.title, + state: raw.state, + author, + body: raw.body ?? "", + labels: (raw.labels ?? []) + .map((label) => (typeof label === "string" ? label : (label.name ?? ""))) + .filter(Boolean), + assignees: (raw.assignees ?? []) + .map((assignee) => (typeof assignee === "string" ? assignee : (assignee.login ?? ""))) + .filter(Boolean), + url: raw.url, + createdAt: raw.createdAt, + comments: raw.comments ?? 0, + }; +} + +export async function issue(params: IssueParams, cwd?: string): Promise | ToolError> { + if (params.action === "list") { + const args = [ + "issue", + "list", + "--json", + "number,title,state,author,body,labels,assignees,url,createdAt,comments", + ]; + if (params.state) args.push("--state", params.state); + if (params.labels && params.labels.length > 0) args.push("--label", params.labels.join(",")); + if (params.assignee) args.push("--assignee", params.assignee); + if (params.limit) args.push("--limit", String(params.limit)); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const raw = JSON.parse(result.stdout) as GhIssue[]; + const issues = raw.map((item) => mapIssueInfo(item)); + const data: IssueListResult = { issues }; + return { data, _rendered: `Issues: ${issues.length}` }; + } + + if (params.action === "view") { + if (!params.number) { + return { error: "Issue number required" }; + } + const result = await gh( + [ + "issue", + "view", + String(params.number), + "--json", + "number,title,state,author,body,labels,assignees,url,createdAt,comments", + ], + { cwd }, + ); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const issueInfo = mapIssueInfo(JSON.parse(result.stdout) as GhIssue); + return { data: { issue: issueInfo }, _rendered: `Issue #${issueInfo.number}: ${issueInfo.title}` }; + } + + if (params.action === "create") { + const args = ["issue", "create"]; + if (params.title) args.push("--title", params.title); + if (params.body) args.push("--body", params.body); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + let number = 0; + let url = ""; + const match = result.stdout.match(/https?:\/\/\S+/); + if (match) { + url = match[0]; + const numMatch = url.match(/issues\/(\d+)/); + if (numMatch) number = Number.parseInt(numMatch[1], 10); + } + const data: IssueCreateResult = { number, url }; + return { data, _rendered: url ? `Created issue ${url}` : "Created issue" }; + } + + if (params.action === "close" || params.action === "reopen") { + if (!params.number) { + return { error: "Issue number required" }; + } + const result = await gh(["issue", params.action, String(params.number)], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const verb = params.action === "close" ? "Closed" : "Reopened"; + return { data: { status: "success" }, _rendered: `${verb} issue #${params.number}` }; + } + + if (params.action === "comment") { + if (!params.number || !params.comment_body) { + return { error: "Issue number and comment_body required" }; + } + const result = await gh(["issue", "comment", String(params.number), "--body", params.comment_body], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + return { data: { status: "success" }, _rendered: `Commented on issue #${params.number}` }; + } + + return { error: `Unknown issue action: ${params.action}` }; +} diff --git a/packages/git-tool/src/operations/github/pr.ts b/packages/git-tool/src/operations/github/pr.ts new file mode 100644 index 000000000..4a9ef09b0 --- /dev/null +++ b/packages/git-tool/src/operations/github/pr.ts @@ -0,0 +1,247 @@ +import type { + PRActionResult, + PRCreateResult, + PRInfo, + PRListResult, + PRParams, + PRResult, + ToolError, + ToolResult, +} from "../../types"; +import { gh } from "../../utils"; + +function parseChecks(raw: unknown): { passing: number; failing: number; pending: number } | undefined { + if (!raw) return undefined; + if (Array.isArray(raw)) { + let passing = 0; + let failing = 0; + let pending = 0; + for (const check of raw) { + const status = + (check as { state?: string; conclusion?: string }).conclusion ?? (check as { state?: string }).state ?? ""; + switch (status) { + case "SUCCESS": + case "success": + passing += 1; + break; + case "FAILURE": + case "failure": + case "ERROR": + case "error": + failing += 1; + break; + default: + pending += 1; + } + } + return { passing, failing, pending }; + } + return undefined; +} + +type GhPr = { + number: number; + title: string; + state: string; + author?: { login?: string } | string; + headRefName?: string; + baseRefName?: string; + branch?: string; + base?: string; + url: string; + createdAt: string; + updatedAt: string; + additions?: number; + deletions?: number; + commits?: number; + reviewDecision?: string | null; + checks?: unknown; + statusCheckRollup?: unknown; +}; + +function mapPrInfo(raw: GhPr): PRInfo { + const author = typeof raw.author === "string" ? raw.author : (raw.author?.login ?? ""); + return { + number: raw.number, + title: raw.title, + state: raw.state, + author, + branch: raw.headRefName ?? raw.branch ?? "", + base: raw.baseRefName ?? raw.base ?? "", + url: raw.url, + createdAt: raw.createdAt, + updatedAt: raw.updatedAt, + additions: raw.additions ?? 0, + deletions: raw.deletions ?? 0, + commits: raw.commits ?? 0, + reviewDecision: raw.reviewDecision ?? undefined, + checks: parseChecks(raw.checks ?? raw.statusCheckRollup), + }; +} + +export async function pr(params: PRParams, cwd?: string): Promise | ToolError> { + if (params.action === "list") { + const args = [ + "pr", + "list", + "--json", + "number,title,state,author,headRefName,baseRefName,url,createdAt,updatedAt,additions,deletions,commits,reviewDecision,checks", + ]; + if (params.limit) args.push("--limit", String(params.limit)); + if (params.state) args.push("--state", params.state); + if (params.author) args.push("--author", params.author); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const raw = JSON.parse(result.stdout) as GhPr[]; + const prs = raw.map((item) => mapPrInfo(item)); + const data: PRListResult = { prs }; + return { data, _rendered: `PRs: ${prs.length}` }; + } + + if (params.action === "view") { + if (!params.number) { + return { error: "PR number required" }; + } + const result = await gh( + [ + "pr", + "view", + String(params.number), + "--json", + "number,title,state,author,headRefName,baseRefName,url,createdAt,updatedAt,additions,deletions,commits,reviewDecision,checks", + ], + { cwd }, + ); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const raw = JSON.parse(result.stdout) as GhPr; + const prInfo = mapPrInfo(raw); + return { data: { pr: prInfo }, _rendered: `PR #${prInfo.number}: ${prInfo.title}` }; + } + + if (params.action === "create") { + const args = ["pr", "create"]; + if (params.title) args.push("--title", params.title); + if (params.body) args.push("--body", params.body); + if (params.base) args.push("--base", params.base); + if (params.head) args.push("--head", params.head); + if (params.draft) args.push("--draft"); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + let number = 0; + let url = ""; + const viewArgs = ["pr", "view", "--json", "number,url"]; + if (params.head) viewArgs.push("--head", params.head); + const viewResult = await gh(viewArgs, { cwd }); + if (!viewResult.error && viewResult.stdout.trim().length > 0) { + const raw = JSON.parse(viewResult.stdout) as { number: number; url: string }; + number = raw.number; + url = raw.url; + } else { + const match = result.stdout.match(/https?:\/\/\S+/); + if (match) { + url = match[0]; + const numMatch = url.match(/pull\/(\d+)/); + if (numMatch) number = Number.parseInt(numMatch[1], 10); + } + } + + const data: PRCreateResult = { number, url }; + return { data, _rendered: url ? `Created PR ${url}` : "Created PR" }; + } + + if (params.action === "diff") { + if (!params.number) { + return { error: "PR number required" }; + } + const result = await gh(["pr", "diff", String(params.number)], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PRActionResult = { status: "success", diff: result.stdout }; + return { data, _rendered: "PR diff" }; + } + + if (params.action === "checkout") { + if (!params.number) { + return { error: "PR number required" }; + } + const result = await gh(["pr", "checkout", String(params.number)], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PRActionResult = { status: "success" }; + return { data, _rendered: `Checked out PR #${params.number}` }; + } + + if (params.action === "merge") { + if (!params.number) { + return { error: "PR number required" }; + } + const args = ["pr", "merge", String(params.number)]; + if (params.merge_method === "merge") args.push("--merge"); + if (params.merge_method === "squash") args.push("--squash"); + if (params.merge_method === "rebase") args.push("--rebase"); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PRActionResult = { status: "success" }; + return { data, _rendered: `Merged PR #${params.number}` }; + } + + if (params.action === "close") { + if (!params.number) { + return { error: "PR number required" }; + } + const result = await gh(["pr", "close", String(params.number)], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PRActionResult = { status: "success" }; + return { data, _rendered: `Closed PR #${params.number}` }; + } + + if (params.action === "ready") { + if (!params.number) { + return { error: "PR number required" }; + } + const result = await gh(["pr", "ready", String(params.number)], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PRActionResult = { status: "success" }; + return { data, _rendered: `Marked PR #${params.number} ready` }; + } + + if (params.action === "review") { + if (!params.number) { + return { error: "PR number required" }; + } + if (!params.review_action) { + return { error: "review_action required" }; + } + if (params.review_action === "comment" && !params.review_body) { + return { error: "review_body required for comment review" }; + } + const args = ["pr", "review", String(params.number)]; + if (params.review_action === "approve") args.push("--approve"); + if (params.review_action === "request-changes") args.push("--request-changes"); + if (params.review_action === "comment") args.push("--comment"); + if (params.review_body) args.push("--body", params.review_body); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PRActionResult = { status: "success" }; + return { data, _rendered: `Reviewed PR #${params.number}` }; + } + + return { error: `Unknown PR action: ${params.action}` }; +} diff --git a/packages/git-tool/src/operations/github/release.ts b/packages/git-tool/src/operations/github/release.ts new file mode 100644 index 000000000..40a6522c7 --- /dev/null +++ b/packages/git-tool/src/operations/github/release.ts @@ -0,0 +1,109 @@ +import type { ReleaseInfo, ReleaseListResult, ReleaseParams, ReleaseResult, ToolError, ToolResult } from "../../types"; +import { gh } from "../../utils"; + +type GhRelease = { + tagName?: string; + tag?: string; + name?: string; + body?: string; + isDraft?: boolean; + draft?: boolean; + isPrerelease?: boolean; + prerelease?: boolean; + createdAt: string; + publishedAt?: string; + url: string; + assets?: Array<{ name: string; size: number; downloadCount?: number; download_count?: number }>; +}; + +function mapReleaseInfo(raw: GhRelease): ReleaseInfo { + return { + tag: raw.tagName ?? raw.tag ?? "", + name: raw.name ?? "", + body: raw.body ?? "", + draft: raw.isDraft ?? raw.draft ?? false, + prerelease: raw.isPrerelease ?? raw.prerelease ?? false, + createdAt: raw.createdAt, + publishedAt: raw.publishedAt ?? "", + url: raw.url, + assets: (raw.assets ?? []).map((asset) => ({ + name: asset.name, + size: asset.size, + downloadCount: asset.downloadCount ?? asset.download_count ?? 0, + })), + }; +} + +export async function release(params: ReleaseParams, cwd?: string): Promise | ToolError> { + if (params.action === "list") { + const args = ["release", "list", "--json", "name,tagName,createdAt,publishedAt,isDraft,isPrerelease,url,assets"]; + if (params.limit) args.push("--limit", String(params.limit)); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const raw = JSON.parse(result.stdout) as GhRelease[]; + const releases = raw.map((item) => mapReleaseInfo(item)); + const data: ReleaseListResult = { releases }; + return { data, _rendered: `Releases: ${releases.length}` }; + } + + if (params.action === "view") { + if (!params.tag) return { error: "tag required" }; + const result = await gh( + [ + "release", + "view", + params.tag, + "--json", + "name,tagName,body,createdAt,publishedAt,isDraft,isPrerelease,url,assets", + ], + { cwd }, + ); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const info = mapReleaseInfo(JSON.parse(result.stdout) as GhRelease); + return { data: info, _rendered: `Release ${info.tag}` }; + } + + if (params.action === "create") { + if (!params.tag) return { error: "tag required" }; + const args = ["release", "create", params.tag]; + if (params.title) args.push("--title", params.title); + if (params.notes) args.push("--notes", params.notes); + if (params.generate_notes) args.push("--generate-notes"); + if (params.draft) args.push("--draft"); + if (params.prerelease) args.push("--prerelease"); + if (params.target) args.push("--target", params.target); + const result = await gh(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const urlMatch = result.stdout.match(/https?:\/\/\S+/); + const url = urlMatch ? urlMatch[0] : undefined; + return { data: { status: "success", url }, _rendered: url ? `Created release ${url}` : "Created release" }; + } + + if (params.action === "delete") { + if (!params.tag) return { error: "tag required" }; + const result = await gh(["release", "delete", params.tag, "--yes"], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + return { data: { status: "success" }, _rendered: `Deleted release ${params.tag}` }; + } + + if (params.action === "upload") { + if (!params.tag || !params.assets || params.assets.length === 0) { + return { error: "tag and assets required" }; + } + const result = await gh(["release", "upload", params.tag, ...params.assets], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + return { data: { status: "success" }, _rendered: `Uploaded assets to ${params.tag}` }; + } + + return { error: `Unknown release action: ${params.action}` }; +} diff --git a/packages/git-tool/src/operations/log.ts b/packages/git-tool/src/operations/log.ts new file mode 100644 index 000000000..61dcaf195 --- /dev/null +++ b/packages/git-tool/src/operations/log.ts @@ -0,0 +1,63 @@ +import { parseLog } from "../parsers/log-parser"; +import { renderLog } from "../render"; +import type { Commit, LogParams, LogResult, ToolError, ToolResult } from "../types"; +import { git, parseShortstat } from "../utils"; + +const LOG_FORMAT = "%H%x00%h%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%b%x1e"; + +async function enrichStats(commits: Commit[], cwd?: string): Promise { + for (const commit of commits) { + const result = await git(["show", "-s", "--shortstat", commit.sha], { cwd }); + if (result.error) continue; + const statLine = result.stdout.split("\n").find((line) => line.includes("files changed")); + if (!statLine) continue; + const stats = parseShortstat(statLine); + if (stats) { + commit.stats = { + files: stats.files, + additions: stats.additions, + deletions: stats.deletions, + }; + } + } +} + +export async function log(params: LogParams, cwd?: string): Promise | ToolError> { + const limit = params.limit ?? 10; + const fetchLimit = limit + 1; + + const args = ["log", `--format=${LOG_FORMAT}`, "-n", String(fetchLimit)]; + if (params.ref) args.push(params.ref); + if (params.author) args.push(`--author=${params.author}`); + if (params.since) args.push(`--since=${params.since}`); + if (params.until) args.push(`--until=${params.until}`); + if (params.grep) args.push(`--grep=${params.grep}`); + if (params.merges === true) args.push("--merges"); + if (params.merges === false) args.push("--no-merges"); + if (params.first_parent) args.push("--first-parent"); + if (params.paths && params.paths.length > 0) { + args.push("--", ...params.paths); + } + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + let commits = parseLog(result.stdout); + const hasMore = commits.length > limit; + if (hasMore) commits = commits.slice(0, limit); + + if (params.format && params.format !== "full") { + for (const commit of commits) { + commit.message = commit.subject; + } + } + + if (params.stat) { + await enrichStats(commits, cwd); + } + + const finalResult: LogResult = { commits, hasMore }; + return { data: finalResult, _rendered: renderLog(finalResult) }; +} diff --git a/packages/git-tool/src/operations/merge.ts b/packages/git-tool/src/operations/merge.ts new file mode 100644 index 000000000..578d77106 --- /dev/null +++ b/packages/git-tool/src/operations/merge.ts @@ -0,0 +1,62 @@ +import { GitErrorCode } from "../errors"; +import { renderMerge } from "../render"; +import type { MergeParams, MergeResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +async function getConflicts(cwd?: string): Promise { + const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); + if (result.error) return []; + return result.stdout.split("\n").filter(Boolean); +} + +export async function merge(params: MergeParams, cwd?: string): Promise | ToolError> { + if (params.abort) { + const result = await git(["merge", "--abort"], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: MergeResult = { status: "success" }; + return { data, _rendered: renderMerge(data) }; + } + + if (params.continue) { + const result = await git(["merge", "--continue"], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const shaResult = await git(["rev-parse", "HEAD"], { cwd }); + const data: MergeResult = { status: "success", sha: shaResult.error ? undefined : shaResult.stdout.trim() }; + return { data, _rendered: renderMerge(data) }; + } + + const args = ["merge"]; + if (params.no_ff) args.push("--no-ff"); + if (params.ff_only) args.push("--ff-only"); + if (params.squash) args.push("--squash"); + if (params.message) args.push("-m", params.message); + if (!params.ref) { + return { error: "Merge ref is required" }; + } + args.push(params.ref); + + const result = await git(args, { cwd }); + if (result.error) { + if (result.error.code === GitErrorCode.MERGE_CONFLICT) { + const conflicts = await getConflicts(cwd); + const data: MergeResult = { status: "conflict", conflicts }; + return { data, _rendered: renderMerge(data) }; + } + return { error: result.error.message, code: result.error.code }; + } + + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + let status: MergeResult["status"] = "success"; + if (output.includes("already up to date")) { + status = "up-to-date"; + } else if (output.includes("fast-forward")) { + status = "fast-forward"; + } + const shaResult = await git(["rev-parse", "HEAD"], { cwd }); + const data: MergeResult = { status, sha: shaResult.error ? undefined : shaResult.stdout.trim() }; + return { data, _rendered: renderMerge(data) }; +} diff --git a/packages/git-tool/src/operations/pull.ts b/packages/git-tool/src/operations/pull.ts new file mode 100644 index 000000000..224cd7a52 --- /dev/null +++ b/packages/git-tool/src/operations/pull.ts @@ -0,0 +1,47 @@ +import { GitErrorCode } from "../errors"; +import { renderPull } from "../render"; +import type { PullParams, PullResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +async function getConflicts(cwd?: string): Promise { + const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); + if (result.error) return []; + return result.stdout.split("\n").filter(Boolean); +} + +async function countPulledCommits(cwd?: string): Promise { + const origResult = await git(["rev-parse", "-q", "--verify", "ORIG_HEAD"], { cwd }); + if (origResult.error) return 0; + const orig = origResult.stdout.trim(); + const countResult = await git(["rev-list", "--count", `${orig}..HEAD`], { cwd }); + if (countResult.error) return 0; + return Number.parseInt(countResult.stdout.trim(), 10); +} + +export async function pull(params: PullParams, cwd?: string): Promise | ToolError> { + const args = ["pull"]; + if (params.rebase) args.push("--rebase"); + if (params.ff_only) args.push("--ff-only"); + if (params.remote) args.push(params.remote); + if (params.branch) args.push(params.branch); + + const result = await git(args, { cwd }); + if (result.error) { + if (result.error.code === GitErrorCode.MERGE_CONFLICT || result.error.code === GitErrorCode.REBASE_CONFLICT) { + const conflicts = await getConflicts(cwd); + const data: PullResult = { status: "conflict", conflicts }; + return { data, _rendered: renderPull(data) }; + } + return { error: result.error.message, code: result.error.code }; + } + + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + if (output.includes("already up to date")) { + const data: PullResult = { status: "up-to-date" }; + return { data, _rendered: renderPull(data) }; + } + + const commits = await countPulledCommits(cwd); + const data: PullResult = { status: "success", commits }; + return { data, _rendered: renderPull(data) }; +} diff --git a/packages/git-tool/src/operations/push.ts b/packages/git-tool/src/operations/push.ts new file mode 100644 index 000000000..2ff1f53a4 --- /dev/null +++ b/packages/git-tool/src/operations/push.ts @@ -0,0 +1,59 @@ +import { renderPush } from "../render"; +import type { PushParams, PushResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +async function getCurrentBranch(cwd?: string): Promise { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); + if (result.error) return ""; + return result.stdout.trim(); +} + +async function hasRemoteRef(remote: string, branch: string, cwd?: string): Promise { + const result = await git(["rev-parse", "--verify", `refs/remotes/${remote}/${branch}`], { cwd }); + return !result.error; +} + +async function countCommitsToPush(remote: string, branch: string, hasRemote: boolean, cwd?: string): Promise { + if (hasRemote) { + const result = await git(["rev-list", "--count", `refs/remotes/${remote}/${branch}..${branch}`], { cwd }); + if (result.error) return 0; + return Number.parseInt(result.stdout.trim(), 10); + } + const result = await git(["rev-list", "--count", branch], { cwd }); + if (result.error) return 0; + return Number.parseInt(result.stdout.trim(), 10); +} + +export async function push(params: PushParams, cwd?: string): Promise | ToolError> { + const remote = params.remote ?? "origin"; + const branch = params.branch ?? (await getCurrentBranch(cwd)); + if (!branch) { + return { error: "Branch not found", code: "BRANCH_NOT_FOUND" }; + } + + if (params.delete) { + const result = await git(["push", remote, "--delete", branch], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: PushResult = { remote, branch, commits: 0, newBranch: false }; + return { data, _rendered: renderPush(data) }; + } + + const hasRemote = await hasRemoteRef(remote, branch, cwd); + const commits = await countCommitsToPush(remote, branch, hasRemote, cwd); + + const args = ["push", remote, branch]; + if (params.set_upstream) args.push("--set-upstream"); + if (params.tags) args.push("--tags"); + if (params.force_with_lease) args.push("--force-with-lease"); + if (params.force) args.push("--force"); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const data: PushResult = { remote, branch, commits, newBranch: !hasRemote }; + return { data, _rendered: renderPush(data) }; +} diff --git a/packages/git-tool/src/operations/rebase.ts b/packages/git-tool/src/operations/rebase.ts new file mode 100644 index 000000000..53c7cab1f --- /dev/null +++ b/packages/git-tool/src/operations/rebase.ts @@ -0,0 +1,36 @@ +import { GitErrorCode } from "../errors"; +import { renderRebase } from "../render"; +import type { RebaseParams, RebaseResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +async function getConflicts(cwd?: string): Promise { + const result = await git(["diff", "--name-only", "--diff-filter=U"], { cwd }); + if (result.error) return []; + return result.stdout.split("\n").filter(Boolean); +} + +export async function rebase(params: RebaseParams, cwd?: string): Promise | ToolError> { + const args = ["rebase"]; + if (params.abort) args.push("--abort"); + if (params.continue) args.push("--continue"); + if (params.skip) args.push("--skip"); + if (!params.abort && !params.continue && !params.skip) { + if (params.onto) args.push("--onto", params.onto); + if (params.upstream) args.push(params.upstream); + } + + const result = await git(args, { cwd }); + if (result.error) { + if (result.error.code === GitErrorCode.REBASE_CONFLICT || result.error.code === GitErrorCode.MERGE_CONFLICT) { + const conflicts = await getConflicts(cwd); + const data: RebaseResult = { status: "conflict", conflicts }; + return { data, _rendered: renderRebase(data) }; + } + return { error: result.error.message, code: result.error.code }; + } + + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); + const status: RebaseResult["status"] = output.includes("up to date") ? "up-to-date" : "success"; + const data: RebaseResult = { status }; + return { data, _rendered: renderRebase(data) }; +} diff --git a/packages/git-tool/src/operations/restore.ts b/packages/git-tool/src/operations/restore.ts new file mode 100644 index 000000000..4dc6f4a99 --- /dev/null +++ b/packages/git-tool/src/operations/restore.ts @@ -0,0 +1,19 @@ +import { renderRestore } from "../render"; +import type { RestoreParams, RestoreResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +export async function restore(params: RestoreParams, cwd?: string): Promise | ToolError> { + const args = ["restore"]; + if (params.staged) args.push("--staged"); + if (params.worktree) args.push("--worktree"); + if (params.source) args.push(`--source=${params.source}`); + args.push("--", ...params.paths); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const data: RestoreResult = { restored: params.paths }; + return { data, _rendered: renderRestore(data) }; +} diff --git a/packages/git-tool/src/operations/show.ts b/packages/git-tool/src/operations/show.ts new file mode 100644 index 000000000..5f59e1ee3 --- /dev/null +++ b/packages/git-tool/src/operations/show.ts @@ -0,0 +1,102 @@ +import { parseDiff } from "../parsers/diff-parser"; +import { parseLog } from "../parsers/log-parser"; +import { renderShowCommit, renderShowFile } from "../render"; +import type { ShowCommitResult, ShowFileResult, ShowParams, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +const LOG_FORMAT = "%H%x00%h%x00%an%x00%ae%x00%aI%x00%cn%x00%ce%x00%cI%x00%P%x00%s%x00%b%x1e"; + +const MAX_SHOW_LINES = 2000; +const MAX_SHOW_BYTES = 200_000; + +export async function show( + params: ShowParams, + cwd?: string, +): Promise | ToolError> { + if (params.path) { + const result = await git(["show", `${params.ref}:${params.path}`], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + let content = result.stdout; + let truncated = false; + + // Ambiguity: no truncation limits specified for show file; defaulting to 2000 lines or 200KB. + if (content.length > MAX_SHOW_BYTES) { + content = content.slice(0, MAX_SHOW_BYTES); + truncated = true; + } + + let lines = content.split("\n"); + if (lines.length > MAX_SHOW_LINES) { + lines = lines.slice(0, MAX_SHOW_LINES); + content = lines.join("\n"); + truncated = true; + } + + if (params.lines) { + const start = Math.max(1, params.lines.start); + const end = Math.max(start, params.lines.end); + const slice = lines.slice(start - 1, end); + content = slice.join("\n"); + truncated = false; + } + + const data: ShowFileResult = { + path: params.path, + ref: params.ref, + content, + truncated, + }; + return { data, _rendered: renderShowFile(data) }; + } + + const commitResult = await git(["show", "-s", `--format=${LOG_FORMAT}`, params.ref], { cwd }); + if (commitResult.error) { + return { error: commitResult.error.message, code: commitResult.error.code }; + } + const commits = parseLog(commitResult.stdout); + const commit = commits[0]; + if (!commit) { + return { error: "Commit not found", code: "REF_NOT_FOUND" }; + } + + let diffData: ShowCommitResult["diff"] | undefined; + if (params.diff || params.stat) { + const diffResult = await git(["show", params.ref, "--format="], { cwd }); + if (diffResult.error) { + return { error: diffResult.error.message, code: diffResult.error.code }; + } + const parsed = parseDiff(diffResult.stdout); + const files = parsed.files.map((file) => { + if (!params.diff) { + delete file.hunks; + } + return file; + }); + const stats = files.reduce( + (acc, file) => { + acc.filesChanged += 1; + acc.insertions += file.additions; + acc.deletions += file.deletions; + return acc; + }, + { filesChanged: 0, insertions: 0, deletions: 0 }, + ); + const diff: ShowCommitResult["diff"] = { + files, + stats, + truncated: parsed.truncated, + truncatedFiles: parsed.truncatedFiles.length > 0 ? parsed.truncatedFiles : undefined, + }; + diffData = diff; + } + + const data: ShowCommitResult = { + commit, + ...(diffData ? { diff: diffData } : {}), + }; + + return { data, _rendered: renderShowCommit(data) }; +} diff --git a/packages/git-tool/src/operations/stash.ts b/packages/git-tool/src/operations/stash.ts new file mode 100644 index 000000000..0bda5e235 --- /dev/null +++ b/packages/git-tool/src/operations/stash.ts @@ -0,0 +1,86 @@ +import type { StashEntry, StashParams, StashResult, ToolError, ToolResult } from "../types"; +import { git, parseShortstat } from "../utils"; + +function parseStashIndex(ref: string): number { + const match = ref.match(/stash@\{(\d+)\}/); + return match ? Number.parseInt(match[1], 10) : -1; +} + +function parseBranch(subject: string): string { + const match = subject.match(/on ([^:]+):/i); + return match ? match[1] : ""; +} + +export async function stash(params: StashParams, cwd?: string): Promise | ToolError> { + const action = params.action ?? "list"; + + if (action === "list") { + const result = await git(["stash", "list", "--date=iso-strict", "--format=%gd%x00%gs%x00%cd"], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const stashes: StashEntry[] = []; + for (const line of result.stdout.split("\n")) { + if (!line) continue; + const parts = line.split("\x00"); + if (parts.length < 3) continue; + const ref = parts[0]; + const message = parts[1]; + const date = parts[2]; + stashes.push({ + index: parseStashIndex(ref), + message, + branch: parseBranch(message), + date, + }); + } + return { data: { stashes }, _rendered: `Stashes: ${stashes.length}` }; + } + + if (action === "show") { + const index = params.index ?? 0; + const ref = `stash@{${index}}`; + const statResult = await git(["stash", "show", "--shortstat", ref], { cwd }); + if (statResult.error) { + return { error: statResult.error.message, code: statResult.error.code }; + } + const statLine = statResult.stdout.split("\n").find((line) => line.includes("files changed")); + const statsParsed = statLine ? parseShortstat(statLine) : null; + const stats = { + files: statsParsed?.files ?? 0, + additions: statsParsed?.additions ?? 0, + deletions: statsParsed?.deletions ?? 0, + }; + const filesResult = await git(["stash", "show", "--name-only", ref], { cwd }); + if (filesResult.error) { + return { error: filesResult.error.message, code: filesResult.error.code }; + } + const files = filesResult.stdout.split("\n").filter(Boolean); + return { data: { stats, files }, _rendered: `Stash ${index} (${files.length} files)` }; + } + + if (action === "push") { + const args = ["stash", "push"]; + if (params.message) args.push("-m", params.message); + if (params.include_untracked) args.push("-u"); + if (params.keep_index) args.push("--keep-index"); + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + return { data: { status: "success" }, _rendered: "Stash saved" }; + } + + if (action === "pop" || action === "apply" || action === "drop") { + const index = params.index ?? 0; + const ref = `stash@{${index}}`; + const args = ["stash", action, ref]; + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + return { data: { status: "success" }, _rendered: `Stash ${action} ${index}` }; + } + + return { error: `Unknown stash action: ${action}` }; +} diff --git a/packages/git-tool/src/operations/status.ts b/packages/git-tool/src/operations/status.ts new file mode 100644 index 000000000..8a12a2c2e --- /dev/null +++ b/packages/git-tool/src/operations/status.ts @@ -0,0 +1,54 @@ +import { parseStatus } from "../parsers/status-parser"; +import { renderStatus } from "../render"; +import type { StatusParams, StatusResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +export async function status(params: StatusParams, cwd?: string): Promise | ToolError> { + const args = ["status", "--porcelain=v2", "--branch", "--ahead-behind"]; + if (params.ignored) args.push("--ignored"); + + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + + const parsed = parseStatus(result.stdout, Boolean(params.ignored)); + let finalResult = parsed; + + if (params.only) { + const base: StatusResult = { + branch: parsed.branch, + upstream: parsed.upstream, + ahead: parsed.ahead, + behind: parsed.behind, + staged: [], + modified: [], + untracked: [], + conflicts: [], + }; + switch (params.only) { + case "branch": + finalResult = base; + break; + case "modified": + finalResult = { ...base, modified: parsed.modified }; + break; + case "staged": + finalResult = { ...base, staged: parsed.staged }; + break; + case "untracked": + finalResult = { ...base, untracked: parsed.untracked }; + break; + case "conflicts": + finalResult = { ...base, conflicts: parsed.conflicts }; + break; + case "sync": + finalResult = base; + break; + default: + finalResult = parsed; + } + } + + return { data: finalResult, _rendered: renderStatus(finalResult) }; +} diff --git a/packages/git-tool/src/operations/tag.ts b/packages/git-tool/src/operations/tag.ts new file mode 100644 index 000000000..ba7bfd247 --- /dev/null +++ b/packages/git-tool/src/operations/tag.ts @@ -0,0 +1,79 @@ +import { renderTag } from "../render"; +import type { TagInfo, TagParams, TagResult, ToolError, ToolResult } from "../types"; +import { git } from "../utils"; + +export async function tag(params: TagParams, cwd?: string): Promise | ToolError> { + const action = params.action ?? "list"; + + if (action === "list") { + const result = await git( + [ + "for-each-ref", + "refs/tags", + "--format=%(refname:short)%x00%(objectname)%x00%(taggername)%x00%(taggerdate:iso-strict)%x00%(subject)%x00%(objecttype)", + ], + { cwd }, + ); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const tags: TagInfo[] = []; + for (const line of result.stdout.split("\n")) { + if (!line) continue; + const parts = line.split("\x00"); + if (parts.length < 6) continue; + const [name, sha, taggerName, taggerDate, subject, objectType] = parts; + const annotated = objectType === "tag"; + const tagger = taggerName && taggerDate ? { name: taggerName, date: taggerDate } : undefined; + tags.push({ name, sha, message: subject || undefined, tagger, annotated }); + } + const data: TagResult = { tags }; + return { data, _rendered: renderTag(data) }; + } + + if (action === "create") { + if (!params.name) { + return { error: "Tag name required" }; + } + const args = ["tag"]; + if (params.force) args.push("-f"); + if (params.sign) args.push("-s"); + if (params.message) { + args.push("-a", "-m", params.message); + } + args.push(params.name); + if (params.ref) args.push(params.ref); + const result = await git(args, { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: TagResult = { status: "success" }; + return { data, _rendered: renderTag(data) }; + } + + if (action === "delete") { + if (!params.name) { + return { error: "Tag name required" }; + } + const result = await git(["tag", "-d", params.name], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: TagResult = { status: "success" }; + return { data, _rendered: renderTag(data) }; + } + + if (action === "push") { + if (!params.name) { + return { error: "Tag name required" }; + } + const result = await git(["push", "origin", params.name], { cwd }); + if (result.error) { + return { error: result.error.message, code: result.error.code }; + } + const data: TagResult = { status: "success" }; + return { data, _rendered: renderTag(data) }; + } + + return { error: `Unknown tag action: ${action}` }; +} diff --git a/packages/git-tool/src/parsers/blame-parser.ts b/packages/git-tool/src/parsers/blame-parser.ts new file mode 100644 index 000000000..08f6db405 --- /dev/null +++ b/packages/git-tool/src/parsers/blame-parser.ts @@ -0,0 +1,91 @@ +import type { BlameLine } from "../types"; + +interface BlameMeta { + sha: string; + shortSha: string; + author: string; + date: string; + lineNo: number; + original?: { sha: string; path: string; lineNo: number }; +} + +function formatDate(timestamp: string): string { + const seconds = Number.parseInt(timestamp, 10); + if (!Number.isFinite(seconds)) return ""; + return new Date(seconds * 1000).toISOString(); +} + +export function parseBlame(output: string): BlameLine[] { + const lines = output.split("\n"); + const result: BlameLine[] = []; + let current: BlameMeta | null = null; + let remaining = 0; + let currentFilename = ""; + + for (const line of lines) { + if (!line) continue; + if (line.startsWith("\t")) { + if (!current) continue; + result.push({ + lineNo: current.lineNo, + sha: current.sha, + shortSha: current.shortSha, + author: current.author, + date: current.date, + content: line.slice(1), + original: current.original, + }); + current.lineNo += 1; + remaining -= 1; + if (remaining <= 0) { + current = null; + } + continue; + } + + const headerMatch = line.match(/^([0-9a-f]{40}) (\d+) (\d+) (\d+)/); + if (headerMatch) { + const sha = headerMatch[1]; + const finalLine = Number.parseInt(headerMatch[3], 10); + remaining = Number.parseInt(headerMatch[4], 10); + current = { + sha, + shortSha: sha.slice(0, 7), + author: "", + date: "", + lineNo: finalLine, + }; + continue; + } + + if (!current) continue; + + if (line.startsWith("author ")) { + current.author = line.slice(7).trim(); + continue; + } + if (line.startsWith("author-time ")) { + current.date = formatDate(line.slice(12).trim()); + continue; + } + if (line.startsWith("previous ")) { + const parts = line.split(" "); + if (parts.length >= 3) { + current.original = { + sha: parts[1], + path: currentFilename, + lineNo: current.lineNo, + }; + } + continue; + } + if (line.startsWith("filename ")) { + currentFilename = line.slice(9).trim(); + if (current.original) { + current.original.path = currentFilename; + } + } + } + + return result; +} diff --git a/packages/git-tool/src/parsers/diff-parser.ts b/packages/git-tool/src/parsers/diff-parser.ts new file mode 100644 index 000000000..1672b6b4f --- /dev/null +++ b/packages/git-tool/src/parsers/diff-parser.ts @@ -0,0 +1,162 @@ +import type { DiffLine, FileDiff, Hunk } from "../types"; + +export interface DiffParseResult { + files: FileDiff[]; + truncated: boolean; + truncatedFiles: string[]; +} + +export interface DiffParseOptions { + maxLines?: number; +} + +function parseHunkHeader(header: string): { oldStart: number; oldCount: number; newStart: number; newCount: number } { + const match = header.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (!match) { + return { oldStart: 0, oldCount: 0, newStart: 0, newCount: 0 }; + } + return { + oldStart: Number.parseInt(match[1], 10), + oldCount: Number.parseInt(match[2] ?? "1", 10), + newStart: Number.parseInt(match[3], 10), + newCount: Number.parseInt(match[4] ?? "1", 10), + }; +} + +export function parseDiff(raw: string, options?: DiffParseOptions): DiffParseResult { + const maxLines = options?.maxLines ?? Number.POSITIVE_INFINITY; + const lines = raw.split("\n"); + const files: FileDiff[] = []; + const truncatedFiles: string[] = []; + let truncated = false; + let lineCount = 0; + + let current: FileDiff | null = null; + let currentHunk: Hunk | null = null; + let oldLine = 0; + let newLine = 0; + + const finalizeHunk = () => { + if (current && currentHunk) { + current.hunks = current.hunks ?? []; + current.hunks.push(currentHunk); + currentHunk = null; + } + }; + + const finalizeFile = () => { + finalizeHunk(); + if (current) { + files.push(current); + current = null; + } + }; + + for (const line of lines) { + if (line.startsWith("diff --git ")) { + finalizeFile(); + const match = line.match(/^diff --git a\/(.*) b\/(.*)$/); + const path = match ? match[2] : line.slice("diff --git ".length).trim(); + const oldPath = match ? match[1] : undefined; + current = { + path, + oldPath, + status: "modified", + binary: false, + additions: 0, + deletions: 0, + }; + continue; + } + + if (!current) continue; + + if (line.startsWith("new file mode")) { + current.status = "added"; + continue; + } + if (line.startsWith("deleted file mode")) { + current.status = "deleted"; + continue; + } + if (line.startsWith("rename from ")) { + current.status = "renamed"; + current.oldPath = line.slice("rename from ".length).trim(); + continue; + } + if (line.startsWith("rename to ")) { + current.path = line.slice("rename to ".length).trim(); + continue; + } + if (line.startsWith("copy from ")) { + current.status = "copied"; + current.oldPath = line.slice("copy from ".length).trim(); + continue; + } + if (line.startsWith("copy to ")) { + current.path = line.slice("copy to ".length).trim(); + continue; + } + if (line.startsWith("Binary files ") || line.startsWith("GIT binary patch")) { + current.binary = true; + continue; + } + + if (line.startsWith("@@ ")) { + finalizeHunk(); + const header = line; + const ranges = parseHunkHeader(header); + currentHunk = { + oldStart: ranges.oldStart, + oldCount: ranges.oldCount, + newStart: ranges.newStart, + newCount: ranges.newCount, + header, + lines: [], + }; + oldLine = ranges.oldStart; + newLine = ranges.newStart; + continue; + } + + if (!currentHunk) { + continue; + } + + if (lineCount >= maxLines) { + if (!truncated) { + truncated = true; + truncatedFiles.push(current.path); + } + continue; + } + + if (line.startsWith("\\")) { + continue; + } + + let diffLine: DiffLine | null = null; + if (line.startsWith("+") && !line.startsWith("+++")) { + diffLine = { type: "add", content: line.slice(1), newLineNo: newLine }; + newLine += 1; + current.additions += 1; + } else if (line.startsWith("-") && !line.startsWith("---")) { + diffLine = { type: "delete", content: line.slice(1), oldLineNo: oldLine }; + oldLine += 1; + current.deletions += 1; + } else if (line.startsWith(" ")) { + diffLine = { type: "context", content: line.slice(1), oldLineNo: oldLine, newLineNo: newLine }; + oldLine += 1; + newLine += 1; + } + + if (diffLine) { + currentHunk.lines.push(diffLine); + lineCount += 1; + } + } + + finalizeFile(); + + return { files, truncated, truncatedFiles }; +} diff --git a/packages/git-tool/src/parsers/log-parser.ts b/packages/git-tool/src/parsers/log-parser.ts new file mode 100644 index 000000000..029bfe595 --- /dev/null +++ b/packages/git-tool/src/parsers/log-parser.ts @@ -0,0 +1,43 @@ +import type { Commit } from "../types"; + +const RECORD_SEPARATOR = "\x1e"; +const FIELD_SEPARATOR = "\x00"; + +export function parseLog(output: string): Commit[] { + const records = output.split(RECORD_SEPARATOR).filter((record) => record.trim().length > 0); + const commits: Commit[] = []; + + for (const record of records) { + const fields = record.split(FIELD_SEPARATOR); + if (fields.length < 11) continue; + + const [ + sha, + shortSha, + authorName, + authorEmail, + authorDate, + committerName, + committerEmail, + committerDate, + parentsRaw, + subject, + body, + ] = fields; + + const message = body ? `${subject}\n\n${body}` : subject; + const parents = parentsRaw ? parentsRaw.split(" ").filter(Boolean) : []; + + commits.push({ + sha, + shortSha, + author: { name: authorName, email: authorEmail, date: authorDate }, + committer: { name: committerName, email: committerEmail, date: committerDate }, + message, + subject, + parents, + }); + } + + return commits; +} diff --git a/packages/git-tool/src/parsers/status-parser.ts b/packages/git-tool/src/parsers/status-parser.ts new file mode 100644 index 000000000..76f13ba9c --- /dev/null +++ b/packages/git-tool/src/parsers/status-parser.ts @@ -0,0 +1,93 @@ +import type { FileStatus, StatusResult } from "../types"; + +function mapStatus(code: string, path: string, oldPath?: string): FileStatus { + const statusMap: Record = { + A: "added", + M: "modified", + D: "deleted", + R: "renamed", + C: "copied", + T: "modified", + U: "modified", + }; + return { path, status: statusMap[code] ?? "modified", oldPath }; +} + +export function parseStatus(output: string, includeIgnored: boolean): StatusResult { + const lines = output.split("\n"); + const result: StatusResult = { + branch: "", + upstream: null, + ahead: 0, + behind: 0, + staged: [], + modified: [], + untracked: [], + conflicts: [], + }; + if (includeIgnored) { + result.ignored = []; + } + + for (const line of lines) { + if (line.startsWith("# branch.head ")) { + result.branch = line.slice(14).trim(); + continue; + } + if (line.startsWith("# branch.upstream ")) { + result.upstream = line.slice(18).trim(); + continue; + } + if (line.startsWith("# branch.ab ")) { + const match = line.match(/\+(\d+) -?(\d+)/); + if (match) { + result.ahead = Number.parseInt(match[1], 10); + result.behind = Number.parseInt(match[2] ?? "0", 10); + } + continue; + } + + if (line.startsWith("1 ") || line.startsWith("2 ")) { + const parts = line.split(" "); + const xy = parts[1]; + let path = parts.slice(-1)[0] ?? ""; + let oldPath: string | undefined; + + if (line.startsWith("2 ")) { + const [beforeTab, afterTab] = line.split("\t"); + if (afterTab) { + const preParts = beforeTab.split(" "); + path = preParts[preParts.length - 1]; + oldPath = afterTab.trim(); + } + } + + if (xy[0] !== ".") { + result.staged.push(mapStatus(xy[0], path, oldPath)); + } + if (xy[1] !== ".") { + result.modified.push(mapStatus(xy[1], path, oldPath)); + } + continue; + } + + if (line.startsWith("u ")) { + const path = line.split(" ").slice(-1)[0]; + if (path) { + result.conflicts.push(path); + } + continue; + } + + if (line.startsWith("? ")) { + result.untracked.push(line.slice(2)); + continue; + } + + if (includeIgnored && line.startsWith("! ")) { + result.ignored?.push(line.slice(2)); + } + } + + return result; +} diff --git a/packages/git-tool/src/render.ts b/packages/git-tool/src/render.ts new file mode 100644 index 000000000..3c08adc62 --- /dev/null +++ b/packages/git-tool/src/render.ts @@ -0,0 +1,181 @@ +import type { + AddResult, + BlameResult, + BranchListResult, + CherryPickResult, + CommitResult, + DiffResult, + FetchResult, + LogResult, + MergeResult, + PullResult, + PushResult, + RebaseResult, + ReleaseResult, + RestoreResult, + ShowCommitResult, + ShowFileResult, + StatusResult, + TagResult, +} from "./types"; + +function renderFileList(title: string, files: string[]): string { + if (files.length === 0) return ""; + return `${title}:\n${files.map((file) => `- ${file}`).join("\n")}`; +} + +export function renderStatus(result: StatusResult): string { + const parts: string[] = []; + const sync = result.upstream + ? `(upstream ${result.upstream}, ahead ${result.ahead}, behind ${result.behind})` + : "(no upstream)"; + parts.push(`Branch: ${result.branch} ${sync}`); + + if (result.staged.length > 0) { + parts.push( + `Staged (${result.staged.length}):\n${result.staged + .map((file) => `- ${file.path} (${file.status})`) + .join("\n")}`, + ); + } + if (result.modified.length > 0) { + parts.push( + `Modified (${result.modified.length}):\n${result.modified + .map((file) => `- ${file.path} (${file.status})`) + .join("\n")}`, + ); + } + if (result.untracked.length > 0) { + parts.push(renderFileList(`Untracked (${result.untracked.length})`, result.untracked)); + } + if (result.conflicts.length > 0) { + parts.push(renderFileList(`Conflicts (${result.conflicts.length})`, result.conflicts)); + } + if (result.ignored && result.ignored.length > 0) { + parts.push(renderFileList(`Ignored (${result.ignored.length})`, result.ignored)); + } + + return parts.join("\n\n"); +} + +export function renderDiff(result: DiffResult): string { + const header = `${result.stats.filesChanged} files changed, ${result.stats.insertions} insertions(+), ${result.stats.deletions} deletions(-)`; + const files = result.files.map((file) => { + const status = file.status.toUpperCase(); + return `- ${status} ${file.path} (+${file.additions}/-${file.deletions})`; + }); + const truncatedNote = result.truncated ? "\n\nDiff output truncated." : ""; + return [header, ...files].join("\n") + truncatedNote; +} + +export function renderLog(result: LogResult): string { + if (result.commits.length === 0) return "No commits found."; + const lines = result.commits.map((commit) => `${commit.shortSha} ${commit.subject}`); + const more = result.hasMore ? "\n(more commits available)" : ""; + return lines.join("\n") + more; +} + +export function renderShowCommit(result: ShowCommitResult): string { + const commit = result.commit; + const header = `${commit.shortSha} ${commit.subject}`; + if (!result.diff) return header; + return `${header}\n\n${renderDiff(result.diff)}`; +} + +export function renderShowFile(result: ShowFileResult): string { + const truncated = result.truncated ? "\n\n[truncated]" : ""; + return `${result.content}${truncated}`; +} + +export function renderBlame(result: BlameResult): string { + return result.lines.map((line) => `${line.shortSha} ${line.lineNo} ${line.author}: ${line.content}`).join("\n"); +} + +export function renderBranchList(result: BranchListResult): string { + const lines: string[] = []; + lines.push(`Current: ${result.current}`); + lines.push("Local:"); + for (const branch of result.local) { + const track = branch.upstream + ? ` (${branch.upstream} ahead ${branch.ahead ?? 0} behind ${branch.behind ?? 0}${branch.gone ? ", gone" : ""})` + : ""; + lines.push(`- ${branch.name}${track}`); + } + if (result.remote && result.remote.length > 0) { + lines.push("Remote:"); + for (const branch of result.remote) { + lines.push(`- ${branch.name}`); + } + } + return lines.join("\n"); +} + +export function renderAdd(result: AddResult): string { + return `Staged ${result.staged.length} files`; +} + +export function renderRestore(result: RestoreResult): string { + return `Restored ${result.restored.length} files`; +} + +export function renderCommit(result: CommitResult): string { + return `${result.shortSha} ${result.subject}`; +} + +export function renderMerge(result: MergeResult): string { + if (result.status === "conflict") { + return `Merge conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; + } + return `Merge ${result.status}`; +} + +export function renderRebase(result: RebaseResult): string { + if (result.status === "conflict") { + return `Rebase conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; + } + return `Rebase ${result.status}`; +} + +export function renderCherryPick(result: CherryPickResult): string { + if (result.status === "conflict") { + return `Cherry-pick conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; + } + return `Cherry-pick applied ${result.appliedCommits?.length ?? 0} commits`; +} + +export function renderFetch(result: FetchResult): string { + const lines = result.updated.map((entry) => `- ${entry.ref} ${entry.oldSha} -> ${entry.newSha}`); + if (result.pruned && result.pruned.length > 0) { + lines.push(`Pruned: ${result.pruned.join(", ")}`); + } + return lines.length > 0 ? lines.join("\n") : "Fetch completed"; +} + +export function renderPull(result: PullResult): string { + if (result.status === "conflict") { + return `Pull conflict${result.conflicts?.length ? `: ${result.conflicts.join(", ")}` : ""}`; + } + if (result.status === "up-to-date") return "Already up to date."; + return `Pulled ${result.commits ?? 0} commits`; +} + +export function renderPush(result: PushResult): string { + return `Pushed ${result.commits} commits to ${result.remote}/${result.branch}`; +} + +export function renderRelease(result: ReleaseResult): string { + if ("releases" in result) { + return `Releases: ${result.releases.length}`; + } + if ("tag" in result) { + return `Release ${result.tag}`; + } + return "Release operation completed"; +} + +export function renderTag(result: TagResult): string { + if ("tags" in result) { + return `Tags: ${result.tags.length}`; + } + return "Tag operation completed"; +} diff --git a/packages/git-tool/src/safety/guards.ts b/packages/git-tool/src/safety/guards.ts new file mode 100644 index 000000000..7385d1a57 --- /dev/null +++ b/packages/git-tool/src/safety/guards.ts @@ -0,0 +1,144 @@ +import type { Operation, SafetyCheck, SafetyResult } from "../types"; +import { git } from "../utils"; +import { defaultPolicy, isProtectedBranch } from "./policies"; + +const sessionCommits = new Set(); + +export function markCommitCreated(sha: string): void { + sessionCommits.add(sha); +} + +async function getCurrentBranch(cwd?: string): Promise { + const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); + if (result.error) return ""; + return result.stdout.trim(); +} + +async function getHeadSha(cwd?: string): Promise { + const result = await git(["rev-parse", "HEAD"], { cwd }); + if (result.error) return ""; + return result.stdout.trim(); +} + +async function getBranchSync(cwd?: string): Promise<{ upstream: string | null; ahead: number; behind: number }> { + const result = await git(["status", "--porcelain=v2", "--branch", "--ahead-behind"], { cwd }); + if (result.error) return { upstream: null, ahead: 0, behind: 0 }; + + let upstream: string | null = null; + let ahead = 0; + let behind = 0; + for (const line of result.stdout.split("\n")) { + if (line.startsWith("# branch.upstream ")) { + upstream = line.slice(18).trim(); + } + if (line.startsWith("# branch.ab ")) { + const match = line.match(/\+(\d+) -?(\d+)/); + if (match) { + ahead = Number.parseInt(match[1], 10); + behind = Number.parseInt(match[2] ?? "0", 10); + } + } + } + return { upstream, ahead, behind }; +} + +async function isHeadPushed(cwd?: string): Promise { + const sync = await getBranchSync(cwd); + if (!sync.upstream) return false; + return sync.ahead === 0; +} + +function evaluateChecks(checks: SafetyCheck[]): SafetyResult { + const blocked = checks.find((check) => check.level === "block"); + if (blocked) { + return { + blocked: true, + confirm: false, + message: blocked.message, + suggestion: blocked.suggestion, + override: blocked.override, + warnings: [], + }; + } + const confirm = checks.find((check) => check.level === "confirm"); + if (confirm) { + return { + blocked: false, + confirm: true, + message: confirm.message, + suggestion: confirm.suggestion, + override: confirm.override, + warnings: [], + }; + } + const warnings = checks.filter((check) => check.level === "warn").map((check) => check.message); + return { + blocked: false, + confirm: false, + warnings, + }; +} + +export async function checkSafety( + operation: Operation, + params: Record, + cwd?: string, +): Promise { + const checks: SafetyCheck[] = []; + + if (operation === "push" && params.force) { + const branch = (params.branch as string | undefined) ?? (await getCurrentBranch(cwd)); + if (branch && isProtectedBranch(branch)) { + checks.push({ + level: defaultPolicy.forcePushMain, + message: `Force push to protected branch '${branch}' is blocked`, + override: "force_override", + }); + } else { + checks.push({ + level: defaultPolicy.forcePush, + message: `Force push to '${branch || "current branch"}' will overwrite remote history`, + suggestion: "Consider using force_with_lease instead", + }); + } + } + + if (operation === "commit" && params.amend) { + const pushed = await isHeadPushed(cwd); + const headSha = await getHeadSha(cwd); + if (pushed && headSha && !sessionCommits.has(headSha)) { + checks.push({ + level: defaultPolicy.amendPushed, + message: "Cannot amend: HEAD has been pushed to remote", + suggestion: "Create a new commit instead", + }); + } + } + + if (operation === "rebase") { + const pushed = await isHeadPushed(cwd); + if (pushed) { + checks.push({ + level: defaultPolicy.rebasePushed, + message: "Cannot rebase: HEAD has been pushed to remote", + suggestion: "Merge instead of rebasing pushed commits", + }); + } + } + + if (operation === "restore" && params.worktree && !params.staged) { + checks.push({ + level: defaultPolicy.discardChanges, + message: "Restoring worktree will discard local changes", + }); + } + + if (operation === "branch" && params.action === "delete") { + checks.push({ + level: defaultPolicy.deleteBranch, + message: "Deleting a branch will remove its ref", + }); + } + + return evaluateChecks(checks); +} diff --git a/packages/git-tool/src/safety/policies.ts b/packages/git-tool/src/safety/policies.ts new file mode 100644 index 000000000..f1d77b600 --- /dev/null +++ b/packages/git-tool/src/safety/policies.ts @@ -0,0 +1,25 @@ +import type { SafetyPolicy } from "../types"; + +export const defaultPolicy: SafetyPolicy = { + forcePush: "confirm", + forcePushMain: "block", + hardReset: "confirm", + discardChanges: "warn", + deleteBranch: "warn", + amendPushed: "block", + rebasePushed: "block", +}; + +export const defaultProtectedBranches = ["main", "master", "develop", "release/*"]; + +export function isProtectedBranch(branch: string): boolean { + return defaultProtectedBranches.some((pattern) => { + if (pattern.endsWith("/")) { + return branch.startsWith(pattern); + } + if (pattern.endsWith("/*")) { + return branch.startsWith(pattern.slice(0, -1)); + } + return branch === pattern; + }); +} diff --git a/packages/git-tool/src/types.ts b/packages/git-tool/src/types.ts new file mode 100644 index 000000000..c6cf5eef2 --- /dev/null +++ b/packages/git-tool/src/types.ts @@ -0,0 +1,668 @@ +export type Operation = + | "status" + | "diff" + | "log" + | "show" + | "blame" + | "branch" + | "add" + | "restore" + | "commit" + | "checkout" + | "merge" + | "rebase" + | "stash" + | "cherry-pick" + | "fetch" + | "pull" + | "push" + | "tag" + | "pr" + | "issue" + | "ci" + | "release"; + +export interface ToolResult { + data: T; + _rendered: string; +} + +export interface ToolConfirm { + confirm: string; + override: string; + _rendered?: string; +} + +export interface ToolError { + error: string; + code?: string; + suggestion?: string; + _rendered?: string; +} + +export type ToolResponse = ToolResult | ToolConfirm | ToolError; + +export interface FileStatus { + path: string; + status: "added" | "modified" | "deleted" | "renamed" | "copied"; + oldPath?: string; +} + +export interface StatusParams { + operation: "status"; + only?: "branch" | "modified" | "staged" | "untracked" | "conflicts" | "sync"; + ignored?: boolean; +} + +export interface StatusResult { + branch: string; + upstream: string | null; + ahead: number; + behind: number; + staged: FileStatus[]; + modified: FileStatus[]; + untracked: string[]; + conflicts: string[]; + ignored?: string[]; +} + +export type DiffTarget = + | "unstaged" + | "staged" + | "head" + | { + from: string; + to?: string; + }; + +export interface DiffParams { + operation: "diff"; + target?: DiffTarget; + paths?: string[]; + stat_only?: boolean; + name_only?: boolean; + context?: number; + max_lines?: number; + ignore_whitespace?: boolean; +} + +export interface DiffLine { + type: "context" | "add" | "delete"; + content: string; + oldLineNo?: number; + newLineNo?: number; +} + +export interface Hunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + header: string; + lines: DiffLine[]; +} + +export interface FileDiff { + path: string; + oldPath?: string; + status: "added" | "modified" | "deleted" | "renamed" | "copied"; + binary: boolean; + additions: number; + deletions: number; + hunks?: Hunk[]; +} + +export interface DiffResult { + files: FileDiff[]; + stats: { + filesChanged: number; + insertions: number; + deletions: number; + }; + truncated: boolean; + truncatedFiles?: string[]; +} + +export interface LogParams { + operation: "log"; + limit?: number; + ref?: string; + author?: string; + since?: string; + until?: string; + paths?: string[]; + grep?: string; + format?: "oneline" | "short" | "full"; + stat?: boolean; + merges?: boolean; + first_parent?: boolean; +} + +export interface CommitAuthor { + name: string; + email: string; + date: string; +} + +export interface Commit { + sha: string; + shortSha: string; + author: CommitAuthor; + committer: CommitAuthor; + message: string; + subject: string; + parents: string[]; + stats?: { additions: number; deletions: number; files: number }; +} + +export interface LogResult { + commits: Commit[]; + hasMore: boolean; +} + +export interface ShowParams { + operation: "show"; + ref: string; + path?: string; + diff?: boolean; + stat?: boolean; + lines?: { start: number; end: number }; +} + +export interface ShowCommitResult { + commit: Commit; + diff?: DiffResult; +} + +export interface ShowFileResult { + path: string; + ref: string; + content: string; + truncated: boolean; +} + +export interface BlameParams { + operation: "blame"; + path: string; + lines?: { start: number; end: number }; + root?: boolean; + ignore_whitespace?: boolean; +} + +export interface BlameLine { + lineNo: number; + sha: string; + shortSha: string; + author: string; + date: string; + content: string; + original?: { + sha: string; + path: string; + lineNo: number; + }; +} + +export interface BlameResult { + lines: BlameLine[]; +} + +export interface BranchParams { + operation: "branch"; + action?: "list" | "create" | "delete" | "rename" | "current"; + name?: string; + newName?: string; + startPoint?: string; + remotes?: boolean; + force?: boolean; +} + +export interface BranchInfo { + name: string; + sha: string; + upstream?: string; + ahead?: number; + behind?: number; + gone?: boolean; +} + +export interface BranchListResult { + current: string; + local: BranchInfo[]; + remote?: BranchInfo[]; +} + +export interface AddParams { + operation: "add"; + paths?: string[]; + update?: boolean; + all?: boolean; + dry_run?: boolean; +} + +export interface AddResult { + staged: string[]; +} + +export interface RestoreParams { + operation: "restore"; + paths: string[]; + staged?: boolean; + worktree?: boolean; + source?: string; +} + +export interface RestoreResult { + restored: string[]; +} + +export interface CommitParams { + operation: "commit"; + message: string; + all?: boolean; + allow_empty?: boolean; + sign?: boolean; + no_verify?: boolean; + amend?: boolean; +} + +export interface CommitResult { + sha: string; + shortSha: string; + subject: string; + stats: { additions: number; deletions: number; files: number }; +} + +export interface CheckoutParams { + operation: "checkout"; + ref?: string; + create?: boolean; + paths?: string[]; + force?: boolean; +} + +export interface CheckoutResult { + branch?: string; + previous?: string; + restoredFiles?: string[]; +} + +export interface MergeParams { + operation: "merge"; + ref: string; + message?: string; + no_ff?: boolean; + ff_only?: boolean; + squash?: boolean; + abort?: boolean; + continue?: boolean; +} + +export interface MergeResult { + status: "success" | "conflict" | "up-to-date" | "fast-forward"; + sha?: string; + conflicts?: string[]; + mergedCommits?: number; +} + +export interface RebaseParams { + operation: "rebase"; + onto?: string; + upstream?: string; + abort?: boolean; + continue?: boolean; + skip?: boolean; +} + +export interface RebaseResult { + status: "success" | "conflict" | "up-to-date"; + conflicts?: string[]; + rebasedCommits?: number; +} + +export interface StashParams { + operation: "stash"; + action?: "push" | "pop" | "apply" | "drop" | "list" | "show"; + message?: string; + include_untracked?: boolean; + index?: number; + keep_index?: boolean; +} + +export interface StashEntry { + index: number; + message: string; + branch: string; + date: string; +} + +export interface StashListResult { + stashes: StashEntry[]; +} + +export interface StashShowResult { + stats: { additions: number; deletions: number; files: number }; + files: string[]; +} + +export interface StashActionResult { + status: "success"; +} + +export type StashResult = StashListResult | StashShowResult | StashActionResult; + +export interface CherryPickParams { + operation: "cherry-pick"; + commits: string[]; + no_commit?: boolean; + abort?: boolean; + continue?: boolean; +} + +export interface CherryPickResult { + status: "success" | "conflict"; + appliedCommits?: string[]; + conflicts?: string[]; +} + +export interface FetchParams { + operation: "fetch"; + remote?: string; + branch?: string; + all?: boolean; + prune?: boolean; + tags?: boolean; +} + +export interface FetchResult { + updated: { ref: string; oldSha: string; newSha: string }[]; + pruned?: string[]; +} + +export interface PullParams { + operation: "pull"; + remote?: string; + branch?: string; + rebase?: boolean; + ff_only?: boolean; +} + +export interface PullResult { + status: "success" | "conflict" | "up-to-date"; + commits?: number; + conflicts?: string[]; +} + +export interface PushParams { + operation: "push"; + remote?: string; + branch?: string; + set_upstream?: boolean; + tags?: boolean; + force?: boolean; + force_with_lease?: boolean; + delete?: boolean; + force_override?: boolean; +} + +export interface PushResult { + remote: string; + branch: string; + commits: number; + newBranch: boolean; +} + +export interface TagParams { + operation: "tag"; + action?: "list" | "create" | "delete" | "push"; + name?: string; + message?: string; + ref?: string; + force?: boolean; + sign?: boolean; +} + +export interface TagInfo { + name: string; + sha: string; + message?: string; + tagger?: { name: string; date: string }; + annotated: boolean; +} + +export interface TagListResult { + tags: TagInfo[]; +} + +export interface TagActionResult { + status: "success"; +} + +export type TagResult = TagListResult | TagActionResult; + +export interface PRParams { + operation: "pr"; + action: "list" | "view" | "create" | "checkout" | "diff" | "merge" | "close" | "ready" | "review"; + number?: number; + title?: string; + body?: string; + base?: string; + head?: string; + draft?: boolean; + state?: "open" | "closed" | "merged" | "all"; + author?: string; + limit?: number; + merge_method?: "merge" | "squash" | "rebase"; + review_action?: "approve" | "request-changes" | "comment"; + review_body?: string; +} + +export interface PRInfo { + number: number; + title: string; + state: string; + author: string; + branch: string; + base: string; + url: string; + createdAt: string; + updatedAt: string; + additions: number; + deletions: number; + commits: number; + reviewDecision?: string; + checks?: { passing: number; failing: number; pending: number }; +} + +export interface PRListResult { + prs: PRInfo[]; +} + +export interface PRCreateResult { + number: number; + url: string; +} + +export interface PRActionResult { + status: "success"; + diff?: string; +} + +export type PRResult = PRListResult | PRCreateResult | PRActionResult | { pr: PRInfo }; + +export interface IssueParams { + operation: "issue"; + action: "list" | "view" | "create" | "close" | "reopen" | "comment"; + number?: number; + title?: string; + body?: string; + state?: "open" | "closed" | "all"; + labels?: string[]; + assignee?: string; + limit?: number; + comment_body?: string; +} + +export interface IssueInfo { + number: number; + title: string; + state: string; + author: string; + body: string; + labels: string[]; + assignees: string[]; + url: string; + createdAt: string; + comments: number; +} + +export interface IssueListResult { + issues: IssueInfo[]; +} + +export interface IssueCreateResult { + number: number; + url: string; +} + +export interface IssueActionResult { + status: "success"; +} + +export type IssueResult = IssueListResult | IssueCreateResult | IssueActionResult | { issue: IssueInfo }; + +export interface CIParams { + operation: "ci"; + action: "list" | "view" | "watch" | "run" | "cancel" | "rerun"; + workflow?: string; + run_id?: number; + limit?: number; + branch?: string; + inputs?: Record; + logs_failed?: boolean; +} + +export interface RunInfo { + id: number; + name: string; + status: "queued" | "in_progress" | "completed"; + conclusion?: "success" | "failure" | "cancelled" | "skipped"; + branch: string; + sha: string; + url: string; + createdAt: string; + updatedAt: string; +} + +export interface RunListResult { + runs: RunInfo[]; +} + +export interface JobInfo { + id: number; + name: string; + status: string; + conclusion?: string; + steps: { name: string; status: string; conclusion?: string }[]; +} + +export interface RunViewResult { + run: RunInfo; + jobs: JobInfo[]; + logs?: string; +} + +export interface CIActionResult { + status: "success"; +} + +export type CIResult = RunListResult | RunViewResult | CIActionResult; + +export interface ReleaseParams { + operation: "release"; + action: "list" | "view" | "create" | "delete" | "upload"; + tag?: string; + title?: string; + notes?: string; + generate_notes?: boolean; + draft?: boolean; + prerelease?: boolean; + target?: string; + assets?: string[]; + limit?: number; +} + +export interface ReleaseInfo { + tag: string; + name: string; + body: string; + draft: boolean; + prerelease: boolean; + createdAt: string; + publishedAt: string; + url: string; + assets: { name: string; size: number; downloadCount: number }[]; +} + +export interface ReleaseListResult { + releases: ReleaseInfo[]; +} + +export interface ReleaseActionResult { + status: "success"; + url?: string; +} + +export type ReleaseResult = ReleaseListResult | ReleaseInfo | ReleaseActionResult; + +export type GitParams = + | StatusParams + | DiffParams + | LogParams + | ShowParams + | BlameParams + | BranchParams + | AddParams + | RestoreParams + | CommitParams + | CheckoutParams + | MergeParams + | RebaseParams + | StashParams + | CherryPickParams + | FetchParams + | PullParams + | PushParams + | TagParams + | PRParams + | IssueParams + | CIParams + | ReleaseParams; + +export type SafetyLevel = "safe" | "warn" | "confirm" | "block"; + +export interface SafetyPolicy { + forcePush: SafetyLevel; + forcePushMain: SafetyLevel; + hardReset: SafetyLevel; + discardChanges: SafetyLevel; + deleteBranch: SafetyLevel; + amendPushed: SafetyLevel; + rebasePushed: SafetyLevel; +} + +export interface SafetyCheck { + level: SafetyLevel; + message: string; + suggestion?: string; + override?: string; +} + +export interface SafetyResult { + blocked: boolean; + confirm: boolean; + message?: string; + suggestion?: string; + override?: string; + warnings: string[]; +} diff --git a/packages/git-tool/src/utils.ts b/packages/git-tool/src/utils.ts new file mode 100644 index 000000000..b539d3109 --- /dev/null +++ b/packages/git-tool/src/utils.ts @@ -0,0 +1,128 @@ +import type { Subprocess } from "bun"; +import { detectGhError, detectGitError, GitError, GitErrorCode } from "./errors"; + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; + error?: GitError | null; +} + +export interface ExecOptions { + cwd?: string; + signal?: AbortSignal; + timeout?: number; +} + +async function readStream(stream: ReadableStream | undefined): Promise { + if (!stream) return ""; + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks).toString(); +} + +export async function exec(command: string, args: string[], options?: ExecOptions): Promise { + const cwd = options?.cwd ?? process.cwd(); + const proc: Subprocess = Bun.spawn([command, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let killed = false; + let timeoutId: ReturnType | undefined; + + const killProcess = () => { + if (!killed) { + killed = true; + proc.kill(); + setTimeout(() => { + try { + proc.kill(9); + } catch { + // Ignore if already dead. + } + }, 5000); + } + }; + + if (options?.signal) { + if (options.signal.aborted) { + killProcess(); + } else { + options.signal.addEventListener("abort", killProcess, { once: true }); + } + } + + if (options?.timeout && options.timeout > 0) { + timeoutId = setTimeout(() => { + killProcess(); + }, options.timeout); + } + + const [stdout, stderr, exitCode] = await Promise.all([ + readStream(proc.stdout as ReadableStream), + readStream(proc.stderr as ReadableStream), + proc.exited, + ]); + + if (timeoutId) clearTimeout(timeoutId); + if (options?.signal) { + options.signal.removeEventListener("abort", killProcess); + } + + return { stdout, stderr, exitCode: exitCode ?? 0 }; +} + +export async function git(args: string[], options?: ExecOptions): Promise { + const gitPath = Bun.which("git"); + if (!gitPath) { + return { + stdout: "", + stderr: "git not found", + exitCode: 127, + error: new GitError("git is not installed", GitErrorCode.UNKNOWN), + }; + } + const result = await exec(gitPath, args, options); + return { ...result, error: detectGitError(result.stderr, result.exitCode) }; +} + +export async function gh(args: string[], options?: ExecOptions): Promise { + const ghPath = Bun.which("gh"); + if (!ghPath) { + return { + stdout: "", + stderr: "gh not found", + exitCode: 127, + error: new GitError("GitHub CLI is not installed", GitErrorCode.GH_NOT_INSTALLED), + }; + } + const result = await exec(ghPath, args, options); + return { ...result, error: detectGhError(result.stderr, result.exitCode) }; +} + +export function parseShortstat(text: string): { files: number; additions: number; deletions: number } | null { + const match = text.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/); + if (!match) return null; + return { + files: Number.parseInt(match[1], 10), + additions: match[2] ? Number.parseInt(match[2], 10) : 0, + deletions: match[3] ? Number.parseInt(match[3], 10) : 0, + }; +} + +export function isTruthy(value: string | undefined): boolean { + if (!value) return false; + return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes"; +} diff --git a/packages/git-tool/test/cache.test.ts b/packages/git-tool/test/cache.test.ts new file mode 100644 index 000000000..fec6ac408 --- /dev/null +++ b/packages/git-tool/test/cache.test.ts @@ -0,0 +1,38 @@ +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { gitTool } from "../src/git-tool"; +import type { StatusResult, ToolResult } from "../src/types"; +import { cleanupRepo, createTestRepo, runGit, writeFile } from "./helpers"; + +let repoDir: string; +let previousCwd: string; + +beforeEach(() => { + previousCwd = process.cwd(); + repoDir = createTestRepo(); + process.chdir(repoDir); +}); + +afterEach(() => { + process.chdir(previousCwd); + cleanupRepo(repoDir); +}); + +describe("git-tool cache", () => { + it("invalidates status cache on write operations", async () => { + writeFile(join(repoDir, "file.txt"), "hello"); + runGit(["add", "file.txt"], repoDir); + runGit(["commit", "-m", "initial"], repoDir); + + writeFile(join(repoDir, "file.txt"), "hello world"); + + const status1 = (await gitTool({ operation: "status" })) as ToolResult; + expect(status1.data.modified.map((file) => file.path)).toContain("file.txt"); + + await gitTool({ operation: "add", paths: ["file.txt"] }); + + const status2 = (await gitTool({ operation: "status" })) as ToolResult; + expect(status2.data.staged.map((file) => file.path)).toContain("file.txt"); + expect(status2.data.modified.map((file) => file.path)).not.toContain("file.txt"); + }); +}); diff --git a/packages/git-tool/test/helpers.ts b/packages/git-tool/test/helpers.ts new file mode 100644 index 000000000..6e72f6fab --- /dev/null +++ b/packages/git-tool/test/helpers.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export interface GitRunResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export function runGit(args: string[], cwd: string): GitRunResult { + const result = Bun.spawnSync(["git", ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + return { + stdout: Buffer.from(result.stdout ?? []).toString(), + stderr: Buffer.from(result.stderr ?? []).toString(), + exitCode: result.exitCode ?? 0, + }; +} + +export function createTestRepo(): string { + const dir = mkdtempSync(join(tmpdir(), "git-tool-test-")); + let result = runGit(["init"], dir); + if (result.exitCode !== 0) { + throw new Error(result.stderr); + } + result = runGit(["config", "user.email", "test@example.com"], dir); + if (result.exitCode !== 0) throw new Error(result.stderr); + result = runGit(["config", "user.name", "Test User"], dir); + if (result.exitCode !== 0) throw new Error(result.stderr); + return dir; +} + +export function writeFile(path: string, content: string): void { + writeFileSync(path, content, "utf-8"); +} + +export function cleanupRepo(dir: string): void { + rmSync(dir, { recursive: true, force: true }); +} diff --git a/packages/git-tool/test/parsers.test.ts b/packages/git-tool/test/parsers.test.ts new file mode 100644 index 000000000..6e3419c81 --- /dev/null +++ b/packages/git-tool/test/parsers.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { parseBlame } from "../src/parsers/blame-parser"; +import { parseDiff } from "../src/parsers/diff-parser"; +import { parseLog } from "../src/parsers/log-parser"; +import { parseStatus } from "../src/parsers/status-parser"; + +const RS = "\x1e"; +const FS = "\x00"; + +describe("git-tool parsers", () => { + it("parses status porcelain v2", () => { + const output = [ + "# branch.head main", + "# branch.upstream origin/main", + "# branch.ab +1 -2", + "1 M. N... 100644 100644 100644 file1.txt", + "1 .M N... 100644 100644 100644 file2.txt", + "u UU N... 100644 100644 100644 conflict.txt", + "? untracked.txt", + "! ignored.log", + ].join("\n"); + + const result = parseStatus(output, true); + expect(result.branch).toBe("main"); + expect(result.upstream).toBe("origin/main"); + expect(result.ahead).toBe(1); + expect(result.behind).toBe(2); + expect(result.staged.map((item) => item.path)).toContain("file1.txt"); + expect(result.modified.map((item) => item.path)).toContain("file2.txt"); + expect(result.conflicts).toContain("conflict.txt"); + expect(result.untracked).toContain("untracked.txt"); + expect(result.ignored).toContain("ignored.log"); + }); + + it("parses unified diff", () => { + const diff = [ + "diff --git a/file.txt b/file.txt", + "index 123..456 100644", + "--- a/file.txt", + "+++ b/file.txt", + "@@ -1,2 +1,3 @@", + " line1", + "-line2", + "+line2 changed", + "+line3", + ].join("\n"); + + const result = parseDiff(diff); + expect(result.files).toHaveLength(1); + const file = result.files[0]; + expect(file.path).toBe("file.txt"); + expect(file.additions).toBe(2); + expect(file.deletions).toBe(1); + expect(file.hunks?.length).toBe(1); + }); + + it("parses log format", () => { + const record = [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "aaaaaaa", + "Alice", + "alice@example.com", + "2024-01-01T00:00:00Z", + "Bob", + "bob@example.com", + "2024-01-01T01:00:00Z", + "", + "Commit subject", + "Commit body", + ].join(FS); + const output = `${record}${RS}`; + const commits = parseLog(output); + expect(commits).toHaveLength(1); + expect(commits[0].subject).toBe("Commit subject"); + expect(commits[0].message).toContain("Commit body"); + }); + + it("parses blame porcelain", () => { + const output = [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa 1 1 1", + "author Alice", + "author-time 1700000000", + "filename file.txt", + "\tline one", + ].join("\n"); + const lines = parseBlame(output); + expect(lines).toHaveLength(1); + expect(lines[0].author).toBe("Alice"); + expect(lines[0].content).toBe("line one"); + }); +}); diff --git a/packages/git-tool/test/safety.test.ts b/packages/git-tool/test/safety.test.ts new file mode 100644 index 000000000..d3b9cb968 --- /dev/null +++ b/packages/git-tool/test/safety.test.ts @@ -0,0 +1,76 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { checkSafety } from "../src/safety/guards"; +import { cleanupRepo, createTestRepo, runGit, writeFile } from "./helpers"; + +let repoDir: string; +let previousCwd: string; + +beforeEach(() => { + previousCwd = process.cwd(); + repoDir = createTestRepo(); + process.chdir(repoDir); +}); + +afterEach(() => { + process.chdir(previousCwd); + cleanupRepo(repoDir); +}); + +describe("git-tool safety", () => { + it("blocks force push to protected branch", async () => { + runGit(["branch", "-M", "main"], repoDir); + const result = await checkSafety("push", { force: true }); + expect(result.blocked).toBe(true); + }); + + it("warns on discard changes", async () => { + const result = await checkSafety("restore", { worktree: true }); + expect(result.blocked).toBe(false); + expect(result.confirm).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it("warns on branch delete", async () => { + const result = await checkSafety("branch", { action: "delete" }); + expect(result.blocked).toBe(false); + expect(result.confirm).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + }); + + it("blocks amend when HEAD is pushed", async () => { + runGit(["branch", "-M", "main"], repoDir); + writeFile(join(repoDir, "file.txt"), "hello"); + runGit(["add", "file.txt"], repoDir); + runGit(["commit", "-m", "initial"], repoDir); + + const remoteDir = mkdtempSync(join(tmpdir(), "git-tool-remote-")); + runGit(["init", "--bare"], remoteDir); + runGit(["remote", "add", "origin", remoteDir], repoDir); + runGit(["push", "-u", "origin", "main"], repoDir); + + const result = await checkSafety("commit", { amend: true }); + expect(result.blocked).toBe(true); + + cleanupRepo(remoteDir); + }); + + it("blocks rebase when HEAD is pushed", async () => { + runGit(["branch", "-M", "main"], repoDir); + writeFile(join(repoDir, "file.txt"), "hello"); + runGit(["add", "file.txt"], repoDir); + runGit(["commit", "-m", "initial"], repoDir); + + const remoteDir = mkdtempSync(join(tmpdir(), "git-tool-remote-")); + runGit(["init", "--bare"], remoteDir); + runGit(["remote", "add", "origin", remoteDir], repoDir); + runGit(["push", "-u", "origin", "main"], repoDir); + + const result = await checkSafety("rebase", {}); + expect(result.blocked).toBe(true); + + cleanupRepo(remoteDir); + }); +}); diff --git a/packages/git-tool/tsconfig.build.json b/packages/git-tool/tsconfig.build.json new file mode 100644 index 000000000..695dd9ad2 --- /dev/null +++ b/packages/git-tool/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"] +} diff --git a/packages/git-tool/vitest.config.ts b/packages/git-tool/vitest.config.ts new file mode 100644 index 000000000..361d790c0 --- /dev/null +++ b/packages/git-tool/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 30000, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 2c0bcb18e..af4abad71 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,8 @@ "@oh-my-pi/pi-coding-agent": ["./packages/coding-agent/src/index.ts"], "@oh-my-pi/pi-coding-agent/hooks": ["./packages/coding-agent/src/core/hooks/index.ts"], "@oh-my-pi/pi-coding-agent/*": ["./packages/coding-agent/src/*"], + "@oh-my-pi/pi-git-tool": ["./packages/git-tool/src/index.ts"], + "@oh-my-pi/pi-git-tool/*": ["./packages/git-tool/src/*"], "@sinclair/typebox": ["./node_modules/@sinclair/typebox"], "@oh-my-pi/pi-mom": ["./packages/mom/src/index.ts"], "@oh-my-pi/pi-mom/*": ["./packages/mom/src/*"],