From cc2bdbf01f2036d3daa48e8984d3deaedd0f03ca Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 22 Jan 2026 12:23:01 +0700 Subject: [PATCH] Add structured agent helper and checkout git fixes --- package-lock.json | 43 +- packages/server/package.json | 4 +- .../agent/agent-response-loop.e2e.test.ts | 61 +++ .../server/agent/agent-response-loop.test.ts | 112 +++++ .../src/server/agent/agent-response-loop.ts | 204 +++++++++ packages/server/src/server/session.ts | 64 +-- .../server/src/utils/checkout-git.test.ts | 167 +++++++ packages/server/src/utils/checkout-git.ts | 411 ++++++++++++++++++ 8 files changed, 1016 insertions(+), 50 deletions(-) create mode 100644 packages/server/src/server/agent/agent-response-loop.e2e.test.ts create mode 100644 packages/server/src/server/agent/agent-response-loop.test.ts create mode 100644 packages/server/src/server/agent/agent-response-loop.ts create mode 100644 packages/server/src/utils/checkout-git.test.ts create mode 100644 packages/server/src/utils/checkout-git.ts diff --git a/package-lock.json b/package-lock.json index d23062496..88f9cc7b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9872,6 +9872,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -25187,6 +25188,7 @@ "@openrouter/ai-sdk-provider": "^1.2.0", "@xterm/headless": "^6.0.0", "ai": "^5.0.76", + "ajv": "^8.17.1", "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", @@ -25200,7 +25202,8 @@ "tiny-invariant": "^1.3.3", "uuid": "^9.0.1", "ws": "^8.14.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@playwright/test": "^1.56.1", @@ -25238,6 +25241,22 @@ "node": ">=18" } }, + "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "packages/server/node_modules/@modelcontextprotocol/sdk/node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -25293,6 +25312,28 @@ "node": ">= 0.6" } }, + "packages/server/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "packages/server/node_modules/ajv/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "packages/server/node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", diff --git a/packages/server/package.json b/packages/server/package.json index 28772ffb4..0d5739424 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -35,6 +35,7 @@ "@openrouter/ai-sdk-provider": "^1.2.0", "@xterm/headless": "^6.0.0", "ai": "^5.0.76", + "ajv": "^8.17.1", "dotenv": "^17.2.3", "express": "^4.18.2", "express-basic-auth": "^1.2.1", @@ -48,7 +49,8 @@ "tiny-invariant": "^1.3.3", "uuid": "^9.0.1", "ws": "^8.14.2", - "zod": "^3.23.8" + "zod": "^3.23.8", + "zod-to-json-schema": "^3.25.1" }, "devDependencies": { "@playwright/test": "^1.56.1", diff --git a/packages/server/src/server/agent/agent-response-loop.e2e.test.ts b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts new file mode 100644 index 000000000..d2c7e08fa --- /dev/null +++ b/packages/server/src/server/agent/agent-response-loop.e2e.test.ts @@ -0,0 +1,61 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { z } from "zod"; +import { + generateStructuredAgentResponse, +} from "./agent-response-loop.js"; +import { AgentManager } from "./agent-manager.js"; +import { createAllClients, shutdownProviders } from "./provider-registry.js"; +import pino from "pino"; + +const CODEX_TEST_MODEL = "gpt-5.1-codex-mini"; +const CODEX_TEST_REASONING_EFFORT = "low"; + +describe("getStructuredAgentResponse (e2e)", () => { + let manager: AgentManager; + let cwd: string; + + beforeEach(async () => { + cwd = mkdtempSync(path.join(tmpdir(), "agent-response-loop-")); + const logger = pino({ level: "silent" }); + manager = new AgentManager({ + clients: createAllClients(logger), + logger, + }); + }); + + afterEach(async () => { + rmSync(cwd, { recursive: true, force: true }); + await shutdownProviders(pino({ level: "silent" })); + }, 60000); + + test( + "returns schema-valid JSON from a real Codex agent", + async () => { + const schema = z.object({ + title: z.string(), + count: z.number(), + }); + + const result = await generateStructuredAgentResponse({ + manager, + agentConfig: { + provider: "codex", + model: CODEX_TEST_MODEL, + reasoningEffort: CODEX_TEST_REASONING_EFFORT, + cwd, + title: "Structured Response Test", + }, + prompt: "Return JSON with a short title and count 2.", + schema, + maxRetries: 1, + }); + + expect(result.title.length).toBeGreaterThan(0); + expect(typeof result.count).toBe("number"); + }, + 180000 + ); +}); diff --git a/packages/server/src/server/agent/agent-response-loop.test.ts b/packages/server/src/server/agent/agent-response-loop.test.ts new file mode 100644 index 000000000..90e7afb49 --- /dev/null +++ b/packages/server/src/server/agent/agent-response-loop.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { z } from "zod"; +import { + getStructuredAgentResponse, + StructuredAgentResponseError, + type AgentCaller, +} from "./agent-response-loop.js"; + +function createScriptedCaller(responses: string[]) { + const prompts: string[] = []; + const caller: AgentCaller = async (prompt) => { + prompts.push(prompt); + const index = prompts.length - 1; + return responses[index] ?? responses[responses.length - 1] ?? ""; + }; + return { caller, prompts }; +} + +describe("getStructuredAgentResponse", () => { + it("retries on invalid JSON and succeeds", async () => { + const schema = z.object({ title: z.string() }); + const { caller, prompts } = createScriptedCaller([ + "not json", + '{"title":"ok"}', + ]); + + const result = await getStructuredAgentResponse({ + caller, + prompt: "Provide a title", + schema, + maxRetries: 2, + }); + + expect(result).toEqual({ title: "ok" }); + expect(prompts).toHaveLength(2); + expect(prompts[1]).toContain("Previous response was invalid"); + expect(prompts[1]).toContain("Invalid JSON"); + }); + + it("retries on schema mismatch with validation errors", async () => { + const schema = z.object({ count: z.number() }); + const { caller, prompts } = createScriptedCaller([ + '{"count":"nope"}', + '{"count":2}', + ]); + + const result = await getStructuredAgentResponse({ + caller, + prompt: "Provide a count", + schema, + maxRetries: 2, + }); + + expect(result).toEqual({ count: 2 }); + expect(prompts).toHaveLength(2); + expect(prompts[1]).toContain("validation errors"); + expect(prompts[1]).toContain("count"); + }); + + it("fails after maxRetries with last response and validation errors", async () => { + const schema = z.object({ count: z.number() }); + const { caller } = createScriptedCaller([ + '{"count":"nope"}', + '{"count":"still"}', + ]); + + try { + await getStructuredAgentResponse({ + caller, + prompt: "Provide a count", + schema, + maxRetries: 1, + }); + throw new Error("Expected getStructuredAgentResponse to throw"); + } catch (error) { + expect(error).toBeInstanceOf(StructuredAgentResponseError); + expect(error).toEqual( + expect.objectContaining({ + name: "StructuredAgentResponseError", + lastResponse: '{"count":"still"}', + validationErrors: expect.arrayContaining([expect.stringContaining("count")]), + }) + ); + } + }); + + it("retries on raw JSON Schema validation errors and succeeds", async () => { + const schema = { + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, + }; + const { caller, prompts } = createScriptedCaller([ + '{"name": 123}', + '{"name": "ok"}', + ]); + + const result = await getStructuredAgentResponse({ + caller, + prompt: "Provide a name", + schema, + maxRetries: 2, + }); + + expect(result).toEqual({ name: "ok" }); + expect(prompts).toHaveLength(2); + expect(prompts[1]).toContain("validation errors"); + }); +}); diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts new file mode 100644 index 000000000..2d5c98012 --- /dev/null +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -0,0 +1,204 @@ +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv"; +import type { AgentSessionConfig } from "./agent-sdk-types.js"; +import type { AgentManager } from "./agent-manager.js"; +import { getAgentProviderDefinition } from "./provider-manifest.js"; + +export type JsonSchema = Record; + +export type AgentCaller = (prompt: string) => Promise; + +export class StructuredAgentResponseError extends Error { + readonly lastResponse: string; + readonly validationErrors: string[]; + + constructor(message: string, options: { lastResponse: string; validationErrors: string[] }) { + super(message); + this.name = "StructuredAgentResponseError"; + this.lastResponse = options.lastResponse; + this.validationErrors = options.validationErrors; + } +} + +export interface StructuredAgentResponseOptions { + caller: AgentCaller; + prompt: string; + schema: z.ZodType | JsonSchema; + maxRetries?: number; + schemaName?: string; +} + +export interface StructuredAgentGenerationOptions { + manager: AgentManager; + agentConfig: AgentSessionConfig; + agentId?: string; + prompt: string; + schema: z.ZodType | JsonSchema; + maxRetries?: number; + schemaName?: string; +} + +interface SchemaValidator { + jsonSchema: JsonSchema; + validate: (value: unknown) => { ok: true; value: T } | { ok: false; errors: string[] }; +} + +function isZodSchema(value: unknown): value is z.ZodTypeAny { + return typeof (value as z.ZodTypeAny | undefined)?.safeParse === "function"; +} + +function buildZodValidator(schema: z.ZodTypeAny, schemaName: string): SchemaValidator { + const zodToJsonSchemaAny = zodToJsonSchema as unknown as ( + input: z.ZodTypeAny, + name?: string + ) => JsonSchema; + const jsonSchema = zodToJsonSchemaAny(schema, schemaName); + return { + jsonSchema, + validate: (value) => { + const result = schema.safeParse(value); + if (result.success) { + return { ok: true, value: result.data as T }; + } + const errors = result.error.issues.map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"; + return `${path}: ${issue.message}`; + }); + return { ok: false, errors }; + }, + }; +} + +function buildJsonSchemaValidator(schema: JsonSchema): SchemaValidator { + const AjvConstructor = Ajv as unknown as { + new (options?: AjvOptions): { + compile: (input: JsonSchema) => ((value: unknown) => boolean) & { + errors?: ErrorObject[] | null; + }; + }; + }; + const ajv = new AjvConstructor({ allErrors: true, strict: false }); + const validate = ajv.compile(schema); + return { + jsonSchema: schema, + validate: (value) => { + const ok = validate(value); + if (ok) { + return { ok: true, value: value as T }; + } + const errors = (validate.errors ?? []).map((error: ErrorObject) => { + const path = error.instancePath && error.instancePath.length > 0 ? error.instancePath : "(root)"; + const message = error.message ?? "is invalid"; + return `${path}: ${message}`; + }); + return { ok: false, errors }; + }, + }; +} + +function buildValidator(schema: z.ZodType | JsonSchema, schemaName: string): SchemaValidator { + if (isZodSchema(schema)) { + return buildZodValidator(schema, schemaName); + } + return buildJsonSchemaValidator(schema); +} + +function buildBasePrompt(prompt: string, jsonSchema: JsonSchema): string { + const schemaText = JSON.stringify(jsonSchema, null, 2); + return [ + prompt.trim(), + "", + "You must respond with JSON only that matches this JSON Schema:", + schemaText, + ].join("\n"); +} + +function buildRetryPrompt(basePrompt: string, errors: string[]): string { + const formattedErrors = errors.map((error) => `- ${error}`).join("\n"); + return [ + basePrompt, + "", + "Previous response was invalid with validation errors:", + formattedErrors.length > 0 ? formattedErrors : "- Unknown validation error", + "", + "Respond again with JSON only that matches the schema.", + ].join("\n"); +} + +export async function getStructuredAgentResponse( + options: StructuredAgentResponseOptions +): Promise { + const { caller, prompt, schema, maxRetries = 2, schemaName = "Response" } = options; + const validator = buildValidator(schema, schemaName); + const basePrompt = buildBasePrompt(prompt, validator.jsonSchema); + + let attemptPrompt = basePrompt; + let lastResponse = ""; + let lastErrors: string[] = []; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const response = await caller(attemptPrompt); + lastResponse = response; + + let parsed: unknown; + try { + parsed = JSON.parse(response); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + lastErrors = [`Invalid JSON: ${message}`]; + if (attempt === maxRetries) { + break; + } + attemptPrompt = buildRetryPrompt(basePrompt, lastErrors); + continue; + } + + const validation = validator.validate(parsed); + if (validation.ok) { + return validation.value; + } + + lastErrors = validation.errors; + if (attempt === maxRetries) { + break; + } + attemptPrompt = buildRetryPrompt(basePrompt, lastErrors); + } + + throw new StructuredAgentResponseError( + "Agent response did not match the required JSON schema", + { + lastResponse, + validationErrors: lastErrors, + } + ); +} + +export async function generateStructuredAgentResponse( + options: StructuredAgentGenerationOptions +): Promise { + const { manager, agentConfig, agentId, prompt, schema, maxRetries, schemaName } = options; + const modeId = + agentConfig.modeId ?? getAgentProviderDefinition(agentConfig.provider).defaultModeId ?? undefined; + const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId); + try { + const caller: AgentCaller = async (nextPrompt) => { + const result = await manager.runAgent(agent.id, nextPrompt); + return result.finalText; + }; + return await getStructuredAgentResponse({ + caller, + prompt, + schema, + maxRetries, + schemaName, + }); + } finally { + try { + await manager.closeAgent(agent.id); + } catch { + // ignore cleanup errors + } + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0c4b0a9fc..4cca704c1 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -67,12 +67,12 @@ import { generateAgentTitle, isTitleGeneratorInitialized, } from "../services/agent-title-generator.js"; +import { createWorktree, slugify, validateBranchSlug } from "../utils/worktree.js"; import { - createWorktree, - detectRepoInfo, - slugify, - validateBranchSlug, -} from "../utils/worktree.js"; + getCheckoutDiff, + getCheckoutStatus, + NotGitRepoError, +} from "../utils/checkout-git.js"; import { expandTilde } from "../utils/path.js"; import type pino from "pino"; @@ -1617,16 +1617,16 @@ export class Session { const resolvedCwd = expandTilde(cwd); try { - const repoInfo = await detectRepoInfo(resolvedCwd); + const status = await getCheckoutStatus(resolvedCwd); + if (!status.isGit) { + throw new NotGitRepoError(resolvedCwd); + } + const repoRoot = status.repoRoot ?? resolvedCwd; const { stdout: branchesRaw } = await execAsync( "git branch --format='%(refname:short)'", - { cwd: repoInfo.path, env: READ_ONLY_GIT_ENV } + { cwd: repoRoot, env: READ_ONLY_GIT_ENV } ); - const { stdout: currentRaw } = await execAsync( - "git rev-parse --abbrev-ref HEAD", - { cwd: resolvedCwd, env: READ_ONLY_GIT_ENV } - ); - const currentBranch = currentRaw.trim(); + const currentBranch = status.currentBranch ?? ""; const branches = branchesRaw .split("\n") .map((line) => line.trim()) @@ -1636,13 +1636,13 @@ export class Session { isCurrent: name === currentBranch, })); - const isDirty = await this.isWorkingTreeDirty(resolvedCwd); + const isDirty = status.isDirty ?? false; this.emit({ type: "git_repo_info_response", payload: { cwd: resolvedCwd, - repoRoot: repoInfo.path, + repoRoot, requestId, branches, currentBranch: currentBranch || null, @@ -2140,40 +2140,8 @@ export class Session { return; } - // Get diff for tracked files - const { stdout: trackedDiff } = await execAsync("git diff HEAD", { - cwd: agent.cwd, - }); - - // Get diff for untracked files (new files not yet added to git) - // Using git diff --no-index /dev/null to show new file content as additions - let untrackedDiff = ""; - try { - const { stdout: untrackedFiles } = await execAsync( - "git ls-files --others --exclude-standard", - { cwd: agent.cwd } - ); - const newFiles = untrackedFiles.trim().split("\n").filter(Boolean); - - for (const file of newFiles) { - try { - // Use git diff with --no-index to generate diff for untracked file - const { stdout: fileDiff } = await execAsync( - `git diff --no-index /dev/null "${file}" || true`, - { cwd: agent.cwd } - ); - if (fileDiff) { - untrackedDiff += fileDiff; - } - } catch { - // Ignore errors for individual files (binary files, etc.) - } - } - } catch { - // Ignore errors getting untracked files - } - - const combinedDiff = trackedDiff + untrackedDiff; + const diffResult = await getCheckoutDiff(agent.cwd, { mode: "uncommitted" }); + const combinedDiff = diffResult.diff; this.emit({ type: "git_diff_response", diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts new file mode 100644 index 000000000..0989c6369 --- /dev/null +++ b/packages/server/src/utils/checkout-git.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execSync } from "child_process"; +import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + commitAll, + getCheckoutDiff, + getCheckoutStatus, + mergeToBase, + MergeConflictError, + NotGitRepoError, +} from "./checkout-git.js"; +import { createWorktree } from "./worktree.js"; + +function initRepo(): { tempDir: string; repoDir: string } { + const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "checkout-git-test-"))); + const repoDir = join(tempDir, "repo"); + execSync(`mkdir -p ${repoDir}`); + execSync("git init -b main", { cwd: repoDir }); + execSync("git config user.email 'test@test.com'", { cwd: repoDir }); + execSync("git config user.name 'Test'", { cwd: repoDir }); + writeFileSync(join(repoDir, "file.txt"), "hello\n"); + execSync("git add .", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir }); + return { tempDir, repoDir }; +} + +describe("checkout git utilities", () => { + let tempDir: string; + let repoDir: string; + + beforeEach(() => { + const setup = initRepo(); + tempDir = setup.tempDir; + repoDir = setup.repoDir; + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("throws NotGitRepoError for non-git directories", async () => { + const nonGitDir = join(tempDir, "not-git"); + execSync(`mkdir -p ${nonGitDir}`); + + await expect( + getCheckoutDiff(nonGitDir, { mode: "uncommitted" }) + ).rejects.toBeInstanceOf(NotGitRepoError); + }); + + it("handles status/diff/commit in a normal repo", async () => { + writeFileSync(join(repoDir, "file.txt"), "updated\n"); + + const status = await getCheckoutStatus(repoDir); + expect(status.isGit).toBe(true); + expect(status.currentBranch).toBe("main"); + expect(status.isDirty).toBe(true); + + const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted" }); + expect(diff.diff).toContain("-hello"); + expect(diff.diff).toContain("+updated"); + + await commitAll(repoDir, "update file"); + + const cleanStatus = await getCheckoutStatus(repoDir); + expect(cleanStatus.isDirty).toBe(false); + const message = execSync("git log -1 --pretty=%B", { cwd: repoDir }) + .toString() + .trim(); + expect(message).toBe("update file"); + }); + + it("commits messages with quotes safely", async () => { + const message = `He said "hello" and it's fine`; + writeFileSync(join(repoDir, "file.txt"), "quoted\n"); + + await commitAll(repoDir, message); + + const logMessage = execSync("git log -1 --pretty=%B", { cwd: repoDir }) + .toString() + .trim(); + expect(logMessage).toBe(message); + }); + + it("handles status/diff/commit in a .paseo worktree", async () => { + const result = await createWorktree({ + branchName: "main", + cwd: repoDir, + worktreeSlug: "alpha", + }); + + writeFileSync(join(result.worktreePath, "file.txt"), "worktree change\n"); + + const status = await getCheckoutStatus(result.worktreePath); + expect(status.isGit).toBe(true); + expect(status.repoRoot).toBe(repoDir); + expect(status.isDirty).toBe(true); + + const diff = await getCheckoutDiff(result.worktreePath, { mode: "uncommitted" }); + expect(diff.diff).toContain("-hello"); + expect(diff.diff).toContain("+worktree change"); + + await commitAll(result.worktreePath, "worktree update"); + + const cleanStatus = await getCheckoutStatus(result.worktreePath); + expect(cleanStatus.isDirty).toBe(false); + const message = execSync("git log -1 --pretty=%B", { + cwd: result.worktreePath, + }) + .toString() + .trim(); + expect(message).toBe("worktree update"); + }); + + it("merges the current branch into base", async () => { + writeFileSync(join(repoDir, "merge.txt"), "feature\n"); + execSync("git checkout -b feature", { cwd: repoDir }); + execSync("git add merge.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'feature commit'", { cwd: repoDir }); + const featureCommit = execSync("git rev-parse HEAD", { cwd: repoDir }) + .toString() + .trim(); + + await mergeToBase(repoDir, { baseRef: "main" }); + + const baseContainsFeature = execSync(`git merge-base --is-ancestor ${featureCommit} main`, { + cwd: repoDir, + stdio: "pipe", + }); + expect(baseContainsFeature).toBeDefined(); + + const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", { cwd: repoDir }) + .toString() + .trim(); + expect(currentBranch).toBe("feature"); + }); + + it("returns typed MergeConflictError on merge conflicts", async () => { + const conflictFile = join(repoDir, "conflict.txt"); + writeFileSync(conflictFile, "base\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'add conflict file'", { + cwd: repoDir, + }); + + execSync("git checkout -b feature", { cwd: repoDir }); + writeFileSync(conflictFile, "feature change\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'feature change'", { + cwd: repoDir, + }); + + execSync("git checkout main", { cwd: repoDir }); + writeFileSync(conflictFile, "main change\n"); + execSync("git add conflict.txt", { cwd: repoDir }); + execSync("git -c commit.gpgsign=false commit -m 'main change'", { + cwd: repoDir, + }); + + execSync("git checkout feature", { cwd: repoDir }); + + await expect( + mergeToBase(repoDir, { baseRef: "main" }) + ).rejects.toBeInstanceOf(MergeConflictError); + }); +}); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts new file mode 100644 index 000000000..4893d582b --- /dev/null +++ b/packages/server/src/utils/checkout-git.ts @@ -0,0 +1,411 @@ +import { exec, execFile } from "child_process"; +import { promisify } from "util"; +import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js"; +import { parseDiff } from "../server/utils/diff-highlighter.js"; +import { detectRepoInfo } from "./worktree.js"; + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); +const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", +}; + +export class NotGitRepoError extends Error { + readonly cwd: string; + + constructor(cwd: string) { + super(`Not a git repository: ${cwd}`); + this.name = "NotGitRepoError"; + this.cwd = cwd; + } +} + +export class MergeConflictError extends Error { + readonly baseRef: string; + readonly currentBranch: string; + readonly conflictFiles: string[]; + + constructor(options: { baseRef: string; currentBranch: string; conflictFiles: string[] }) { + super(`Merge conflict while merging ${options.currentBranch} into ${options.baseRef}`); + this.name = "MergeConflictError"; + this.baseRef = options.baseRef; + this.currentBranch = options.currentBranch; + this.conflictFiles = options.conflictFiles; + } +} + +export interface AheadBehind { + ahead: number; + behind: number; +} + +export interface CheckoutStatus { + isGit: boolean; + repoRoot?: string; + currentBranch?: string | null; + isDirty?: boolean; + baseRef?: string | null; + aheadBehind?: AheadBehind | null; +} + +export interface CheckoutDiffResult { + diff: string; + structured?: ParsedDiffFile[]; +} + +export interface CheckoutDiffCompare { + mode: "uncommitted" | "base"; + baseRef?: string; + includeStructured?: boolean; +} + +export interface MergeToBaseOptions { + baseRef?: string; + mode?: "merge" | "squash"; + commitMessage?: string; +} + +function isGitError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + return /not a git repository/i.test(error.message) || /git repository/i.test(error.message); +} + +async function requireRepoInfo(cwd: string) { + try { + return await detectRepoInfo(cwd); + } catch (error) { + if (isGitError(error)) { + throw new NotGitRepoError(cwd); + } + if (error instanceof Error) { + throw new NotGitRepoError(cwd); + } + throw error; + } +} + +async function getCurrentBranch(cwd: string): Promise { + const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const branch = stdout.trim(); + return branch.length > 0 ? branch : null; +} + +async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise { + if (repoType === "bare") { + return false; + } + const { stdout } = await execAsync("git status --porcelain", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + return stdout.trim().length > 0; +} + +async function resolveBaseRef(repoRoot: string): Promise { + try { + const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", { + cwd: repoRoot, + env: READ_ONLY_GIT_ENV, + }); + const ref = stdout.trim(); + if (ref) { + return ref.replace(/^refs\/remotes\//, ""); + } + } catch { + // ignore + } + + const { stdout } = await execAsync("git branch --format='%(refname:short)'", { + cwd: repoRoot, + env: READ_ONLY_GIT_ENV, + }); + const branches = stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (branches.includes("main")) { + return "main"; + } + if (branches.includes("master")) { + return "master"; + } + + return null; +} + +async function getAheadBehind(cwd: string, baseRef: string, currentBranch: string): Promise { + if (!baseRef || !currentBranch || baseRef === currentBranch) { + return null; + } + const { stdout } = await execAsync( + `git rev-list --left-right --count ${baseRef}...${currentBranch}`, + { cwd, env: READ_ONLY_GIT_ENV } + ); + const [behindRaw, aheadRaw] = stdout.trim().split(/\s+/); + const behind = Number.parseInt(behindRaw ?? "0", 10); + const ahead = Number.parseInt(aheadRaw ?? "0", 10); + if (Number.isNaN(behind) || Number.isNaN(ahead)) { + return null; + } + return { ahead, behind }; +} + +async function getUntrackedDiff(cwd: string): Promise { + let untrackedDiff = ""; + try { + const { stdout: untrackedFiles } = await execAsync( + "git ls-files --others --exclude-standard", + { cwd, env: READ_ONLY_GIT_ENV } + ); + const newFiles = untrackedFiles.trim().split("\n").filter(Boolean); + + for (const file of newFiles) { + try { + const { stdout: fileDiff } = await execAsync( + `git diff --no-index /dev/null "${file}" || true`, + { cwd, env: READ_ONLY_GIT_ENV } + ); + if (fileDiff) { + untrackedDiff += fileDiff; + } + } catch { + // Ignore errors for individual files + } + } + } catch { + // Ignore errors getting untracked files + } + return untrackedDiff; +} + +export async function getCheckoutStatus(cwd: string): Promise { + let repoInfo: Awaited>; + try { + repoInfo = await detectRepoInfo(cwd); + } catch (error) { + if (isGitError(error)) { + return { isGit: false }; + } + throw error; + } + + const currentBranch = await getCurrentBranch(cwd); + const isDirty = await isWorkingTreeDirty(cwd, repoInfo.type); + const baseRef = await resolveBaseRef(repoInfo.path); + const aheadBehind = baseRef && currentBranch + ? await getAheadBehind(cwd, baseRef, currentBranch) + : null; + + return { + isGit: true, + repoRoot: repoInfo.path, + currentBranch, + isDirty, + baseRef, + aheadBehind, + }; +} + +export async function getCheckoutDiff( + cwd: string, + compare: CheckoutDiffCompare +): Promise { + await requireRepoInfo(cwd); + + let diff = ""; + if (compare.mode === "uncommitted") { + const { stdout: trackedDiff } = await execAsync("git diff HEAD", { + cwd, + env: READ_ONLY_GIT_ENV, + }); + const untrackedDiff = await getUntrackedDiff(cwd); + diff = trackedDiff + untrackedDiff; + } else { + const repoInfo = await detectRepoInfo(cwd); + const baseRef = compare.baseRef ?? (await resolveBaseRef(repoInfo.path)); + if (!baseRef) { + diff = ""; + } else { + const { stdout } = await execAsync(`git diff ${baseRef}...HEAD`, { + cwd, + env: READ_ONLY_GIT_ENV, + }); + diff = stdout; + } + } + + if (compare.includeStructured) { + return { diff, structured: parseDiff(diff) }; + } + return { diff }; +} + +export async function commitAll(cwd: string, message: string): Promise { + await requireRepoInfo(cwd); + await execFileAsync("git", ["add", "-A"], { cwd }); + await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { + cwd, + }); +} + +export async function mergeToBase(cwd: string, options: MergeToBaseOptions = {}): Promise { + const repoInfo = await requireRepoInfo(cwd); + const currentBranch = await getCurrentBranch(cwd); + const baseRef = options.baseRef ?? (await resolveBaseRef(repoInfo.path)); + if (!baseRef) { + throw new Error("Unable to determine base branch for merge"); + } + if (!currentBranch) { + throw new Error("Unable to determine current branch for merge"); + } + if (baseRef === currentBranch) { + return; + } + + const originalBranch = currentBranch; + const mode = options.mode ?? "merge"; + try { + await execAsync(`git checkout ${baseRef}`, { cwd }); + if (mode === "squash") { + await execAsync(`git merge --squash ${originalBranch}`, { cwd }); + const message = options.commitMessage ?? `Squash merge ${originalBranch} into ${baseRef}`; + await execFileAsync("git", ["-c", "commit.gpgsign=false", "commit", "-m", message], { cwd }); + } else { + await execAsync(`git merge ${originalBranch}`, { cwd }); + } + } catch (error) { + const errorDetails = + error instanceof Error + ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` + : String(error); + try { + const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ + execAsync("git diff --name-only --diff-filter=U", { cwd }), + execAsync("git ls-files -u", { cwd }), + execAsync("git status --porcelain", { cwd }), + ]); + const statusConflicts = statusOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) + .map((line) => line.slice(3).trim()); + const conflicts = [ + ...unmergedOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + ...lsFilesOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split("\t").pop() as string), + ...statusConflicts, + ].filter(Boolean); + const conflictDetected = + conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); + if (conflictDetected) { + try { + await execAsync("git merge --abort", { cwd }); + } catch { + // ignore + } + throw new MergeConflictError({ + baseRef, + currentBranch: originalBranch, + conflictFiles: conflicts.length > 0 ? conflicts : [], + }); + } + } catch (innerError) { + if (innerError instanceof MergeConflictError) { + throw innerError; + } + // ignore detection failures + } + + throw error; + } finally { + if (originalBranch !== baseRef) { + try { + await execAsync(`git checkout ${originalBranch}`, { cwd }); + } catch { + // ignore + } + } + } +} + +export interface CreatePullRequestOptions { + title: string; + body?: string; + base?: string; + head?: string; + draft?: boolean; +} + +export interface PullRequestStatus { + url: string; + title: string; + state: string; + baseRefName: string; + headRefName: string; +} + +async function ensureGhAvailable(cwd: string): Promise { + try { + await execAsync("gh --version", { cwd }); + } catch { + throw new Error("GitHub CLI (gh) is not available or not authenticated"); + } +} + +export async function createPullRequest(cwd: string, options: CreatePullRequestOptions): Promise<{ url: string; number: number }> { + await requireRepoInfo(cwd); + await ensureGhAvailable(cwd); + const args = ["pr", "create", "--json", "url,number", "--title", options.title]; + if (options.body) { + args.push("--body", options.body); + } + if (options.base) { + args.push("--base", options.base); + } + if (options.head) { + args.push("--head", options.head); + } + if (options.draft) { + args.push("--draft"); + } + const { stdout } = await execAsync(`gh ${args.map((arg) => `"${arg}"`).join(" ")}`, { + cwd, + }); + const parsed = JSON.parse(stdout.trim()); + return { url: parsed.url, number: parsed.number }; +} + +export async function getPullRequestStatus(cwd: string): Promise { + await requireRepoInfo(cwd); + await ensureGhAvailable(cwd); + const { stdout } = await execAsync( + "gh pr status --json url,title,state,baseRefName,headRefName", + { cwd } + ); + const parsed = JSON.parse(stdout.trim()); + const current = parsed.currentBranch; + if (!current) { + return null; + } + return { + url: current.url, + title: current.title, + state: current.state, + baseRefName: current.baseRefName, + headRefName: current.headRefName, + }; +}